[
  {
    "path": ".gitattributes",
    "content": "*.onnx filter=lfs diff=lfs merge=lfs -text\n"
  },
  {
    "path": ".github/workflows/build-release.yml",
    "content": "# name: Build and Release Chrome Extension\n\n# on:\n#   push:\n#     branches: [ master, develop ]\n#     paths:\n#       - 'app/chrome-extension/**'\n#   pull_request:\n#     branches: [ master ]\n#     paths:\n#       - 'app/chrome-extension/**'\n#   workflow_dispatch:\n\n# jobs:\n#   build-extension:\n#     runs-on: ubuntu-latest\n    \n#     steps:\n#     - name: Checkout code\n#       uses: actions/checkout@v4\n      \n#     - name: Setup Node.js\n#       uses: actions/setup-node@v4\n#       with:\n#         node-version: '18'\n#         cache: 'npm'\n#         cache-dependency-path: 'app/chrome-extension/package-lock.json'\n        \n#     - name: Install dependencies\n#       run: |\n#         cd app/chrome-extension\n#         npm ci\n        \n#     - name: Build extension\n#       run: |\n#         cd app/chrome-extension\n#         npm run build\n        \n#     - name: Create zip package\n#       run: |\n#         cd app/chrome-extension\n#         npm run zip\n        \n#     - name: Prepare release directory\n#       run: |\n#         mkdir -p releases/chrome-extension/latest\n#         mkdir -p releases/chrome-extension/$(date +%Y%m%d-%H%M%S)\n        \n#     - name: Copy release files\n#       run: |\n#         # Copy to latest\n#         cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/latest/chrome-mcp-server-latest.zip\n        \n#         # Copy to timestamped version\n#         TIMESTAMP=$(date +%Y%m%d-%H%M%S)\n#         cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/$TIMESTAMP/chrome-mcp-server-$TIMESTAMP.zip\n        \n#     - name: Upload build artifacts\n#       uses: actions/upload-artifact@v4\n#       with:\n#         name: chrome-extension-build\n#         path: releases/chrome-extension/\n#         retention-days: 30\n        \n#     - name: Commit and push releases (if on main branch)\n#       if: github.ref == 'refs/heads/main'\n#       run: |\n#         git config --local user.email \"action@github.com\"\n#         git config --local user.name \"GitHub Action\"\n#         git add releases/\n#         git diff --staged --quiet || git commit -m \"Auto-build: Update Chrome extension release [skip ci]\"\n#         git push\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.output\nstats.html\nstats-*.json\n.wxt\nweb-ext.config.ts\ndist\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n*.onnx\n\n# Environment variables\n.env\n.env.local\n.env.*.local\n\n# Prevent npm metadata pollution\nfalse/\nmetadata-v1.3/\nregistry.npmmirror.com/\nregistry.npmjs.com/\n\nother/\ntools_optimize.md\nAgents.md\nCLAUDE.md\n\n**/*/coverage/*\n\n.docs/\n.claude/"
  },
  {
    "path": ".husky/commit-msg",
    "content": "npx --no -- commitlint --edit \"$1\""
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged"
  },
  {
    "path": ".prettierignore",
    "content": "# 构建输出目录\ndist\n.output\n.wxt\n\n# 依赖\nnode_modules\n\n# 日志\nlogs\n*.log\n\n# 缓存\n.cache\n.temp\n\n# 编辑器配置\n.vscode\n!.vscode/extensions.json\n.idea\n\n# 系统文件\n.DS_Store\nThumbs.db\n\n# 打包文件\n*.zip\n*.tar.gz\n\n# 统计文件\nstats.html\nstats-*.json\n\n# 锁文件\npnpm-lock.yaml"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"printWidth\": 100,\n  \"endOfLine\": \"auto\",\n  \"proseWrap\": \"preserve\",\n  \"htmlWhitespaceSensitivity\": \"strict\"\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 hangye\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Chrome MCP Server 🚀\n\n[![Stars](https://img.shields.io/github/stars/hangwin/mcp-chrome)](https://img.shields.io/github/stars/hangwin/mcp-chrome)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-blue.svg)](https://www.typescriptlang.org/)\n[![Chrome Extension](https://img.shields.io/badge/Chrome-Extension-green.svg)](https://developer.chrome.com/docs/extensions/)\n[![Release](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)\n\n> 🌟 **Turn your Chrome browser into your intelligent assistant** - Let AI take control of your browser, transforming it into a powerful AI-controlled automation tool.\n\n**📖 Documentation**: [English](README.md) | [中文](README_zh.md)\n\n> The project is still in its early stages and is under intensive development. More features, stability improvements, and other enhancements will follow.\n\n---\n\n## 🎯 What is Chrome MCP Server?\n\nChrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) server** that exposes your Chrome browser functionality to AI assistants like Claude, enabling complex browser automation, content analysis, and semantic search. Unlike traditional browser automation tools (like Playwright), **Chrome MCP Server** directly uses your daily Chrome browser, leveraging existing user habits, configurations, and login states, allowing various large models or chatbots to take control of your browser and truly become your everyday assistant.\n\n## ✨ New Features(2025/12/30)\n\n- **A New Visual Editor for Claude Code & Codex**, for more detail here: [VisualEditor](docs/VisualEditor.md)\n\n## ✨ Core Features\n\n- 😁 **Chatbot/Model Agnostic**: Let any LLM or chatbot client or agent you prefer automate your browser\n- ⭐️ **Use Your Original Browser**: Seamlessly integrate with your existing browser environment (your configurations, login states, etc.)\n- 💻 **Fully Local**: Pure local MCP server ensuring user privacy\n- 🚄 **Streamable HTTP**: Streamable HTTP connection method\n- 🏎 **Cross-Tab**: Cross-tab context\n- 🧠 **Semantic Search**: Built-in vector database for intelligent browser tab content discovery\n- 🔍 **Smart Content Analysis**: AI-powered text extraction and similarity matching\n- 🌐 **20+ Tools**: Support for screenshots, network monitoring, interactive operations, bookmark management, browsing history, and 20+ other tools\n- 🚀 **SIMD-Accelerated AI**: Custom WebAssembly SIMD optimization for 4-8x faster vector operations\n\n## 🆚 Comparison with Similar Projects\n\n| Comparison Dimension    | Playwright-based MCP Server                                                                                               | Chrome Extension-based MCP Server                                                                      |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |\n| **Resource Usage**      | ❌ Requires launching independent browser process, installing Playwright dependencies, downloading browser binaries, etc. | ✅ No need to launch independent browser process, directly utilizes user's already open Chrome browser |\n| **User Session Reuse**  | ❌ Requires re-login                                                                                                      | ✅ Automatically uses existing login state                                                             |\n| **Browser Environment** | ❌ Clean environment lacks user settings                                                                                  | ✅ Fully preserves user environment                                                                    |\n| **API Access**          | ⚠️ Limited to Playwright API                                                                                              | ✅ Full access to Chrome native APIs                                                                   |\n| **Startup Speed**       | ❌ Requires launching browser process                                                                                     | ✅ Only needs to activate extension                                                                    |\n| **Response Speed**      | 50-200ms inter-process communication                                                                                      | ✅ Faster                                                                                              |\n\n## 🚀 Quick Start\n\n### Prerequisites\n\n- Node.js >= 20.0.0 and pnpm/npm\n- Chrome/Chromium browser\n\n### Installation Steps\n\n1. **Download the latest Chrome extension from GitHub**\n\nDownload link: https://github.com/hangwin/mcp-chrome/releases\n\n2. **Install mcp-chrome-bridge globally**\n\nnpm\n\n```bash\nnpm install -g mcp-chrome-bridge\n```\n\npnpm\n\n```bash\n# Method 1: Enable scripts globally (recommended)\npnpm config set enable-pre-post-scripts true\npnpm install -g mcp-chrome-bridge\n\n# Method 2: Manual registration (if postinstall doesn't run)\npnpm install -g mcp-chrome-bridge\nmcp-chrome-bridge register\n```\n\n> Note: pnpm v7+ disables postinstall scripts by default for security. The `enable-pre-post-scripts` setting controls whether pre/post install scripts run. If automatic registration fails, use the manual registration command above.\n\n3. **Load Chrome Extension**\n   - Open Chrome and go to `chrome://extensions/`\n   - Enable \"Developer mode\"\n   - Click \"Load unpacked\" and select `your/dowloaded/extension/folder`\n   - Click the extension icon to open the plugin, then click connect to see the MCP configuration\n     <img width=\"475\" alt=\"Screenshot 2025-06-09 15 52 06\" src=\"https://github.com/user-attachments/assets/241e57b8-c55f-41a4-9188-0367293dc5bc\" />\n\n### Usage with MCP Protocol Clients\n\n#### Using Streamable HTTP Connection (👍🏻 Recommended)\n\nAdd the following configuration to your MCP client configuration (using CherryStudio as an example):\n\n> Streamable HTTP connection method is recommended\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp-server\": {\n      \"type\": \"streamableHttp\",\n      \"url\": \"http://127.0.0.1:12306/mcp\"\n    }\n  }\n}\n```\n\n#### Using STDIO Connection (Alternative)\n\nIf your client only supports stdio connection method, please use the following approach:\n\n1. First, check the installation location of the npm package you just installed\n\n```sh\n# npm check method\nnpm list -g mcp-chrome-bridge\n# pnpm check method\npnpm list -g mcp-chrome-bridge\n```\n\nAssuming the command above outputs the path: /Users/xxx/Library/pnpm/global/5\nThen your final path would be: /Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js\n\n2. Replace the configuration below with the final path you just obtained\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp-stdio\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"node\",\n        \"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js\"\n      ]\n    }\n  }\n}\n```\n\neg：config in augment:\n\n<img width=\"494\" alt=\"截屏2025-06-22 22 11 25\" src=\"https://github.com/user-attachments/assets/48eefc0c-a257-4d3b-8bbe-d7ff716de2bf\" />\n\n## 🛠️ Available Tools\n\nComplete tool list: [Complete Tool List](docs/TOOLS.md)\n\n<details>\n<summary><strong>📊 Browser Management (6 tools)</strong></summary>\n\n- `get_windows_and_tabs` - List all browser windows and tabs\n- `chrome_navigate` - Navigate to URLs and control viewport\n- `chrome_switch_tab` - Switch the current active tab\n- `chrome_close_tabs` - Close specific tabs or windows\n- `chrome_go_back_or_forward` - Browser navigation control\n- `chrome_inject_script` - Inject content scripts into web pages\n- `chrome_send_command_to_inject_script` - Send commands to injected content scripts\n</details>\n\n<details>\n<summary><strong>📸 Screenshots & Visual (1 tool)</strong></summary>\n\n- `chrome_screenshot` - Advanced screenshot capture with element targeting, full-page support, and custom dimensions\n</details>\n\n<details>\n<summary><strong>🌐 Network Monitoring (4 tools)</strong></summary>\n\n- `chrome_network_capture_start/stop` - webRequest API network capture\n- `chrome_network_debugger_start/stop` - Debugger API with response bodies\n- `chrome_network_request` - Send custom HTTP requests\n</details>\n\n<details>\n<summary><strong>🔍 Content Analysis (4 tools)</strong></summary>\n\n- `search_tabs_content` - AI-powered semantic search across browser tabs\n- `chrome_get_web_content` - Extract HTML/text content from pages\n- `chrome_get_interactive_elements` - Find clickable elements\n- `chrome_console` - Capture and retrieve console output from browser tabs\n</details>\n\n<details>\n<summary><strong>🎯 Interaction (3 tools)</strong></summary>\n\n- `chrome_click_element` - Click elements using CSS selectors\n- `chrome_fill_or_select` - Fill forms and select options\n- `chrome_keyboard` - Simulate keyboard input and shortcuts\n</details>\n\n<details>\n<summary><strong>📚 Data Management (5 tools)</strong></summary>\n\n- `chrome_history` - Search browser history with time filters\n- `chrome_bookmark_search` - Find bookmarks by keywords\n- `chrome_bookmark_add` - Add new bookmarks with folder support\n- `chrome_bookmark_delete` - Delete bookmarks\n</details>\n\n## 🧪 Usage Examples\n\n### AI helps you summarize webpage content and automatically control Excalidraw for drawing\n\nprompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)\nInstruction: Help me summarize the current page content, then draw a diagram to aid my understanding.\nhttps://www.youtube.com/watch?v=3fBPdUBWVz0\n\nhttps://github.com/user-attachments/assets/fd17209b-303d-48db-9e5e-3717141df183\n\n### After analyzing the content of the image, the LLM automatically controls Excalidraw to replicate the image\n\nprompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)\nInstruction: First, analyze the content of the image, and then replicate the image by combining the analysis with the content of the image.\nhttps://www.youtube.com/watch?v=tEPdHZBzbZk\n\nhttps://github.com/user-attachments/assets/60d12b1a-9b74-40f4-994c-95e8fa1fc8d3\n\n### AI automatically injects scripts and modifies webpage styles\n\nprompt: [modify-web-prompt](prompt/modify-web.md)\nInstruction: Help me modify the current page's style and remove advertisements.\nhttps://youtu.be/twI6apRKHsk\n\nhttps://github.com/user-attachments/assets/69cb561c-2e1e-4665-9411-4a3185f9643e\n\n### AI automatically captures network requests for you\n\nquery: I want to know what the search API for Xiaohongshu is and what the response structure looks like\n\nhttps://youtu.be/1hHKr7XKqnQ\n\nhttps://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9\n\n### AI helps analyze your browsing history\n\nquery: Analyze my browsing history from the past month\n\nhttps://youtu.be/jf2UZfrR2Vk\n\nhttps://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae\n\n### Web page conversation\n\nquery: Translate and summarize the current web page\nhttps://youtu.be/FlJKS9UQyC8\n\nhttps://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396\n\n### AI automatically takes screenshots for you (web page screenshots)\n\nquery: Take a screenshot of Hugging Face's homepage\nhttps://youtu.be/7ycK6iksWi4\n\nhttps://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3\n\n### AI automatically takes screenshots for you (element screenshots)\n\nquery: Capture the icon from Hugging Face's homepage\nhttps://youtu.be/ev8VivANIrk\n\nhttps://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c\n\n### AI helps manage bookmarks\n\nquery: Add the current page to bookmarks and put it in an appropriate folder\n\nhttps://youtu.be/R_83arKmFTo\n\nhttps://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0\n\n### Automatically close web pages\n\nquery: Close all shadcn-related web pages\n\nhttps://youtu.be/2wzUT6eNVg4\n\nhttps://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592\n\n## 🤝 Contributing\n\nWe welcome contributions! Please see [CONTRIBUTING.md](docs/CONTRIBUTING.md) for detailed guidelines.\n\n## 🚧 Future Roadmap\n\nWe have exciting plans for the future development of Chrome MCP Server:\n\n- [ ] Authentication\n- [ ] Recording and Playback\n- [ ] Workflow Automation\n- [ ] Enhanced Browser Support (Firefox Extension)\n\n---\n\n**Want to contribute to any of these features?** Check out our [Contributing Guide](docs/CONTRIBUTING.md) and join our development community!\n\n## 📄 License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## 📚 More Documentation\n\n- [Architecture Design](docs/ARCHITECTURE.md) - Detailed technical architecture documentation\n- [TOOLS API](docs/TOOLS.md) - Complete tool API documentation\n- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issue solutions\n"
  },
  {
    "path": "README_zh.md",
    "content": "# Chrome MCP Server 🚀\n\n[![许可证: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-blue.svg)](https://www.typescriptlang.org/)\n[![Chrome 扩展](https://img.shields.io/badge/Chrome-Extension-green.svg)](https://developer.chrome.com/docs/extensions/)\n\n> 🌟 **让chrome浏览器变成你的智能助手** - 让AI接管你的浏览器，将您的浏览器转变为强大的 AI 控制自动化工具。\n\n**📖 文档**: [English](README.md) | [中文](README_zh.md)\n\n> 项目仍处于早期阶段，正在紧锣密鼓开发中，后续将有更多新功能，以及稳定性等的提升，如遇bug，请轻喷\n\n---\n\n## 🎯 什么是 Chrome MCP Server？\n\nChrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP) 服务器**，它将您的 Chrome 浏览器功能暴露给 Claude 等 AI 助手，实现复杂的浏览器自动化、内容分析和语义搜索等。与传统的浏览器自动化工具（如playwright）不同，**Chrome MCP server**直接使用您日常使用的chrome浏览器，基于现有的用户习惯和配置、登录态，让各种大模型或者各种chatbot都可以接管你的浏览器，真正成为你的日常助手\n\n## ✨ 船新的功能(2025/12/30)\n\n- **让Claude Code/Codex也能使用的可视化编辑器**, 更多详情请看: [VisualEditor](docs/VisualEditor_zh.md)\n\n## ✨ 核心特性\n\n- 😁 **chatbot/模型无关**：让任意你喜欢的llm或chatbot客户端或agent来自动化操作你的浏览器\n- ⭐️ **使用你原本的浏览器**：无缝集成用户本身的浏览器环境（你的配置、登录态等）\n- 💻 **完全本地运行**：纯本地运行的mcp server，保证用户隐私\n- 🚄 **Streamable http**：Streamable http的连接方式\n- 🏎 **跨标签页** 跨标签页的上下文\n- 🧠 **语义搜索**：内置向量数据库和本地小模型，智能发现浏览器标签页内容\n- 🔍 **智能内容分析**：AI 驱动的文本提取和相似度匹配\n- 🌐 **20+ 工具**：支持截图、网络监控、交互操作、书签管理、浏览历史等20多种工具\n- 🚀 **SIMD 加速 AI**：自定义 WebAssembly SIMD 优化，向量运算速度提升 4-8 倍\n\n## 🆚 与同类项目对比\n\n| 对比维度           | 基于Playwright的MCP Server                                          | 基于Chrome插件的MCP Server                                    |\n| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------- |\n| **资源占用**       | ❌ 需启动独立浏览器进程，需要安装Playwright依赖，下载浏览器二进制等 | ✅ 无需启动独立的浏览器进程，直接利用用户已打开的Chrome浏览器 |\n| **用户会话复用**   | ❌ 需重新登录                                                       | ✅ 自动使用已登录状态                                         |\n| **浏览器环境保持** | ❌ 干净环境缺少用户设置                                             | ✅ 完整保留用户环境                                           |\n| **API访问权限**    | ⚠️ 受限于Playwright API                                             | ✅ Chrome原生API全访问                                        |\n| **启动速度**       | ❌ 需启动浏览器进程                                                 | ✅ 只需激活插件                                               |\n| **响应速度**       | 50-200ms进程间通信                                                  | ✅ 更快                                                       |\n\n## 🚀 快速开始\n\n### 环境要求\n\n- Node.js >= 20.0.0 和 （npm 或 pnpm）\n- Chrome/Chromium 浏览器\n\n### 安装步骤\n\n1. **从github上下载最新的chrome扩展**\n\n下载地址：https://github.com/hangwin/mcp-chrome/releases\n\n2. **全局安装mcp-chrome-bridge**\n\nnpm\n\n```bash\nnpm install -g mcp-chrome-bridge\n```\n\npnpm\n\n```bash\n# 方法1：全局启用脚本（推荐）\npnpm config set enable-pre-post-scripts true\npnpm install -g mcp-chrome-bridge\n\n# 方法2：如果 postinstall 没有运行，手动注册\npnpm install -g mcp-chrome-bridge\nmcp-chrome-bridge register\n```\n\n> 注意：pnpm v7+ 默认禁用 postinstall 脚本以提高安全性。`enable-pre-post-scripts` 设置控制是否运行 pre/post 安装脚本。如果自动注册失败，请使用上述手动注册命令。\n\n3. **加载 Chrome 扩展**\n   - 打开 Chrome 并访问 `chrome://extensions/`\n   - 启用\"开发者模式\"\n   - 点击\"加载已解压的扩展程序\"，选择 `your/dowloaded/extension/folder`\n   - 点击插件图标打开插件，点击连接即可看到mcp的配置\n     <img width=\"475\" alt=\"截屏2025-06-09 15 52 06\" src=\"https://github.com/user-attachments/assets/241e57b8-c55f-41a4-9188-0367293dc5bc\" />\n\n### 在支持MCP协议的客户端中使用\n\n#### 使用streamable http的方式连接（👍🏻推荐）\n\n将以下配置添加到客户端的 MCP 配置中以cherryStudio为例：\n\n> 推荐用streamable http的连接方式\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp-server\": {\n      \"type\": \"streamableHttp\",\n      \"url\": \"http://127.0.0.1:12306/mcp\"\n    }\n  }\n}\n```\n\n#### 使用stdio的方式连接（备选）\n\n假设你的客户端仅支持stdio的连接方式，那么请使用下面的方法：\n\n1. 先查看你刚刚安装的npm包的安装位置\n\n```sh\n# npm 查看方式\nnpm list -g mcp-chrome-bridge\n# pnpm 查看方式\npnpm list -g mcp-chrome-bridge\n```\n\n假设上面的命令输出的路径是：/Users/xxx/Library/pnpm/global/5\n那么你的最终路径就是：/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js\n\n2. 把下面的配置替换成你刚刚得到的最终路径\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp-stdio\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"node\",\n        \"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js\"\n      ]\n    }\n  }\n}\n```\n\n比如：在augment中的配置如下：\n\n<img width=\"494\" alt=\"截屏2025-06-22 22 11 25\" src=\"https://github.com/user-attachments/assets/07c0b090-622b-433d-be70-44e8cb8980a5\" />\n\n## 🛠️ 可用工具\n\n完整工具列表：[完整工具列表](docs/TOOLS_zh.md)\n\n<details>\n<summary><strong>📊 浏览器管理 (6个工具)</strong></summary>\n\n- `get_windows_and_tabs` - 列出所有浏览器窗口和标签页\n- `chrome_navigate` - 导航到 URL 并控制视口\n- `chrome_switch_tab` - 切换当前显示的标签页\n- `chrome_close_tabs` - 关闭特定标签页或窗口\n- `chrome_go_back_or_forward` - 浏览器导航控制\n- `chrome_inject_script` - 向网页注入内容脚本\n- `chrome_send_command_to_inject_script` - 向已注入的内容脚本发送指令\n</details>\n\n<details>\n<summary><strong>📸 截图和视觉 (1个工具)</strong></summary>\n\n- `chrome_screenshot` - 高级截图捕获，支持元素定位、全页面和自定义尺寸\n</details>\n\n<details>\n<summary><strong>🌐 网络监控 (4个工具)</strong></summary>\n\n- `chrome_network_capture_start/stop` - webRequest API 网络捕获\n- `chrome_network_debugger_start/stop` - Debugger API 包含响应体\n- `chrome_network_request` - 发送自定义 HTTP 请求\n</details>\n\n<details>\n<summary><strong>🔍 内容分析 (4个工具)</strong></summary>\n\n- `search_tabs_content` - AI 驱动的浏览器标签页语义搜索\n- `chrome_get_web_content` - 从页面提取 HTML/文本内容\n- `chrome_get_interactive_elements` - 查找可点击元素\n- `chrome_console` - 捕获和获取浏览器标签页的控制台输出\n</details>\n\n<details>\n<summary><strong>🎯 交互操作 (3个工具)</strong></summary>\n\n- `chrome_click_element` - 使用 CSS 选择器点击元素\n- `chrome_fill_or_select` - 填充表单和选择选项\n- `chrome_keyboard` - 模拟键盘输入和快捷键\n</details>\n\n<details>\n<summary><strong>📚 数据管理 (5个工具)</strong></summary>\n\n- `chrome_history` - 搜索浏览器历史记录，支持时间过滤\n- `chrome_bookmark_search` - 按关键词查找书签\n- `chrome_bookmark_add` - 添加新书签，支持文件夹\n- `chrome_bookmark_delete` - 删除书签\n</details>\n\n## 🧪 使用示例\n\n### ai帮你总结网页内容然后自动控制excalidraw画图\n\nprompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)\n指令：帮我总结当前页面内容，然后画个图帮我理解\nhttps://www.youtube.com/watch?v=3fBPdUBWVz0\n\nhttps://github.com/user-attachments/assets/f14f79a6-9390-4821-8296-06d020bcfc07\n\n### ai先分析图片的内容元素，然后再自动控制excalidraw把图片模仿出来\n\nprompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)\n指令：先看下图片是否能用excalidraw画出来，如果则列出所需的步骤和元素，然后画出来\nhttps://www.youtube.com/watch?v=tEPdHZBzbZk\n\nhttps://github.com/user-attachments/assets/4f0600c1-bb1e-4b57-85ab-36c8bdf71c68\n\n### ai自动帮你注入脚本并修改网页的样式\n\nprompt: [modify-web-prompt](prompt/modify-web.md)\n指令：帮我修改当前页面的样式，去掉广告\nhttps://youtu.be/twI6apRKHsk\n\nhttps://github.com/user-attachments/assets/aedbe98d-e90c-4a58-a4a5-d888f7293d8e\n\n### ai自动帮你捕获网络请求\n\n指令：我想知道小红书的搜索接口是哪个，响应体结构是什么样的\nhttps://youtu.be/1hHKr7XKqnQ\n\nhttps://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9\n\n### ai帮你分析你的浏览记录\n\n指令：分析一下我近一个月的浏览记录\nhttps://youtu.be/jf2UZfrR2Vk\n\nhttps://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae\n\n### 网页对话\n\n指令：翻译并总结当前网页\nhttps://youtu.be/FlJKS9UQyC8\n\nhttps://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396\n\n### ai帮你自动截图（网页截图）\n\n指令：把huggingface的首页截个图\nhttps://youtu.be/7ycK6iksWi4\n\nhttps://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3\n\n### ai帮你自动截图（元素截图）\n\n指令：把huggingface首页的图标截取下来\nhttps://youtu.be/ev8VivANIrk\n\nhttps://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c\n\n### ai帮你管理书签\n\n指令：将当前页面添加到书签中，放到合适的文件夹\nhttps://youtu.be/R_83arKmFTo\n\nhttps://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0\n\n### 自动关闭网页\n\n指令：关闭所有shadcn相关的网页\nhttps://youtu.be/2wzUT6eNVg4\n\nhttps://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592\n\n## 🤝 贡献指南\n\n我们欢迎贡献！请查看 [CONTRIBUTING_zh.md](docs/CONTRIBUTING_zh.md) 了解详细指南。\n\n## 🚧 未来发展路线图\n\n我们对 Chrome MCP Server 的未来发展有着激动人心的计划：\n\n- [ ] 身份认证\n\n- [ ] 录制与回放\n\n- [ ] 工作流自动化\n\n- [ ] 增强浏览器支持（Firefox 扩展）\n\n---\n\n**想要为这些功能中的任何一个做贡献？** 查看我们的[贡献指南](docs/CONTRIBUTING_zh.md)并加入我们的开发社区！\n\n## 📄 许可证\n\n本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。\n\n## 📚 更多文档\n\n- [架构设计](docs/ARCHITECTURE_zh.md) - 详细的技术架构说明\n- [工具列表](docs/TOOLS_zh.md) - 完整的工具 API 文档\n- [故障排除](docs/TROUBLESHOOTING_zh.md) - 常见问题解决方案\n\n## 微信交流群\n\n拉群的目的是让踩过坑的大佬们互相帮忙解答问题，因本人平时要忙着搬砖，不一定能及时解答\n\n![IMG_6296](https://github.com/user-attachments/assets/ecd2e084-24d2-4038-b75f-3ab020b55594)\n"
  },
  {
    "path": "app/chrome-extension/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 hangwin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "app/chrome-extension/README.md",
    "content": "# WXT + Vue 3\n\nThis template should help get you started developing with Vue 3 in WXT.\n\n## Recommended IDE Setup\n\n- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar).\n"
  },
  {
    "path": "app/chrome-extension/_locales/de/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"chrome-mcp-server\",\n    \"description\": \"Erweiterungsname\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"Stellt Browser-Funktionen mit Ihrem eigenen Chrome zur Verfügung\",\n    \"description\": \"Erweiterungsbeschreibung\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"Native Server-Konfiguration\",\n    \"description\": \"Hauptabschnittstitel für Native Server-Einstellungen\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"Semantische Engine\",\n    \"description\": \"Hauptabschnittstitel für semantische Engine\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"Embedding-Modell\",\n    \"description\": \"Hauptabschnittstitel für Modellauswahl\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"Index-Datenverwaltung\",\n    \"description\": \"Hauptabschnittstitel für Datenverwaltung\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"Modell-Cache-Verwaltung\",\n    \"description\": \"Hauptabschnittstitel für Cache-Verwaltung\"\n  },\n  \"statusLabel\": {\n    \"message\": \"Status\",\n    \"description\": \"Allgemeines Statuslabel\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"Betriebsstatus\",\n    \"description\": \"Server-Betriebsstatuslabel\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"Verbindungsstatus\",\n    \"description\": \"Verbindungsstatuslabel\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"Zuletzt aktualisiert:\",\n    \"description\": \"Zeitstempel der letzten Aktualisierung\"\n  },\n  \"connectButton\": {\n    \"message\": \"Verbinden\",\n    \"description\": \"Verbinden-Schaltflächentext\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"Trennen\",\n    \"description\": \"Trennen-Schaltflächentext\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"Verbindung wird hergestellt...\",\n    \"description\": \"Verbindungsstatusmeldung\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"Verbunden\",\n    \"description\": \"Verbunden-Statusmeldung\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"Getrennt\",\n    \"description\": \"Getrennt-Statusmeldung\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"Erkennung läuft...\",\n    \"description\": \"Erkennungsstatusmeldung\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"Service läuft (Port: $PORT$)\",\n    \"description\": \"Service läuft mit Portnummer\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"Service nicht verbunden\",\n    \"description\": \"Service nicht verbunden Status\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"Verbunden, Service nicht gestartet\",\n    \"description\": \"Verbunden aber Service nicht gestartet Status\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCP Server-Konfiguration\",\n    \"description\": \"MCP Server-Konfigurationsabschnittslabel\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"Verbindungsport\",\n    \"description\": \"Verbindungsport-Eingabelabel\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"Status aktualisieren\",\n    \"description\": \"Status aktualisieren Schaltflächen-Tooltip\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"Konfiguration kopieren\",\n    \"description\": \"Konfiguration kopieren Schaltflächentext\"\n  },\n  \"retryButton\": {\n    \"message\": \"Wiederholen\",\n    \"description\": \"Wiederholen-Schaltflächentext\"\n  },\n  \"cancelButton\": {\n    \"message\": \"Abbrechen\",\n    \"description\": \"Abbrechen-Schaltflächentext\"\n  },\n  \"confirmButton\": {\n    \"message\": \"Bestätigen\",\n    \"description\": \"Bestätigen-Schaltflächentext\"\n  },\n  \"saveButton\": {\n    \"message\": \"Speichern\",\n    \"description\": \"Speichern-Schaltflächentext\"\n  },\n  \"closeButton\": {\n    \"message\": \"Schließen\",\n    \"description\": \"Schließen-Schaltflächentext\"\n  },\n  \"resetButton\": {\n    \"message\": \"Zurücksetzen\",\n    \"description\": \"Zurücksetzen-Schaltflächentext\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"Initialisierung...\",\n    \"description\": \"Initialisierung-Fortschrittsmeldung\"\n  },\n  \"processingStatus\": {\n    \"message\": \"Verarbeitung...\",\n    \"description\": \"Verarbeitung-Fortschrittsmeldung\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"Wird geladen...\",\n    \"description\": \"Ladefortschrittsmeldung\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"Wird geleert...\",\n    \"description\": \"Leerungsfortschrittsmeldung\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"Wird bereinigt...\",\n    \"description\": \"Bereinigungsfortschrittsmeldung\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"Wird heruntergeladen...\",\n    \"description\": \"Download-Fortschrittsmeldung\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"Semantische Engine bereit\",\n    \"description\": \"Semantische Engine bereit Status\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"Semantische Engine wird initialisiert...\",\n    \"description\": \"Semantische Engine Initialisierungsstatus\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"Initialisierung der semantischen Engine fehlgeschlagen\",\n    \"description\": \"Semantische Engine Initialisierung fehlgeschlagen Status\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"Semantische Engine nicht initialisiert\",\n    \"description\": \"Semantische Engine nicht initialisiert Status\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"Semantische Engine initialisieren\",\n    \"description\": \"Semantische Engine initialisieren Schaltflächentext\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"Neu initialisieren\",\n    \"description\": \"Neu initialisieren Schaltflächentext\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"Modell wird heruntergeladen... $PROGRESS$%\",\n    \"description\": \"Modell-Download-Fortschritt mit Prozentsatz\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"Modell wird gewechselt...\",\n    \"description\": \"Modellwechsel-Fortschrittsmeldung\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"Modell geladen\",\n    \"description\": \"Modell erfolgreich geladen Status\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"Modell konnte nicht geladen werden\",\n    \"description\": \"Modell-Ladefehler Status\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"Leichtgewichtiges mehrsprachiges Modell\",\n    \"description\": \"Beschreibung für leichtgewichtige Modelloption\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"Etwas größer als e5-small, aber bessere Leistung\",\n    \"description\": \"Beschreibung für mittlere Modelloption\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"Mehrsprachiges semantisches Modell\",\n    \"description\": \"Beschreibung für mehrsprachige Modelloption\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"Schnell\",\n    \"description\": \"Schnelle Leistungsanzeige\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"Ausgewogen\",\n    \"description\": \"Ausgewogene Leistungsanzeige\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"Genau\",\n    \"description\": \"Genaue Leistungsanzeige\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"Netzwerkverbindungsfehler, bitte Netzwerk prüfen und erneut versuchen\",\n    \"description\": \"Netzwerkverbindungsfehlermeldung\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"Modelldatei beschädigt oder unvollständig, bitte Download wiederholen\",\n    \"description\": \"Modell-Beschädigungsfehlermeldung\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"Unbekannter Fehler, bitte prüfen Sie, ob Ihr Netzwerk auf HuggingFace zugreifen kann\",\n    \"description\": \"Unbekannte Fehler-Rückfallmeldung\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"Zugriff verweigert\",\n    \"description\": \"Zugriff verweigert Fehlermeldung\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"Zeitüberschreitung\",\n    \"description\": \"Zeitüberschreitungsfehlermeldung\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"Indizierte Seiten\",\n    \"description\": \"Anzahl indizierter Seiten Label\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"Indexgröße\",\n    \"description\": \"Indexgröße Label\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"Aktive Tabs\",\n    \"description\": \"Anzahl aktiver Tabs Label\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"Vektordokumente\",\n    \"description\": \"Anzahl Vektordokumente Label\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"Cache-Größe\",\n    \"description\": \"Cache-Größe Label\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"Cache-Einträge\",\n    \"description\": \"Anzahl Cache-Einträge Label\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"Alle Daten löschen\",\n    \"description\": \"Alle Daten löschen Schaltflächentext\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"Gesamten Cache löschen\",\n    \"description\": \"Gesamten Cache löschen Schaltflächentext\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"Abgelaufenen Cache bereinigen\",\n    \"description\": \"Abgelaufenen Cache bereinigen Schaltflächentext\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"Daten exportieren\",\n    \"description\": \"Daten exportieren Schaltflächentext\"\n  },\n  \"importDataButton\": {\n    \"message\": \"Daten importieren\",\n    \"description\": \"Daten importieren Schaltflächentext\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"Datenlöschung bestätigen\",\n    \"description\": \"Datenlöschung bestätigen Dialogtitel\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"Einstellungen\",\n    \"description\": \"Einstellungen Dialogtitel\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"Über\",\n    \"description\": \"Über Dialogtitel\"\n  },\n  \"helpTitle\": {\n    \"message\": \"Hilfe\",\n    \"description\": \"Hilfe Dialogtitel\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"Diese Aktion löscht alle indizierten Webseiteninhalte und Vektordaten, einschließlich:\",\n    \"description\": \"Datenlöschung Warnmeldung\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"Alle Webseitentextinhaltsindizes\",\n    \"description\": \"Erster Punkt in Datenlöschungsliste\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"Vektor-Embedding-Daten\",\n    \"description\": \"Zweiter Punkt in Datenlöschungsliste\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"Suchverlauf und Cache\",\n    \"description\": \"Dritter Punkt in Datenlöschungsliste\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"Diese Aktion ist unwiderruflich! Nach dem Löschen müssen Sie Webseiten erneut durchsuchen, um den Index neu aufzubauen.\",\n    \"description\": \"Unwiderrufliche Aktion Warnung\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"Löschung bestätigen\",\n    \"description\": \"Löschung bestätigen Aktionsschaltfläche\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"Cache-Details\",\n    \"description\": \"Cache-Details Abschnittslabel\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"Keine Cache-Daten vorhanden\",\n    \"description\": \"Keine Cache-Daten verfügbar Meldung\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"Cache-Informationen werden geladen...\",\n    \"description\": \"Cache-Informationen laden Status\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"Cache wird verarbeitet...\",\n    \"description\": \"Cache verarbeiten Status\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"Abgelaufen\",\n    \"description\": \"Abgelaufenes Element Label\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"Lesezeichenleiste\",\n    \"description\": \"Lesezeichenleiste Ordnername\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"Neuer Tab\",\n    \"description\": \"Neuer Tab Label\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"Aktuelle Seite\",\n    \"description\": \"Aktuelle Seite Label\"\n  },\n  \"menuLabel\": {\n    \"message\": \"Menü\",\n    \"description\": \"Menü Barrierefreiheitslabel\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"Navigation\",\n    \"description\": \"Navigation Barrierefreiheitslabel\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"Hauptinhalt\",\n    \"description\": \"Hauptinhalt Barrierefreiheitslabel\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"Sprache\",\n    \"description\": \"Sprachauswahl Label\"\n  },\n  \"themeLabel\": {\n    \"message\": \"Design\",\n    \"description\": \"Design-Auswahl Label\"\n  },\n  \"lightTheme\": {\n    \"message\": \"Hell\",\n    \"description\": \"Helles Design Option\"\n  },\n  \"darkTheme\": {\n    \"message\": \"Dunkel\",\n    \"description\": \"Dunkles Design Option\"\n  },\n  \"autoTheme\": {\n    \"message\": \"Automatisch\",\n    \"description\": \"Automatisches Design Option\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"Erweiterte Einstellungen\",\n    \"description\": \"Erweiterte Einstellungen Abschnittslabel\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"Debug-Modus\",\n    \"description\": \"Debug-Modus Umschalter Label\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"Ausführliche Protokollierung\",\n    \"description\": \"Ausführliche Protokollierung Umschalter Label\"\n  },\n  \"successNotification\": {\n    \"message\": \"Vorgang erfolgreich abgeschlossen\",\n    \"description\": \"Allgemeine Erfolgsmeldung\"\n  },\n  \"warningNotification\": {\n    \"message\": \"Warnung: Bitte prüfen Sie vor dem Fortfahren\",\n    \"description\": \"Allgemeine Warnmeldung\"\n  },\n  \"infoNotification\": {\n    \"message\": \"Information\",\n    \"description\": \"Allgemeine Informationsmeldung\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"Konfiguration in Zwischenablage kopiert\",\n    \"description\": \"Konfiguration kopiert Erfolgsmeldung\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"Daten erfolgreich gelöscht\",\n    \"description\": \"Daten gelöscht Erfolgsmeldung\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"Bytes\",\n    \"description\": \"Bytes Einheit\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\",\n    \"description\": \"Kilobytes Einheit\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\",\n    \"description\": \"Megabytes Einheit\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\",\n    \"description\": \"Gigabytes Einheit\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"Elemente\",\n    \"description\": \"Elemente Zähleinheit\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"Seiten\",\n    \"description\": \"Seiten Zähleinheit\"\n  }\n}"
  },
  {
    "path": "app/chrome-extension/_locales/en/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"chrome-mcp-server\",\n    \"description\": \"Extension name\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"Exposes browser capabilities with your own chrome\",\n    \"description\": \"Extension description\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"Native Server Configuration\",\n    \"description\": \"Main section header for native server settings\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"Semantic Engine\",\n    \"description\": \"Main section header for semantic engine\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"Embedding Model\",\n    \"description\": \"Main section header for model selection\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"Index Data Management\",\n    \"description\": \"Main section header for data management\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"Model Cache Management\",\n    \"description\": \"Main section header for cache management\"\n  },\n  \"statusLabel\": {\n    \"message\": \"Status\",\n    \"description\": \"Generic status label\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"Running Status\",\n    \"description\": \"Server running status label\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"Connection Status\",\n    \"description\": \"Connection status label\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"Last Updated:\",\n    \"description\": \"Last updated timestamp label\"\n  },\n  \"connectButton\": {\n    \"message\": \"Connect\",\n    \"description\": \"Connect button text\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"Disconnect\",\n    \"description\": \"Disconnect button text\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"Connecting...\",\n    \"description\": \"Connecting status message\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"Connected\",\n    \"description\": \"Connected status message\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"Disconnected\",\n    \"description\": \"Disconnected status message\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"Detecting...\",\n    \"description\": \"Detecting status message\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"Service Running (Port: $PORT$)\",\n    \"description\": \"Service running with port number\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"Service Not Connected\",\n    \"description\": \"Service not connected status\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"Connected, Service Not Started\",\n    \"description\": \"Connected but service not started status\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCP Server Configuration\",\n    \"description\": \"MCP server configuration section label\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"Connection Port\",\n    \"description\": \"Connection port input label\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"Refresh Status\",\n    \"description\": \"Refresh status button tooltip\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"Copy Configuration\",\n    \"description\": \"Copy configuration button text\"\n  },\n  \"retryButton\": {\n    \"message\": \"Retry\",\n    \"description\": \"Retry button text\"\n  },\n  \"cancelButton\": {\n    \"message\": \"Cancel\",\n    \"description\": \"Cancel button text\"\n  },\n  \"confirmButton\": {\n    \"message\": \"Confirm\",\n    \"description\": \"Confirm button text\"\n  },\n  \"saveButton\": {\n    \"message\": \"Save\",\n    \"description\": \"Save button text\"\n  },\n  \"closeButton\": {\n    \"message\": \"Close\",\n    \"description\": \"Close button text\"\n  },\n  \"resetButton\": {\n    \"message\": \"Reset\",\n    \"description\": \"Reset button text\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"Initializing...\",\n    \"description\": \"Initializing progress message\"\n  },\n  \"processingStatus\": {\n    \"message\": \"Processing...\",\n    \"description\": \"Processing progress message\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"Loading...\",\n    \"description\": \"Loading progress message\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"Clearing...\",\n    \"description\": \"Clearing progress message\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"Cleaning...\",\n    \"description\": \"Cleaning progress message\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"Downloading...\",\n    \"description\": \"Downloading progress message\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"Semantic Engine Ready\",\n    \"description\": \"Semantic engine ready status\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"Semantic Engine Initializing...\",\n    \"description\": \"Semantic engine initializing status\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"Semantic Engine Initialization Failed\",\n    \"description\": \"Semantic engine initialization failed status\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"Semantic Engine Not Initialized\",\n    \"description\": \"Semantic engine not initialized status\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"Initialize Semantic Engine\",\n    \"description\": \"Initialize semantic engine button text\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"Reinitialize\",\n    \"description\": \"Reinitialize button text\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"Downloading Model... $PROGRESS$%\",\n    \"description\": \"Model download progress with percentage\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"Switching Model...\",\n    \"description\": \"Model switching progress message\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"Model Loaded\",\n    \"description\": \"Model successfully loaded status\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"Model Failed to Load\",\n    \"description\": \"Model failed to load status\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"Lightweight Multilingual Model\",\n    \"description\": \"Description for lightweight model option\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"Slightly larger than e5-small, but better performance\",\n    \"description\": \"Description for medium model option\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"Multilingual Semantic Model\",\n    \"description\": \"Description for multilingual model option\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"Fast\",\n    \"description\": \"Fast performance indicator\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"Balanced\",\n    \"description\": \"Balanced performance indicator\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"Accurate\",\n    \"description\": \"Accurate performance indicator\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"Network connection error, please check network and retry\",\n    \"description\": \"Network connection error message\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"Model file corrupted or incomplete, please retry download\",\n    \"description\": \"Model corruption error message\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"Unknown error, please check if your network can access HuggingFace\",\n    \"description\": \"Unknown error fallback message\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"Permission denied\",\n    \"description\": \"Permission denied error message\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"Operation timed out\",\n    \"description\": \"Timeout error message\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"Indexed Pages\",\n    \"description\": \"Number of indexed pages label\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"Index Size\",\n    \"description\": \"Index size label\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"Active Tabs\",\n    \"description\": \"Number of active tabs label\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"Vector Documents\",\n    \"description\": \"Number of vector documents label\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"Cache Size\",\n    \"description\": \"Cache size label\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"Cache Entries\",\n    \"description\": \"Number of cache entries label\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"Clear All Data\",\n    \"description\": \"Clear all data button text\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"Clear All Cache\",\n    \"description\": \"Clear all cache button text\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"Clean Expired Cache\",\n    \"description\": \"Clean expired cache button text\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"Export Data\",\n    \"description\": \"Export data button text\"\n  },\n  \"importDataButton\": {\n    \"message\": \"Import Data\",\n    \"description\": \"Import data button text\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"Confirm Clear Data\",\n    \"description\": \"Clear data confirmation dialog title\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"Settings\",\n    \"description\": \"Settings dialog title\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"About\",\n    \"description\": \"About dialog title\"\n  },\n  \"helpTitle\": {\n    \"message\": \"Help\",\n    \"description\": \"Help dialog title\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"This operation will clear all indexed webpage content and vector data, including:\",\n    \"description\": \"Clear data warning message\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"All webpage text content index\",\n    \"description\": \"First item in clear data list\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"Vector embedding data\",\n    \"description\": \"Second item in clear data list\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"Search history and cache\",\n    \"description\": \"Third item in clear data list\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.\",\n    \"description\": \"Irreversible operation warning\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"Confirm Clear\",\n    \"description\": \"Confirm clear action button\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"Cache Details\",\n    \"description\": \"Cache details section label\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"No cache data\",\n    \"description\": \"No cache data available message\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"Loading cache information...\",\n    \"description\": \"Loading cache information status\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"Processing cache...\",\n    \"description\": \"Processing cache status\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"Expired\",\n    \"description\": \"Expired item label\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"Bookmarks Bar\",\n    \"description\": \"Bookmarks bar folder name\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"New Tab\",\n    \"description\": \"New tab label\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"Current Page\",\n    \"description\": \"Current page label\"\n  },\n  \"menuLabel\": {\n    \"message\": \"Menu\",\n    \"description\": \"Menu accessibility label\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"Navigation\",\n    \"description\": \"Navigation accessibility label\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"Main Content\",\n    \"description\": \"Main content accessibility label\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"Language\",\n    \"description\": \"Language selector label\"\n  },\n  \"themeLabel\": {\n    \"message\": \"Theme\",\n    \"description\": \"Theme selector label\"\n  },\n  \"lightTheme\": {\n    \"message\": \"Light\",\n    \"description\": \"Light theme option\"\n  },\n  \"darkTheme\": {\n    \"message\": \"Dark\",\n    \"description\": \"Dark theme option\"\n  },\n  \"autoTheme\": {\n    \"message\": \"Auto\",\n    \"description\": \"Auto theme option\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"Advanced Settings\",\n    \"description\": \"Advanced settings section label\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"Debug Mode\",\n    \"description\": \"Debug mode toggle label\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"Verbose Logging\",\n    \"description\": \"Verbose logging toggle label\"\n  },\n  \"successNotification\": {\n    \"message\": \"Operation completed successfully\",\n    \"description\": \"Generic success notification\"\n  },\n  \"warningNotification\": {\n    \"message\": \"Warning: Please review before proceeding\",\n    \"description\": \"Generic warning notification\"\n  },\n  \"infoNotification\": {\n    \"message\": \"Information\",\n    \"description\": \"Generic info notification\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"Configuration copied to clipboard\",\n    \"description\": \"Configuration copied success message\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"Data cleared successfully\",\n    \"description\": \"Data cleared success message\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"bytes\",\n    \"description\": \"Bytes unit\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\",\n    \"description\": \"Kilobytes unit\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\",\n    \"description\": \"Megabytes unit\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\",\n    \"description\": \"Gigabytes unit\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"items\",\n    \"description\": \"Items count unit\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"pages\",\n    \"description\": \"Pages count unit\"\n  },\n  \"userscriptsManagerTitle\": {\n    \"message\": \"Userscripts Manager\",\n    \"description\": \"Options page title\"\n  },\n  \"emergencySwitchLabel\": { \"message\": \"Emergency Switch\", \"description\": \"Global disable switch\" },\n  \"createRunSectionTitle\": {\n    \"message\": \"Create / Run\",\n    \"description\": \"Create & run section title\"\n  },\n  \"nameLabel\": { \"message\": \"Name\", \"description\": \"Name input label\" },\n  \"runAtLabel\": { \"message\": \"Run At\", \"description\": \"runAt select label\" },\n  \"runAtAuto\": { \"message\": \"auto\", \"description\": \"runAt auto\" },\n  \"runAtDocumentStart\": { \"message\": \"document_start\", \"description\": \"runAt document_start\" },\n  \"runAtDocumentEnd\": { \"message\": \"document_end\", \"description\": \"runAt document_end\" },\n  \"runAtDocumentIdle\": { \"message\": \"document_idle\", \"description\": \"runAt document_idle\" },\n  \"worldLabel\": { \"message\": \"World\", \"description\": \"world select label\" },\n  \"worldAuto\": { \"message\": \"auto\", \"description\": \"world auto\" },\n  \"worldIsolated\": { \"message\": \"ISOLATED\", \"description\": \"ISOLATED world\" },\n  \"worldMain\": { \"message\": \"MAIN\", \"description\": \"MAIN world\" },\n  \"modeLabel\": { \"message\": \"Mode\", \"description\": \"mode select label\" },\n  \"modeAuto\": { \"message\": \"auto\", \"description\": \"mode auto\" },\n  \"modePersistent\": { \"message\": \"persistent\", \"description\": \"mode persistent\" },\n  \"modeCss\": { \"message\": \"css\", \"description\": \"mode css\" },\n  \"modeOnce\": { \"message\": \"once\", \"description\": \"mode once\" },\n  \"allFramesLabel\": { \"message\": \"All Frames\", \"description\": \"allFrames checkbox\" },\n  \"persistLabel\": { \"message\": \"Persist\", \"description\": \"persist checkbox\" },\n  \"dnrFallbackLabel\": { \"message\": \"DNR Fallback\", \"description\": \"dnr fallback checkbox\" },\n  \"matchesInputLabel\": { \"message\": \"Matches (comma-separated)\", \"description\": \"matches input\" },\n  \"excludesInputLabel\": {\n    \"message\": \"Excludes (comma-separated)\",\n    \"description\": \"excludes input\"\n  },\n  \"tagsInputLabel\": { \"message\": \"Tags (comma-separated)\", \"description\": \"tags input\" },\n  \"scriptLabel\": { \"message\": \"Script\", \"description\": \"script textarea label\" },\n  \"applyButton\": { \"message\": \"Apply\", \"description\": \"apply button\" },\n  \"runOnceButton\": { \"message\": \"Run Once (CDP)\", \"description\": \"run once button\" },\n  \"listSectionTitle\": { \"message\": \"List\", \"description\": \"list section title\" },\n  \"queryLabel\": { \"message\": \"Query\", \"description\": \"query input label\" },\n  \"statusAll\": { \"message\": \"all\", \"description\": \"status all\" },\n  \"statusEnabled\": { \"message\": \"enabled\", \"description\": \"status enabled\" },\n  \"statusDisabled\": { \"message\": \"disabled\", \"description\": \"status disabled\" },\n  \"domainLabel\": { \"message\": \"Domain\", \"description\": \"domain filter label\" },\n  \"exportAllButton\": { \"message\": \"Export All\", \"description\": \"export button\" },\n  \"tableHeaderName\": { \"message\": \"Name\", \"description\": \"table header name\" },\n  \"tableHeaderWorld\": { \"message\": \"World\", \"description\": \"table header world\" },\n  \"tableHeaderRunAt\": { \"message\": \"Run At\", \"description\": \"table header runAt\" },\n  \"tableHeaderUpdated\": { \"message\": \"Updated\", \"description\": \"table header updated\" },\n  \"deleteButton\": { \"message\": \"Delete\", \"description\": \"delete button\" },\n  \"placeholderOptional\": { \"message\": \"optional\", \"description\": \"generic optional placeholder\" },\n  \"placeholderMatchesExample\": {\n    \"message\": \"e.g. https://*.example.com/*\",\n    \"description\": \"matches example placeholder\"\n  },\n  \"placeholderScriptHint\": {\n    \"message\": \"Paste JS/CSS/TM here\",\n    \"description\": \"script textarea placeholder\"\n  },\n  \"placeholderDomainHint\": { \"message\": \"example.com\", \"description\": \"domain filter placeholder\" }\n}\n"
  },
  {
    "path": "app/chrome-extension/_locales/ja/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"Chrome MCPサーバー\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"自身のChromeブラウザの機能を外部に公開します\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"ネイティブサーバー設定\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"セマンティックエンジン\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"埋め込みモデル\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"インデックスデータ管理\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"モデルキャッシュ管理\"\n  },\n  \"statusLabel\": {\n    \"message\": \"ステータス\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"実行ステータス\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"接続ステータス\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"最終更新:\"\n  },\n  \"connectButton\": {\n    \"message\": \"接続\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"切断\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"接続中...\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"接続済み\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"未接続\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"検出中...\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"サービス実行中 (ポート: $1)\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"サービス未接続\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"接続済み、サービス未起動\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCPサーバー設定\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"接続ポート\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"ステータス更新\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"設定をコピー\"\n  },\n  \"retryButton\": {\n    \"message\": \"再試行\"\n  },\n  \"cancelButton\": {\n    \"message\": \"キャンセル\"\n  },\n  \"confirmButton\": {\n    \"message\": \"確認\"\n  },\n  \"saveButton\": {\n    \"message\": \"保存\"\n  },\n  \"closeButton\": {\n    \"message\": \"閉じる\"\n  },\n  \"resetButton\": {\n    \"message\": \"リセット\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"初期化中...\"\n  },\n  \"processingStatus\": {\n    \"message\": \"処理中...\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"読み込み中...\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"クリア中...\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"クリーンアップ中...\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"ダウンロード中...\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"セマンティックエンジン準備完了\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"セマンティックエンジン初期化中...\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"セマンティックエンジンの初期化に失敗しました\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"セマンティックエンジン未初期化\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"セマンティックエンジンを初期化\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"再初期化\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"モデルをダウンロード中... $1%\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"モデルを切り替え中...\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"モデル読み込み完了\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"モデルの読み込みに失敗しました\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"軽量多言語モデル\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"e5-smallよりわずかに大きいが、性能は向上\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"多言語対応セマンティックモデル\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"高速\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"バランス\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"高精度\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"ネットワーク接続エラーです。ネットワークを確認して再試行してください\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"モデルファイルが破損しているか不完全です。再ダウンロードしてください\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"不明なエラーです。ネットワークがHuggingFaceにアクセスできるか確認してください\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"権限が拒否されました\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"操作がタイムアウトしました\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"インデックス化されたページ\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"インデックスサイズ\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"アクティブなタブ\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"ベクトルドキュメント\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"キャッシュサイズ\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"キャッシュエントリ\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"全データをクリア\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"全キャッシュをクリア\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"期限切れキャッシュをクリーンアップ\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"データのエクスポート\"\n  },\n  \"importDataButton\": {\n    \"message\": \"データのインポート\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"データクリアの確認\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"設定\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"情報\"\n  },\n  \"helpTitle\": {\n    \"message\": \"ヘルプ\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"この操作は、インデックス化されたすべてのウェブページコンテンツとベクトルデータをクリアします。これには以下が含まれます：\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"すべてのウェブページテキストコンテンツインデックス\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"ベクトル埋め込みデータ\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"検索履歴とキャッシュ\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"この操作は元に戻せません！クリア後、再度ウェブページを閲覧してインデックスを再構築する必要があります。\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"クリアを確認\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"キャッシュ詳細\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"キャッシュデータがありません\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"キャッシュ情報を読み込み中...\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"キャッシュを処理中...\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"期限切れ\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"ブックマークバー\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"新しいタブ\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"現在のページ\"\n  },\n  \"menuLabel\": {\n    \"message\": \"メニュー\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"ナビゲーション\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"メインコンテンツ\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"言語\"\n  },\n  \"themeLabel\": {\n    \"message\": \"テーマ\"\n  },\n  \"lightTheme\": {\n    \"message\": \"ライト\"\n  },\n  \"darkTheme\": {\n    \"message\": \"ダーク\"\n  },\n  \"autoTheme\": {\n    \"message\": \"自動\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"詳細設定\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"デバッグモード\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"詳細ロギング\"\n  },\n  \"successNotification\": {\n    \"message\": \"操作が正常に完了しました\"\n  },\n  \"warningNotification\": {\n    \"message\": \"警告：続行する前に確認してください\"\n  },\n  \"infoNotification\": {\n    \"message\": \"情報\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"設定がクリップボードにコピーされました\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"データが正常にクリアされました\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"バイト\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"項目\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"ページ\"\n  }\n}"
  },
  {
    "path": "app/chrome-extension/_locales/ko/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"chrome-mcp-server\",\n    \"description\": \"확장 프로그램 이름\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"크롬 브라우저와 연동하여 브라우저 기능을 제어하는 MCP 서버입니다.\",\n    \"description\": \"확장 프로그램 설명\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"네이티브 서버 설정\",\n    \"description\": \"네이티브 서버 설정의 주 섹션 제목\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"시맨틱 엔진\",\n    \"description\": \"시맨틱 엔진의 주 섹션 제목\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"임베딩 모델\",\n    \"description\": \"모델 선택의 주 섹션 제목\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"인덱스 데이터 관리\",\n    \"description\": \"데이터 관리의 주 섹션 제목\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"모델 캐시 관리\",\n    \"description\": \"캐시 관리의 주 섹션 제목\"\n  },\n  \"statusLabel\": {\n    \"message\": \"상태\",\n    \"description\": \"일반 상태 레이블\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"실행 상태\",\n    \"description\": \"서버 실행 상태 레이블\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"연결 상태\",\n    \"description\": \"연결 상태 레이블\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"마지막 업데이트:\",\n    \"description\": \"마지막 업데이트 타임스탬프 레이블\"\n  },\n  \"connectButton\": {\n    \"message\": \"연결\",\n    \"description\": \"연결 버튼 텍스트\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"연결 끊기\",\n    \"description\": \"연결 끊기 버튼 텍스트\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"연결 중...\",\n    \"description\": \"연결 상태 메시지\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"연결됨\",\n    \"description\": \"연결된 상태 메시지\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"연결 끊김\",\n    \"description\": \"연결이 끊긴 상태 메시지\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"감지 중...\",\n    \"description\": \"감지 상태 메시지\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"서비스 실행 중 (포트: $PORT$)\",\n    \"description\": \"포트 번호와 함께 서비스 실행 중 상태\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"서비스에 연결되지 않음\",\n    \"description\": \"서비스가 연결되지 않은 상태\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"연결됨, 서비스 시작되지 않음\",\n    \"description\": \"연결되었지만 서비스가 시작되지 않은 상태\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCP 서버 설정\",\n    \"description\": \"MCP 서버 설정 섹션 레이블\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"연결 포트\",\n    \"description\": \"연결 포트 입력 레이블\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"상태 새로고침\",\n    \"description\": \"상태 새로고침 버튼 툴팁\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"설정 복사\",\n    \"description\": \"설정 복사 버튼 텍스트\"\n  },\n  \"retryButton\": {\n    \"message\": \"재시도\",\n    \"description\": \"재시도 버튼 텍스트\"\n  },\n  \"cancelButton\": {\n    \"message\": \"취소\",\n    \"description\": \"취소 버튼 텍스트\"\n  },\n  \"confirmButton\": {\n    \"message\": \"확인\",\n    \"description\": \"확인 버튼 텍스트\"\n  },\n  \"saveButton\": {\n    \"message\": \"저장\",\n    \"description\": \"저장 버튼 텍스트\"\n  },\n  \"closeButton\": {\n    \"message\": \"닫기\",\n    \"description\": \"닫기 버튼 텍스트\"\n  },\n  \"resetButton\": {\n    \"message\": \"초기화\",\n    \"description\": \"초기화 버튼 텍스트\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"초기화 중...\",\n    \"description\": \"초기화 진행 메시지\"\n  },\n  \"processingStatus\": {\n    \"message\": \"처리 중...\",\n    \"description\": \"처리 진행 메시지\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"로드 중...\",\n    \"description\": \"로드 진행 메시지\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"삭제 중...\",\n    \"description\": \"삭제 진행 메시지\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"정리 중...\",\n    \"description\": \"정리 진행 메시지\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"다운로드 중...\",\n    \"description\": \"다운로드 진행 메시지\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"시맨틱 엔진 준비 완료\",\n    \"description\": \"시맨틱 엔진 준비 완료 상태\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"시맨틱 엔진 초기화 중...\",\n    \"description\": \"시맨틱 엔진 초기화 상태\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"시맨틱 엔진 초기화 실패\",\n    \"description\": \"시맨틱 엔진 초기화 실패 상태\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"시맨틱 엔진이 초기화되지 않음\",\n    \"description\": \"시맨틱 엔진이 초기화되지 않은 상태\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"시맨틱 엔진 초기화\",\n    \"description\": \"시맨틱 엔진 초기화 버튼 텍스트\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"재초기화\",\n    \"description\": \"재초기화 버튼 텍스트\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"모델 다운로드 중... $PROGRESS$%\",\n    \"description\": \"백분율이 포함된 모델 다운로드 진행 상태\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"모델 전환 중...\",\n    \"description\": \"모델 전환 진행 메시지\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"모델 로드 완료\",\n    \"description\": \"모델 로드 성공 상태\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"모델 로드 실패\",\n    \"description\": \"모델 로드 실패 상태\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"경량 다국어 모델\",\n    \"description\": \"경량 모델 옵션 설명\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"e5-small보다 약간 크지만 성능이 더 좋습니다\",\n    \"description\": \"중간 모델 옵션 설명\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"다국어 시맨틱 모델\",\n    \"description\": \"다국어 모델 옵션 설명\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"빠름\",\n    \"description\": \"빠른 성능 표시\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"균형\",\n    \"description\": \"균형 잡힌 성능 표시\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"정확\",\n    \"description\": \"정확한 성능 표시\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"네트워크 연결 오류, 네트워크를 확인하고 다시 시도하세요\",\n    \"description\": \"네트워크 연결 오류 메시지\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"모델 파일이 손상되었거나 불완전합니다. 다운로드를 다시 시도하세요\",\n    \"description\": \"모델 손상 오류 메시지\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"알 수 없는 오류, 네트워크에서 HuggingFace에 접속할 수 있는지 확인하세요\",\n    \"description\": \"알 수 없는 오류 대체 메시지\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"권한이 거부되었습니다\",\n    \"description\": \"권한 거부 오류 메시지\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"작업 시간 초과\",\n    \"description\": \"시간 초과 오류 메시지\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"인덱싱된 페이지\",\n    \"description\": \"인덱싱된 페이지 수 레이블\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"인덱스 크기\",\n    \"description\": \"인덱스 크기 레이블\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"활성 탭\",\n    \"description\": \"활성 탭 수 레이블\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"벡터 문서\",\n    \"description\": \"벡터 문서 수 레이블\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"캐시 크기\",\n    \"description\": \"캐시 크기 레이블\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"캐시 항목\",\n    \"description\": \"캐시 항목 수 레이블\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"모든 데이터 지우기\",\n    \"description\": \"모든 데이터 지우기 버튼 텍스트\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"모든 캐시 지우기\",\n    \"description\": \"모든 캐시 지우기 버튼 텍스트\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"만료된 캐시 정리\",\n    \"description\": \"만료된 캐시 정리 버튼 텍스트\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"데이터 내보내기\",\n    \"description\": \"데이터 내보내기 버튼 텍스트\"\n  },\n  \"importDataButton\": {\n    \"message\": \"데이터 가져오기\",\n    \"description\": \"데이터 가져오기 버튼 텍스트\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"데이터 지우기 확인\",\n    \"description\": \"데이터 지우기 확인 대화상자 제목\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"설정\",\n    \"description\": \"설정 대화상자 제목\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"정보\",\n    \"description\": \"정보 대화상자 제목\"\n  },\n  \"helpTitle\": {\n    \"message\": \"도움말\",\n    \"description\": \"도움말 대화상자 제목\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"이 작업은 다음을 포함한 모든 인덱싱된 웹페이지 콘텐츠와 벡터 데이터를 지웁니다:\",\n    \"description\": \"데이터 지우기 경고 메시지\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"모든 웹페이지 텍스트 콘텐츠 인덱스\",\n    \"description\": \"데이터 지우기 목록 첫 번째 항목\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"벡터 임베딩 데이터\",\n    \"description\": \"데이터 지우기 목록 두 번째 항목\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"검색 기록 및 캐시\",\n    \"description\": \"데이터 지우기 목록 세 번째 항목\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"이 작업은 되돌릴 수 없습니다! 삭제 후에는 인덱스를 다시 생성하기 위해 웹페이지를 다시 방문해야 합니다.\",\n    \"description\": \"되돌릴 수 없는 작업 경고\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"삭제 확인\",\n    \"description\": \"삭제 작업 확인 버튼\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"캐시 정보\",\n    \"description\": \"캐시 정보 섹션 레이블\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"캐시 데이터 없음\",\n    \"description\": \"사용 가능한 캐시 데이터 없음 메시지\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"캐시 정보를 불러오는 중...\",\n    \"description\": \"캐시 정보 로드 상태\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"캐시 처리 중...\",\n    \"description\": \"캐시 처리 상태\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"만료됨\",\n    \"description\": \"만료된 항목 레이블\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"북마크바\",\n    \"description\": \"북마크바 폴더 이름\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"새 탭\",\n    \"description\": \"새 탭 레이블\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"현재 페이지\",\n    \"description\": \"현재 페이지 레이블\"\n  },\n  \"menuLabel\": {\n    \"message\": \"메뉴\",\n    \"description\": \"메뉴 접근성 레이블\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"탐색\",\n    \"description\": \"탐색 접근성 레이블\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"주요 콘텐츠\",\n    \"description\": \"주요 콘텐츠 접근성 레이블\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"언어\",\n    \"description\": \"언어 선택기 레이블\"\n  },\n  \"themeLabel\": {\n    \"message\": \"테마\",\n    \"description\": \"테마 선택기 레이블\"\n  },\n  \"lightTheme\": {\n    \"message\": \"라이트\",\n    \"description\": \"라이트 테마 옵션\"\n  },\n  \"darkTheme\": {\n    \"message\": \"다크\",\n    \"description\": \"다크 테마 옵션\"\n  },\n  \"autoTheme\": {\n    \"message\": \"자동\",\n    \"description\": \"자동 테마 옵션\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"고급 설정\",\n    \"description\": \"고급 설정 섹션 레이블\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"디버그 모드\",\n    \"description\": \"디버그 모드 토글 레이블\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"상세 로깅\",\n    \"description\": \"상세 로깅 토글 레이블\"\n  },\n  \"successNotification\": {\n    \"message\": \"작업이 성공적으로 완료되었습니다\",\n    \"description\": \"일반 성공 알림\"\n  },\n  \"warningNotification\": {\n    \"message\": \"경고: 계속하기 전에 검토하세요\",\n    \"description\": \"일반 경고 알림\"\n  },\n  \"infoNotification\": {\n    \"message\": \"정보\",\n    \"description\": \"일반 정보 알림\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"설정이 클립보드에 복사되었습니다\",\n    \"description\": \"설정 복사 성공 메시지\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"데이터가 성공적으로 삭제되었습니다\",\n    \"description\": \"데이터 삭제 성공 메시지\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"바이트\",\n    \"description\": \"바이트 단위\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\",\n    \"description\": \"킬로바이트 단위\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\",\n    \"description\": \"메가바이트 단위\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\",\n    \"description\": \"기가바이트 단위\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"개\",\n    \"description\": \"항목 개수 단위\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"페이지\",\n    \"description\": \"페이지 수 단위\"\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/_locales/zh_CN/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"chrome-mcp-server\",\n    \"description\": \"扩展名称\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"使用你自己的 Chrome 浏览器暴露浏览器功能\",\n    \"description\": \"扩展描述\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"Native Server 配置\",\n    \"description\": \"本地服务器设置的主要节标题\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"语义引擎\",\n    \"description\": \"语义引擎的主要节标题\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"Embedding模型\",\n    \"description\": \"模型选择的主要节标题\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"索引数据管理\",\n    \"description\": \"数据管理的主要节标题\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"模型缓存管理\",\n    \"description\": \"缓存管理的主要节标题\"\n  },\n  \"statusLabel\": {\n    \"message\": \"状态\",\n    \"description\": \"通用状态标签\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"运行状态\",\n    \"description\": \"服务器运行状态标签\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"连接状态\",\n    \"description\": \"连接状态标签\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"最后更新:\",\n    \"description\": \"最后更新时间戳标签\"\n  },\n  \"connectButton\": {\n    \"message\": \"连接\",\n    \"description\": \"连接按钮文本\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"断开\",\n    \"description\": \"断开连接按钮文本\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"连接中...\",\n    \"description\": \"连接状态消息\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"已连接\",\n    \"description\": \"已连接状态消息\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"已断开\",\n    \"description\": \"已断开状态消息\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"检测中...\",\n    \"description\": \"检测状态消息\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"服务运行中 (端口: $PORT$)\",\n    \"description\": \"带端口号的服务运行状态\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"服务未连接\",\n    \"description\": \"服务未连接状态\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"已连接，服务未启动\",\n    \"description\": \"已连接但服务未启动状态\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCP 服务器配置\",\n    \"description\": \"MCP 服务器配置节标签\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"连接端口\",\n    \"description\": \"连接端口输入标签\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"刷新状态\",\n    \"description\": \"刷新状态按钮提示\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"复制配置\",\n    \"description\": \"复制配置按钮文本\"\n  },\n  \"retryButton\": {\n    \"message\": \"重试\",\n    \"description\": \"重试按钮文本\"\n  },\n  \"cancelButton\": {\n    \"message\": \"取消\",\n    \"description\": \"取消按钮文本\"\n  },\n  \"confirmButton\": {\n    \"message\": \"确认\",\n    \"description\": \"确认按钮文本\"\n  },\n  \"saveButton\": {\n    \"message\": \"保存\",\n    \"description\": \"保存按钮文本\"\n  },\n  \"closeButton\": {\n    \"message\": \"关闭\",\n    \"description\": \"关闭按钮文本\"\n  },\n  \"resetButton\": {\n    \"message\": \"重置\",\n    \"description\": \"重置按钮文本\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"初始化中...\",\n    \"description\": \"初始化进度消息\"\n  },\n  \"processingStatus\": {\n    \"message\": \"处理中...\",\n    \"description\": \"处理进度消息\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"加载中...\",\n    \"description\": \"加载进度消息\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"清空中...\",\n    \"description\": \"清空进度消息\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"清理中...\",\n    \"description\": \"清理进度消息\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"下载中...\",\n    \"description\": \"下载进度消息\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"语义引擎已就绪\",\n    \"description\": \"语义引擎就绪状态\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"语义引擎初始化中...\",\n    \"description\": \"语义引擎初始化状态\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"语义引擎初始化失败\",\n    \"description\": \"语义引擎初始化失败状态\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"语义引擎未初始化\",\n    \"description\": \"语义引擎未初始化状态\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"初始化语义引擎\",\n    \"description\": \"初始化语义引擎按钮文本\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"重新初始化\",\n    \"description\": \"重新初始化按钮文本\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"下载模型中... $PROGRESS$%\",\n    \"description\": \"带百分比的模型下载进度\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"切换模型中...\",\n    \"description\": \"模型切换进度消息\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"模型已加载\",\n    \"description\": \"模型成功加载状态\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"模型加载失败\",\n    \"description\": \"模型加载失败状态\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"轻量级多语言模型\",\n    \"description\": \"轻量级模型选项的描述\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"比e5-small稍大，但效果更好\",\n    \"description\": \"中等模型选项的描述\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"多语言语义模型\",\n    \"description\": \"多语言模型选项的描述\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"快速\",\n    \"description\": \"快速性能指示器\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"平衡\",\n    \"description\": \"平衡性能指示器\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"精确\",\n    \"description\": \"精确性能指示器\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"网络连接错误，请检查网络连接后重试\",\n    \"description\": \"网络连接错误消息\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"模型文件损坏或不完整，请重试下载\",\n    \"description\": \"模型损坏错误消息\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"未知错误，请检查你的网络是否可以访问HuggingFace\",\n    \"description\": \"未知错误回退消息\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"权限被拒绝\",\n    \"description\": \"权限被拒绝错误消息\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"操作超时\",\n    \"description\": \"超时错误消息\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"已索引页面\",\n    \"description\": \"已索引页面数量标签\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"索引大小\",\n    \"description\": \"索引大小标签\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"活跃标签页\",\n    \"description\": \"活跃标签页数量标签\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"向量文档\",\n    \"description\": \"向量文档数量标签\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"缓存大小\",\n    \"description\": \"缓存大小标签\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"缓存条目\",\n    \"description\": \"缓存条目数量标签\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"清空所有数据\",\n    \"description\": \"清空所有数据按钮文本\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"清空所有缓存\",\n    \"description\": \"清空所有缓存按钮文本\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"清理过期缓存\",\n    \"description\": \"清理过期缓存按钮文本\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"导出数据\",\n    \"description\": \"导出数据按钮文本\"\n  },\n  \"importDataButton\": {\n    \"message\": \"导入数据\",\n    \"description\": \"导入数据按钮文本\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"确认清空数据\",\n    \"description\": \"清空数据确认对话框标题\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"设置\",\n    \"description\": \"设置对话框标题\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"关于\",\n    \"description\": \"关于对话框标题\"\n  },\n  \"helpTitle\": {\n    \"message\": \"帮助\",\n    \"description\": \"帮助对话框标题\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"此操作将清空所有已索引的网页内容和向量数据，包括：\",\n    \"description\": \"清空数据警告消息\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"所有网页的文本内容索引\",\n    \"description\": \"清空数据列表第一项\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"向量嵌入数据\",\n    \"description\": \"清空数据列表第二项\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"搜索历史和缓存\",\n    \"description\": \"清空数据列表第三项\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"此操作不可撤销！清空后需要重新浏览网页来重建索引。\",\n    \"description\": \"不可逆操作警告\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"确认清空\",\n    \"description\": \"确认清空操作按钮\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"缓存详情\",\n    \"description\": \"缓存详情节标签\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"暂无缓存数据\",\n    \"description\": \"无缓存数据可用消息\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"正在加载缓存信息...\",\n    \"description\": \"加载缓存信息状态\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"处理缓存中...\",\n    \"description\": \"处理缓存状态\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"已过期\",\n    \"description\": \"过期项标签\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"书签栏\",\n    \"description\": \"书签栏文件夹名称\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"新标签页\",\n    \"description\": \"新标签页标签\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"当前页面\",\n    \"description\": \"当前页面标签\"\n  },\n  \"menuLabel\": {\n    \"message\": \"菜单\",\n    \"description\": \"菜单辅助功能标签\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"导航\",\n    \"description\": \"导航辅助功能标签\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"主要内容\",\n    \"description\": \"主要内容辅助功能标签\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"语言\",\n    \"description\": \"语言选择器标签\"\n  },\n  \"themeLabel\": {\n    \"message\": \"主题\",\n    \"description\": \"主题选择器标签\"\n  },\n  \"lightTheme\": {\n    \"message\": \"浅色\",\n    \"description\": \"浅色主题选项\"\n  },\n  \"darkTheme\": {\n    \"message\": \"深色\",\n    \"description\": \"深色主题选项\"\n  },\n  \"autoTheme\": {\n    \"message\": \"自动\",\n    \"description\": \"自动主题选项\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"高级设置\",\n    \"description\": \"高级设置节标签\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"调试模式\",\n    \"description\": \"调试模式切换标签\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"详细日志\",\n    \"description\": \"详细日志切换标签\"\n  },\n  \"successNotification\": {\n    \"message\": \"操作成功完成\",\n    \"description\": \"通用成功通知\"\n  },\n  \"warningNotification\": {\n    \"message\": \"警告：请在继续之前检查\",\n    \"description\": \"通用警告通知\"\n  },\n  \"infoNotification\": {\n    \"message\": \"信息\",\n    \"description\": \"通用信息通知\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"配置已复制到剪贴板\",\n    \"description\": \"配置复制成功消息\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"数据清空成功\",\n    \"description\": \"数据清空成功消息\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"字节\",\n    \"description\": \"字节单位\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\",\n    \"description\": \"千字节单位\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\",\n    \"description\": \"兆字节单位\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\",\n    \"description\": \"吉字节单位\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"项\",\n    \"description\": \"项目计数单位\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"页\",\n    \"description\": \"页面计数单位\"\n  },\n  \"userscriptsManagerTitle\": { \"message\": \"脚本管理器\", \"description\": \"Options 页标题\" },\n  \"emergencySwitchLabel\": { \"message\": \"紧急开关\", \"description\": \"紧急关闭开关\" },\n  \"createRunSectionTitle\": { \"message\": \"创建 / 运行\", \"description\": \"创建与运行分区标题\" },\n  \"nameLabel\": { \"message\": \"名称\", \"description\": \"名称输入标签\" },\n  \"runAtLabel\": { \"message\": \"运行时机\", \"description\": \"runAt 选择标签\" },\n  \"runAtAuto\": { \"message\": \"自动\", \"description\": \"runAt auto\" },\n  \"runAtDocumentStart\": { \"message\": \"document_start\", \"description\": \"runAt document_start\" },\n  \"runAtDocumentEnd\": { \"message\": \"document_end\", \"description\": \"runAt document_end\" },\n  \"runAtDocumentIdle\": { \"message\": \"document_idle\", \"description\": \"runAt document_idle\" },\n  \"worldLabel\": { \"message\": \"执行上下文\", \"description\": \"world 选择标签\" },\n  \"worldAuto\": { \"message\": \"自动\", \"description\": \"world auto\" },\n  \"worldIsolated\": { \"message\": \"隔离 (ISOLATED)\", \"description\": \"ISOLATED world\" },\n  \"worldMain\": { \"message\": \"页面 (MAIN)\", \"description\": \"MAIN world\" },\n  \"modeLabel\": { \"message\": \"模式\", \"description\": \"模式选择标签\" },\n  \"modeAuto\": { \"message\": \"自动\", \"description\": \"mode auto\" },\n  \"modePersistent\": { \"message\": \"持久\", \"description\": \"mode persistent\" },\n  \"modeCss\": { \"message\": \"仅样式 (CSS)\", \"description\": \"mode css\" },\n  \"modeOnce\": { \"message\": \"一次运行 (CDP)\", \"description\": \"mode once\" },\n  \"allFramesLabel\": { \"message\": \"全部 frame\", \"description\": \"allFrames 复选框\" },\n  \"persistLabel\": { \"message\": \"持久化\", \"description\": \"persist 复选框\" },\n  \"dnrFallbackLabel\": { \"message\": \"DNR 回退\", \"description\": \"DNR fallback 复选框\" },\n  \"matchesInputLabel\": { \"message\": \"匹配（逗号分隔）\", \"description\": \"matches 输入\" },\n  \"excludesInputLabel\": { \"message\": \"排除（逗号分隔）\", \"description\": \"excludes 输入\" },\n  \"tagsInputLabel\": { \"message\": \"标签（逗号分隔）\", \"description\": \"tags 输入\" },\n  \"scriptLabel\": { \"message\": \"脚本\", \"description\": \"脚本文本标签\" },\n  \"applyButton\": { \"message\": \"应用\", \"description\": \"应用按钮\" },\n  \"runOnceButton\": { \"message\": \"一次运行（CDP）\", \"description\": \"一次运行按钮\" },\n  \"listSectionTitle\": { \"message\": \"脚本列表\", \"description\": \"列表分区标题\" },\n  \"queryLabel\": { \"message\": \"搜索\", \"description\": \"查询输入标签\" },\n  \"statusAll\": { \"message\": \"全部\", \"description\": \"状态-全部\" },\n  \"statusEnabled\": { \"message\": \"启用\", \"description\": \"状态-启用\" },\n  \"statusDisabled\": { \"message\": \"禁用\", \"description\": \"状态-禁用\" },\n  \"domainLabel\": { \"message\": \"域名\", \"description\": \"域名过滤标签\" },\n  \"exportAllButton\": { \"message\": \"导出全部\", \"description\": \"导出按钮\" },\n  \"tableHeaderName\": { \"message\": \"名称\", \"description\": \"表头-名称\" },\n  \"tableHeaderWorld\": { \"message\": \"执行上下文\", \"description\": \"表头-World\" },\n  \"tableHeaderRunAt\": { \"message\": \"运行时机\", \"description\": \"表头-RunAt\" },\n  \"tableHeaderUpdated\": { \"message\": \"更新时间\", \"description\": \"表头-更新时间\" },\n  \"deleteButton\": { \"message\": \"删除\", \"description\": \"删除按钮\" },\n  \"placeholderOptional\": { \"message\": \"可选\", \"description\": \"通用可选占位符\" },\n  \"placeholderMatchesExample\": {\n    \"message\": \"例如：https://*.example.com/*\",\n    \"description\": \"匹配示例占位符\"\n  },\n  \"placeholderScriptHint\": { \"message\": \"在此粘贴 JS/CSS/TM\", \"description\": \"脚本文本域占位符\" },\n  \"placeholderDomainHint\": { \"message\": \"example.com\", \"description\": \"域名筛选占位符\" }\n}\n"
  },
  {
    "path": "app/chrome-extension/_locales/zh_TW/messages.json",
    "content": "{\n  \"extensionName\": {\n    \"message\": \"chrome-mcp-server\",\n    \"description\": \"擴充功能名稱\"\n  },\n  \"extensionDescription\": {\n    \"message\": \"使用您自己的 Chrome 瀏覽器暴露瀏覽器功能\",\n    \"description\": \"擴充功能描述\"\n  },\n  \"nativeServerConfigLabel\": {\n    \"message\": \"原生伺服器設定\",\n    \"description\": \"本機伺服器設定的主要區段標題\"\n  },\n  \"semanticEngineLabel\": {\n    \"message\": \"語意引擎\",\n    \"description\": \"語意引擎的主要區段標題\"\n  },\n  \"embeddingModelLabel\": {\n    \"message\": \"Embedding 模型\",\n    \"description\": \"模型選擇的主要區段標題\"\n  },\n  \"indexDataManagementLabel\": {\n    \"message\": \"索引資料管理\",\n    \"description\": \"資料管理主要區段標題\"\n  },\n  \"modelCacheManagementLabel\": {\n    \"message\": \"模型快取管理\",\n    \"description\": \"快取管理主要區段標題\"\n  },\n  \"statusLabel\": {\n    \"message\": \"狀態\",\n    \"description\": \"通用狀態標籤\"\n  },\n  \"runningStatusLabel\": {\n    \"message\": \"執行狀態\",\n    \"description\": \"伺服器執行狀態標籤\"\n  },\n  \"connectionStatusLabel\": {\n    \"message\": \"連線狀態\",\n    \"description\": \"連線狀態標籤\"\n  },\n  \"lastUpdatedLabel\": {\n    \"message\": \"最後更新：\",\n    \"description\": \"最後更新時間戳標籤\"\n  },\n  \"connectButton\": {\n    \"message\": \"連線\",\n    \"description\": \"連線按鈕文字\"\n  },\n  \"disconnectButton\": {\n    \"message\": \"中斷連線\",\n    \"description\": \"中斷連線按鈕文字\"\n  },\n  \"connectingStatus\": {\n    \"message\": \"連線中...\",\n    \"description\": \"連線狀態訊息\"\n  },\n  \"connectedStatus\": {\n    \"message\": \"已連線\",\n    \"description\": \"已連線狀態訊息\"\n  },\n  \"disconnectedStatus\": {\n    \"message\": \"已中斷\",\n    \"description\": \"已中斷狀態訊息\"\n  },\n  \"detectingStatus\": {\n    \"message\": \"偵測中...\",\n    \"description\": \"偵測狀態訊息\"\n  },\n  \"serviceRunningStatus\": {\n    \"message\": \"服務執行中 (連結埠: $PORT$)\",\n    \"description\": \"含連結埠號的服務執行狀態\",\n    \"placeholders\": {\n      \"port\": {\n        \"content\": \"$1\",\n        \"example\": \"12306\"\n      }\n    }\n  },\n  \"serviceNotConnectedStatus\": {\n    \"message\": \"服務未連線\",\n    \"description\": \"服務未連線狀態\"\n  },\n  \"connectedServiceNotStartedStatus\": {\n    \"message\": \"已連線，服務未啟動\",\n    \"description\": \"已連線但服務未啟動狀態\"\n  },\n  \"mcpServerConfigLabel\": {\n    \"message\": \"MCP 伺服器設定\",\n    \"description\": \"MCP 伺服器設定區段標籤\"\n  },\n  \"connectionPortLabel\": {\n    \"message\": \"連結埠\",\n    \"description\": \"連結埠輸入標籤\"\n  },\n  \"refreshStatusButton\": {\n    \"message\": \"重新整理狀態\",\n    \"description\": \"重新整理狀態按鈕提示\"\n  },\n  \"copyConfigButton\": {\n    \"message\": \"複製設定\",\n    \"description\": \"複製設定按鈕文字\"\n  },\n  \"retryButton\": {\n    \"message\": \"重試\",\n    \"description\": \"重試按鈕文字\"\n  },\n  \"cancelButton\": {\n    \"message\": \"取消\",\n    \"description\": \"取消按鈕文字\"\n  },\n  \"confirmButton\": {\n    \"message\": \"確認\",\n    \"description\": \"確認按鈕文字\"\n  },\n  \"saveButton\": {\n    \"message\": \"儲存\",\n    \"description\": \"儲存按鈕文字\"\n  },\n  \"closeButton\": {\n    \"message\": \"關閉\",\n    \"description\": \"關閉按鈕文字\"\n  },\n  \"resetButton\": {\n    \"message\": \"重設\",\n    \"description\": \"重設按鈕文字\"\n  },\n  \"initializingStatus\": {\n    \"message\": \"初始化中...\",\n    \"description\": \"初始化進度訊息\"\n  },\n  \"processingStatus\": {\n    \"message\": \"處理中...\",\n    \"description\": \"處理進度訊息\"\n  },\n  \"loadingStatus\": {\n    \"message\": \"載入中...\",\n    \"description\": \"載入進度訊息\"\n  },\n  \"clearingStatus\": {\n    \"message\": \"清除中...\",\n    \"description\": \"清除進度訊息\"\n  },\n  \"cleaningStatus\": {\n    \"message\": \"清理中...\",\n    \"description\": \"清理進度訊息\"\n  },\n  \"downloadingStatus\": {\n    \"message\": \"下載中...\",\n    \"description\": \"下載進度訊息\"\n  },\n  \"semanticEngineReadyStatus\": {\n    \"message\": \"語意引擎已就緒\",\n    \"description\": \"語意引擎就緒狀態\"\n  },\n  \"semanticEngineInitializingStatus\": {\n    \"message\": \"語意引擎初始化中...\",\n    \"description\": \"語意引擎初始化狀態\"\n  },\n  \"semanticEngineInitFailedStatus\": {\n    \"message\": \"語意引擎初始化失敗\",\n    \"description\": \"語意引擎初始化失敗狀態\"\n  },\n  \"semanticEngineNotInitStatus\": {\n    \"message\": \"語意引擎未初始化\",\n    \"description\": \"語意引擎未初始化狀態\"\n  },\n  \"initSemanticEngineButton\": {\n    \"message\": \"初始化語意引擎\",\n    \"description\": \"初始化語意引擎按鈕文字\"\n  },\n  \"reinitializeButton\": {\n    \"message\": \"重新初始化\",\n    \"description\": \"重新初始化按鈕文字\"\n  },\n  \"downloadingModelStatus\": {\n    \"message\": \"正在下載模型... $PROGRESS$%\",\n    \"description\": \"含百分比的模型下載進度\",\n    \"placeholders\": {\n      \"progress\": {\n        \"content\": \"$1\",\n        \"example\": \"50\"\n      }\n    }\n  },\n  \"switchingModelStatus\": {\n    \"message\": \"正在切換模型...\",\n    \"description\": \"模型切換進度訊息\"\n  },\n  \"modelLoadedStatus\": {\n    \"message\": \"模型已載入\",\n    \"description\": \"模型成功載入狀態\"\n  },\n  \"modelFailedStatus\": {\n    \"message\": \"模型載入失敗\",\n    \"description\": \"模型載入失敗狀態\"\n  },\n  \"lightweightModelDescription\": {\n    \"message\": \"輕量級多語言模型\",\n    \"description\": \"輕量級模型選項描述\"\n  },\n  \"betterThanSmallDescription\": {\n    \"message\": \"比 e5-small 稍大，但效果更佳\",\n    \"description\": \"中等模型選項描述\"\n  },\n  \"multilingualModelDescription\": {\n    \"message\": \"多語言語意模型\",\n    \"description\": \"多語言模型選項描述\"\n  },\n  \"fastPerformance\": {\n    \"message\": \"快速\",\n    \"description\": \"快速效能指標\"\n  },\n  \"balancedPerformance\": {\n    \"message\": \"平衡\",\n    \"description\": \"平衡效能指標\"\n  },\n  \"accuratePerformance\": {\n    \"message\": \"精確\",\n    \"description\": \"精確效能指標\"\n  },\n  \"networkErrorMessage\": {\n    \"message\": \"網路連線錯誤，請檢查網路後再試一次\",\n    \"description\": \"網路連線錯誤訊息\"\n  },\n  \"modelCorruptedErrorMessage\": {\n    \"message\": \"模型檔案毀損或不完整，請重新下載\",\n    \"description\": \"模型毀損錯誤訊息\"\n  },\n  \"unknownErrorMessage\": {\n    \"message\": \"未知錯誤，請檢查您的網路是否可存取 HuggingFace\",\n    \"description\": \"未知錯誤回退訊息\"\n  },\n  \"permissionDeniedErrorMessage\": {\n    \"message\": \"權限被拒絕\",\n    \"description\": \"權限被拒絕錯誤訊息\"\n  },\n  \"timeoutErrorMessage\": {\n    \"message\": \"操作逾時\",\n    \"description\": \"逾時錯誤訊息\"\n  },\n  \"indexedPagesLabel\": {\n    \"message\": \"已索引頁面\",\n    \"description\": \"已索引頁面數量標籤\"\n  },\n  \"indexSizeLabel\": {\n    \"message\": \"索引大小\",\n    \"description\": \"索引大小標籤\"\n  },\n  \"activeTabsLabel\": {\n    \"message\": \"作用中分頁\",\n    \"description\": \"作用中分頁數量標籤\"\n  },\n  \"vectorDocumentsLabel\": {\n    \"message\": \"向量文件\",\n    \"description\": \"向量文件數量標籤\"\n  },\n  \"cacheSizeLabel\": {\n    \"message\": \"快取大小\",\n    \"description\": \"快取大小標籤\"\n  },\n  \"cacheEntriesLabel\": {\n    \"message\": \"快取項目\",\n    \"description\": \"快取項目數量標籤\"\n  },\n  \"clearAllDataButton\": {\n    \"message\": \"清除所有資料\",\n    \"description\": \"清除所有資料按鈕文字\"\n  },\n  \"clearAllCacheButton\": {\n    \"message\": \"清除所有快取\",\n    \"description\": \"清除所有快取按鈕文字\"\n  },\n  \"cleanExpiredCacheButton\": {\n    \"message\": \"清理過期快取\",\n    \"description\": \"清理過期快取按鈕文字\"\n  },\n  \"exportDataButton\": {\n    \"message\": \"匯出資料\",\n    \"description\": \"匯出資料按鈕文字\"\n  },\n  \"importDataButton\": {\n    \"message\": \"匯入資料\",\n    \"description\": \"匯入資料按鈕文字\"\n  },\n  \"confirmClearDataTitle\": {\n    \"message\": \"確認清除資料\",\n    \"description\": \"清除資料確認對話框標題\"\n  },\n  \"settingsTitle\": {\n    \"message\": \"設定\",\n    \"description\": \"設定對話框標題\"\n  },\n  \"aboutTitle\": {\n    \"message\": \"關於\",\n    \"description\": \"關於對話框標題\"\n  },\n  \"helpTitle\": {\n    \"message\": \"說明\",\n    \"description\": \"說明對話框標題\"\n  },\n  \"clearDataWarningMessage\": {\n    \"message\": \"此操作將清除所有已索引的網頁內容與向量資料，包括：\",\n    \"description\": \"清除資料警告訊息\"\n  },\n  \"clearDataList1\": {\n    \"message\": \"所有網頁的文字內容索引\",\n    \"description\": \"清除資料列表第一項\"\n  },\n  \"clearDataList2\": {\n    \"message\": \"向量嵌入資料\",\n    \"description\": \"清除資料列表第二項\"\n  },\n  \"clearDataList3\": {\n    \"message\": \"搜尋歷史與快取\",\n    \"description\": \"清除資料列表第三項\"\n  },\n  \"clearDataIrreversibleWarning\": {\n    \"message\": \"此操作無法復原！清除後需重新瀏覽網頁以重建索引。\",\n    \"description\": \"不可逆操作警告\"\n  },\n  \"confirmClearButton\": {\n    \"message\": \"確認清除\",\n    \"description\": \"確認清除操作按鈕\"\n  },\n  \"cacheDetailsLabel\": {\n    \"message\": \"快取詳細資訊\",\n    \"description\": \"快取詳細資訊區段標籤\"\n  },\n  \"noCacheDataMessage\": {\n    \"message\": \"尚無快取資料\",\n    \"description\": \"無可用快取資料訊息\"\n  },\n  \"loadingCacheInfoStatus\": {\n    \"message\": \"正在載入快取資訊...\",\n    \"description\": \"載入快取資訊狀態\"\n  },\n  \"processingCacheStatus\": {\n    \"message\": \"正在處理快取...\",\n    \"description\": \"處理快取狀態\"\n  },\n  \"expiredLabel\": {\n    \"message\": \"已過期\",\n    \"description\": \"過期項目標籤\"\n  },\n  \"bookmarksBarLabel\": {\n    \"message\": \"書籤列\",\n    \"description\": \"書籤列資料夾名稱\"\n  },\n  \"newTabLabel\": {\n    \"message\": \"新分頁\",\n    \"description\": \"新分頁標籤\"\n  },\n  \"currentPageLabel\": {\n    \"message\": \"目前頁面\",\n    \"description\": \"目前頁面標籤\"\n  },\n  \"menuLabel\": {\n    \"message\": \"功能表\",\n    \"description\": \"功能表無障礙標籤\"\n  },\n  \"navigationLabel\": {\n    \"message\": \"導覽\",\n    \"description\": \"導覽無障礙標籤\"\n  },\n  \"mainContentLabel\": {\n    \"message\": \"主要內容\",\n    \"description\": \"主要內容無障礙標籤\"\n  },\n  \"languageSelectorLabel\": {\n    \"message\": \"語言\",\n    \"description\": \"語言選擇器標籤\"\n  },\n  \"themeLabel\": {\n    \"message\": \"主題\",\n    \"description\": \"主題選擇器標籤\"\n  },\n  \"lightTheme\": {\n    \"message\": \"淺色\",\n    \"description\": \"淺色主題選項\"\n  },\n  \"darkTheme\": {\n    \"message\": \"深色\",\n    \"description\": \"深色主題選項\"\n  },\n  \"autoTheme\": {\n    \"message\": \"自動\",\n    \"description\": \"自動主題選項\"\n  },\n  \"advancedSettingsLabel\": {\n    \"message\": \"進階設定\",\n    \"description\": \"進階設定區段標籤\"\n  },\n  \"debugModeLabel\": {\n    \"message\": \"偵錯模式\",\n    \"description\": \"偵錯模式切換標籤\"\n  },\n  \"verboseLoggingLabel\": {\n    \"message\": \"詳細日誌\",\n    \"description\": \"詳細日誌切換標籤\"\n  },\n  \"successNotification\": {\n    \"message\": \"操作已成功完成\",\n    \"description\": \"通用成功通知\"\n  },\n  \"warningNotification\": {\n    \"message\": \"警告：請在繼續前檢查\",\n    \"description\": \"通用警告通知\"\n  },\n  \"infoNotification\": {\n    \"message\": \"資訊\",\n    \"description\": \"通用資訊通知\"\n  },\n  \"configCopiedNotification\": {\n    \"message\": \"設定已複製到剪貼簿\",\n    \"description\": \"設定複製成功訊息\"\n  },\n  \"dataClearedNotification\": {\n    \"message\": \"資料清除成功\",\n    \"description\": \"資料清除成功訊息\"\n  },\n  \"bytesUnit\": {\n    \"message\": \"bytes\",\n    \"description\": \"位元組單位\"\n  },\n  \"kilobytesUnit\": {\n    \"message\": \"KB\",\n    \"description\": \"千位元組單位\"\n  },\n  \"megabytesUnit\": {\n    \"message\": \"MB\",\n    \"description\": \"百萬位元組單位\"\n  },\n  \"gigabytesUnit\": {\n    \"message\": \"GB\",\n    \"description\": \"十億位元組單位\"\n  },\n  \"itemsUnit\": {\n    \"message\": \"項目\",\n    \"description\": \"項目計數單位\"\n  },\n  \"pagesUnit\": {\n    \"message\": \"頁面\",\n    \"description\": \"頁面計數單位\"\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/common/agent-models.ts",
    "content": "/**\n * Agent CLI Model Definitions.\n *\n * Static model definitions for each CLI type.\n * Based on the pattern from Claudable (other/cweb).\n */\n\nimport type { CodexReasoningEffort } from 'chrome-mcp-shared';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface ModelDefinition {\n  id: string;\n  name: string;\n  description?: string;\n  supportsImages?: boolean;\n  /** Supported reasoning effort levels for Codex models */\n  supportedReasoningEfforts?: readonly CodexReasoningEffort[];\n}\n\nexport type AgentCliType = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';\n\n// ============================================================\n// Claude Models\n// ============================================================\n\nexport const CLAUDE_MODELS: ModelDefinition[] = [\n  {\n    id: 'claude-sonnet-4-5-20250929',\n    name: 'Claude Sonnet 4.5',\n    description: 'Balanced model with large context window',\n    supportsImages: true,\n  },\n  {\n    id: 'claude-opus-4-5-20251101',\n    name: 'Claude Opus 4.5',\n    description: 'Strongest reasoning model',\n    supportsImages: true,\n  },\n  {\n    id: 'claude-haiku-4-5-20251001',\n    name: 'Claude Haiku 4.5',\n    description: 'Fast and cost-efficient',\n    supportsImages: true,\n  },\n];\n\nexport const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';\n\n// ============================================================\n// Codex Models\n// ============================================================\n\n/** Standard reasoning efforts supported by all models */\nconst CODEX_STANDARD_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high'];\n/** Extended reasoning efforts (includes xhigh) - only for gpt-5.2 and gpt-5.1-codex-max */\nconst CODEX_EXTENDED_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];\n\nexport const CODEX_MODELS: ModelDefinition[] = [\n  {\n    id: 'gpt-5.1',\n    name: 'GPT-5.1',\n    description: 'OpenAI high-quality reasoning model',\n    supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,\n  },\n  {\n    id: 'gpt-5.2',\n    name: 'GPT-5.2',\n    description: 'OpenAI flagship reasoning model with extended effort support',\n    supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,\n  },\n  {\n    id: 'gpt-5.1-codex',\n    name: 'GPT-5.1 Codex',\n    description: 'Coding-optimized model for agent workflows',\n    supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,\n  },\n  {\n    id: 'gpt-5.1-codex-max',\n    name: 'GPT-5.1 Codex Max',\n    description: 'Highest quality coding model with extended effort support',\n    supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,\n  },\n  {\n    id: 'gpt-5.1-codex-mini',\n    name: 'GPT-5.1 Codex Mini',\n    description: 'Fast, cost-efficient coding model',\n    supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,\n  },\n];\n\nexport const CODEX_DEFAULT_MODEL = 'gpt-5.1';\n\n// Codex model alias normalization\nconst CODEX_ALIAS_MAP: Record<string, string> = {\n  gpt5: 'gpt-5.1',\n  gpt_5: 'gpt-5.1',\n  'gpt-5': 'gpt-5.1',\n  'gpt-5.0': 'gpt-5.1',\n};\n\nconst CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id));\n\n/**\n * Normalize a Codex model ID, handling aliases and falling back to default.\n */\nexport function normalizeCodexModelId(model?: string | null): string {\n  if (!model || typeof model !== 'string') {\n    return CODEX_DEFAULT_MODEL;\n  }\n\n  const trimmed = model.trim();\n  if (!trimmed) {\n    return CODEX_DEFAULT_MODEL;\n  }\n\n  const lower = trimmed.toLowerCase();\n  if (CODEX_ALIAS_MAP[lower]) {\n    return CODEX_ALIAS_MAP[lower];\n  }\n\n  if (CODEX_KNOWN_IDS.has(lower)) {\n    return lower;\n  }\n\n  // If the exact casing exists, allow it\n  if (CODEX_KNOWN_IDS.has(trimmed)) {\n    return trimmed;\n  }\n\n  return CODEX_DEFAULT_MODEL;\n}\n\n/**\n * Get supported reasoning efforts for a Codex model.\n * Returns standard efforts (low/medium/high) for unknown models.\n */\nexport function getCodexReasoningEfforts(modelId?: string | null): readonly CodexReasoningEffort[] {\n  const normalized = normalizeCodexModelId(modelId);\n  const model = CODEX_MODELS.find((m) => m.id === normalized);\n  return model?.supportedReasoningEfforts ?? CODEX_STANDARD_EFFORTS;\n}\n\n/**\n * Check if a model supports xhigh reasoning effort.\n */\nexport function supportsXhighEffort(modelId?: string | null): boolean {\n  const efforts = getCodexReasoningEfforts(modelId);\n  return efforts.includes('xhigh');\n}\n\n// ============================================================\n// Cursor Models\n// ============================================================\n\nexport const CURSOR_MODELS: ModelDefinition[] = [\n  {\n    id: 'auto',\n    name: 'Auto',\n    description: 'Cursor auto-selects the best model',\n  },\n  {\n    id: 'claude-sonnet-4-5-20250929',\n    name: 'Claude Sonnet 4.5',\n    description: 'Anthropic Claude via Cursor',\n    supportsImages: true,\n  },\n  {\n    id: 'gpt-4.1',\n    name: 'GPT-4.1',\n    description: 'OpenAI model via Cursor',\n  },\n];\n\nexport const CURSOR_DEFAULT_MODEL = 'auto';\n\n// ============================================================\n// Qwen Models\n// ============================================================\n\nexport const QWEN_MODELS: ModelDefinition[] = [\n  {\n    id: 'qwen3-coder-plus',\n    name: 'Qwen3 Coder Plus',\n    description: 'Balanced 32k context model for coding',\n  },\n  {\n    id: 'qwen3-coder-pro',\n    name: 'Qwen3 Coder Pro',\n    description: 'Larger 128k context with stronger reasoning',\n  },\n  {\n    id: 'qwen3-coder',\n    name: 'Qwen3 Coder',\n    description: 'Fast iteration model',\n  },\n];\n\nexport const QWEN_DEFAULT_MODEL = 'qwen3-coder-plus';\n\n// ============================================================\n// GLM Models\n// ============================================================\n\nexport const GLM_MODELS: ModelDefinition[] = [\n  {\n    id: 'glm-4.6',\n    name: 'GLM 4.6',\n    description: 'Zhipu GLM 4.6 agent runtime',\n  },\n];\n\nexport const GLM_DEFAULT_MODEL = 'glm-4.6';\n\n// ============================================================\n// Aggregated Definitions\n// ============================================================\n\nexport const CLI_MODEL_DEFINITIONS: Record<AgentCliType, ModelDefinition[]> = {\n  claude: CLAUDE_MODELS,\n  codex: CODEX_MODELS,\n  cursor: CURSOR_MODELS,\n  qwen: QWEN_MODELS,\n  glm: GLM_MODELS,\n};\n\nexport const CLI_DEFAULT_MODELS: Record<AgentCliType, string> = {\n  claude: CLAUDE_DEFAULT_MODEL,\n  codex: CODEX_DEFAULT_MODEL,\n  cursor: CURSOR_DEFAULT_MODEL,\n  qwen: QWEN_DEFAULT_MODEL,\n  glm: GLM_DEFAULT_MODEL,\n};\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Get model definitions for a specific CLI type.\n */\nexport function getModelsForCli(cli: string | null | undefined): ModelDefinition[] {\n  if (!cli) return [];\n  const key = cli.toLowerCase() as AgentCliType;\n  return CLI_MODEL_DEFINITIONS[key] || [];\n}\n\n/**\n * Get the default model for a CLI type.\n */\nexport function getDefaultModelForCli(cli: string | null | undefined): string {\n  if (!cli) return '';\n  const key = cli.toLowerCase() as AgentCliType;\n  return CLI_DEFAULT_MODELS[key] || '';\n}\n\n/**\n * Get display name for a model ID.\n */\nexport function getModelDisplayName(\n  cli: string | null | undefined,\n  modelId: string | null | undefined,\n): string {\n  if (!cli || !modelId) return modelId || '';\n  const models = getModelsForCli(cli);\n  const model = models.find((m) => m.id === modelId);\n  return model?.name || modelId;\n}\n"
  },
  {
    "path": "app/chrome-extension/common/constants.ts",
    "content": "/**\n * Chrome Extension Constants\n * Centralized configuration values and magic constants\n */\n\n// Native Host Configuration\nexport const NATIVE_HOST = {\n  NAME: 'com.chromemcp.nativehost',\n  DEFAULT_PORT: 12306,\n} as const;\n\n// Chrome Extension Icons\nexport const ICONS = {\n  NOTIFICATION: 'icon/48.png',\n} as const;\n\n// Timeouts and Delays (in milliseconds)\nexport const TIMEOUTS = {\n  DEFAULT_WAIT: 1000,\n  NETWORK_CAPTURE_MAX: 30000,\n  NETWORK_CAPTURE_IDLE: 3000,\n  SCREENSHOT_DELAY: 100,\n  KEYBOARD_DELAY: 50,\n  CLICK_DELAY: 100,\n} as const;\n\n// Limits and Thresholds\nexport const LIMITS = {\n  MAX_NETWORK_REQUESTS: 100,\n  MAX_SEARCH_RESULTS: 50,\n  MAX_BOOKMARK_RESULTS: 100,\n  MAX_HISTORY_RESULTS: 100,\n  SIMILARITY_THRESHOLD: 0.1,\n  VECTOR_DIMENSIONS: 384,\n} as const;\n\n// Error Messages\nexport const ERROR_MESSAGES = {\n  NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',\n  NATIVE_DISCONNECTED: 'Native connection disconnected',\n  SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',\n  SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',\n  TOOL_EXECUTION_FAILED: 'Tool execution failed',\n  INVALID_PARAMETERS: 'Invalid parameters provided',\n  PERMISSION_DENIED: 'Permission denied',\n  TAB_NOT_FOUND: 'Tab not found',\n  ELEMENT_NOT_FOUND: 'Element not found',\n  NETWORK_ERROR: 'Network error occurred',\n} as const;\n\n// Success Messages\nexport const SUCCESS_MESSAGES = {\n  TOOL_EXECUTED: 'Tool executed successfully',\n  CONNECTION_ESTABLISHED: 'Connection established',\n  SERVER_STARTED: 'Server started successfully',\n  SERVER_STOPPED: 'Server stopped successfully',\n} as const;\n\n// External Links\nexport const LINKS = {\n  TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',\n} as const;\n\n// File Extensions and MIME Types\nexport const FILE_TYPES = {\n  STATIC_EXTENSIONS: [\n    '.css',\n    '.js',\n    '.png',\n    '.jpg',\n    '.jpeg',\n    '.gif',\n    '.svg',\n    '.ico',\n    '.woff',\n    '.woff2',\n    '.ttf',\n  ],\n  FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],\n  IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,\n} as const;\n\n// Network Filtering\nexport const NETWORK_FILTERS = {\n  // Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'\n  EXCLUDED_DOMAINS: [\n    // Google\n    'google-analytics.com',\n    'googletagmanager.com',\n    'analytics.google.com',\n    'doubleclick.net',\n    'googlesyndication.com',\n    'googleads.g.doubleclick.net',\n    'stats.g.doubleclick.net',\n    'adservice.google.com',\n    'pagead2.googlesyndication.com',\n    // Amazon\n    'amazon-adsystem.com',\n    // Microsoft\n    'bat.bing.com',\n    'clarity.ms',\n    // Facebook\n    'connect.facebook.net',\n    'facebook.com/tr',\n    // Twitter\n    'analytics.twitter.com',\n    'ads-twitter.com',\n    // Other ad networks\n    'ads.yahoo.com',\n    'adroll.com',\n    'adnxs.com',\n    'criteo.com',\n    'quantserve.com',\n    'scorecardresearch.com',\n    // Analytics & session recording\n    'segment.io',\n    'amplitude.com',\n    'mixpanel.com',\n    'optimizely.com',\n    'static.hotjar.com',\n    'script.hotjar.com',\n    'crazyegg.com',\n    'clicktale.net',\n    'mouseflow.com',\n    'fullstory.com',\n    // LinkedIn (tracking pixels)\n    'linkedin.com/px',\n  ],\n  // Static resource extensions (used when includeStatic=false)\n  STATIC_RESOURCE_EXTENSIONS: [\n    '.jpg',\n    '.jpeg',\n    '.png',\n    '.gif',\n    '.svg',\n    '.webp',\n    '.ico',\n    '.bmp',\n    '.cur',\n    '.css',\n    '.scss',\n    '.less',\n    '.js',\n    '.jsx',\n    '.ts',\n    '.tsx',\n    '.map',\n    '.woff',\n    '.woff2',\n    '.ttf',\n    '.eot',\n    '.otf',\n    '.mp3',\n    '.mp4',\n    '.avi',\n    '.mov',\n    '.wmv',\n    '.flv',\n    '.webm',\n    '.ogg',\n    '.wav',\n    '.pdf',\n    '.zip',\n    '.rar',\n    '.7z',\n    '.iso',\n    '.dmg',\n    '.doc',\n    '.docx',\n    '.xls',\n    '.xlsx',\n    '.ppt',\n    '.pptx',\n  ],\n  // MIME types treated as static/binary (filtered when includeStatic=false)\n  STATIC_MIME_TYPES_TO_FILTER: [\n    'image/',\n    'font/',\n    'audio/',\n    'video/',\n    'text/css',\n    'text/javascript',\n    'application/javascript',\n    'application/x-javascript',\n    'application/pdf',\n    'application/zip',\n    'application/octet-stream',\n  ],\n  // API-like MIME types (never filtered by MIME)\n  API_MIME_TYPES: [\n    'application/json',\n    'application/xml',\n    'text/xml',\n    'text/plain',\n    'text/event-stream',\n    'application/x-www-form-urlencoded',\n    'application/graphql',\n    'application/grpc',\n    'application/protobuf',\n    'application/x-protobuf',\n    'application/x-json',\n    'application/ld+json',\n    'application/problem+json',\n    'application/problem+xml',\n    'application/soap+xml',\n    'application/vnd.api+json',\n  ],\n  STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],\n} as const;\n\n// Semantic Similarity Configuration\nexport const SEMANTIC_CONFIG = {\n  DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',\n  CHUNK_SIZE: 512,\n  CHUNK_OVERLAP: 50,\n  BATCH_SIZE: 32,\n  CACHE_SIZE: 1000,\n} as const;\n\n// Storage Keys\nexport const STORAGE_KEYS = {\n  SERVER_STATUS: 'serverStatus',\n  NATIVE_SERVER_PORT: 'nativeServerPort',\n  NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',\n  SEMANTIC_MODEL: 'selectedModel',\n  USER_PREFERENCES: 'userPreferences',\n  VECTOR_INDEX: 'vectorIndex',\n  USERSCRIPTS: 'userscripts',\n  USERSCRIPTS_DISABLED: 'userscripts_disabled',\n  // Record & Replay storage keys\n  RR_FLOWS: 'rr_flows',\n  RR_RUNS: 'rr_runs',\n  RR_PUBLISHED: 'rr_published_flows',\n  RR_SCHEDULES: 'rr_schedules',\n  RR_TRIGGERS: 'rr_triggers',\n  // Persistent recording state (guards resume across navigations/service worker restarts)\n  RR_RECORDING_STATE: 'rr_recording_state',\n} as const;\n\n// Notification Configuration\nexport const NOTIFICATIONS = {\n  PRIORITY: 2,\n  TYPE: 'basic' as const,\n} as const;\n\nexport enum ExecutionWorld {\n  ISOLATED = 'ISOLATED',\n  MAIN = 'MAIN',\n}\n"
  },
  {
    "path": "app/chrome-extension/common/element-marker-types.ts",
    "content": "// Element marker types shared across background, content scripts, and popup\n\nexport type UrlMatchType = 'exact' | 'prefix' | 'host';\n\nexport interface ElementMarker {\n  id: string;\n  // Original URL where the marker was created\n  url: string;\n  // Normalized pieces to support matching\n  origin: string; // scheme + host + port\n  host: string; // hostname\n  path: string; // pathname part only\n  matchType: UrlMatchType; // default: 'prefix'\n\n  name: string; // Human-friendly name, e.g., \"Login Button\"\n  selector: string; // Selector string\n  selectorType?: 'css' | 'xpath'; // Default: css\n  listMode?: boolean; // Whether this marker was created in list mode (allows multiple matches)\n  action?: 'click' | 'fill' | 'custom'; // Intended action hint (optional)\n\n  createdAt: number;\n  updatedAt: number;\n}\n\nexport interface UpsertMarkerRequest {\n  id?: string;\n  url: string;\n  name: string;\n  selector: string;\n  selectorType?: 'css' | 'xpath';\n  listMode?: boolean;\n  matchType?: UrlMatchType;\n  action?: 'click' | 'fill' | 'custom';\n}\n\n// Validation actions for MCP-integrated verification\nexport enum MarkerValidationAction {\n  Hover = 'hover',\n  LeftClick = 'left_click',\n  RightClick = 'right_click',\n  DoubleClick = 'double_click',\n  TypeText = 'type_text',\n  PressKeys = 'press_keys',\n  Scroll = 'scroll',\n}\n\nexport interface MarkerValidationRequest {\n  selector: string;\n  selectorType?: 'css' | 'xpath';\n  action: MarkerValidationAction;\n  // Optional payload for certain actions\n  text?: string; // for type_text\n  keys?: string; // for press_keys\n  // Event options for click-like actions\n  button?: 'left' | 'right' | 'middle';\n  bubbles?: boolean;\n  cancelable?: boolean;\n  modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean };\n  // Targeting options\n  coordinates?: { x: number; y: number }; // absolute viewport coords\n  offsetX?: number; // relative to element center if relativeTo = 'element'\n  offsetY?: number;\n  relativeTo?: 'element' | 'viewport';\n  // Navigation options for click-like actions\n  waitForNavigation?: boolean;\n  timeoutMs?: number;\n  // Scroll options\n  scrollDirection?: 'up' | 'down' | 'left' | 'right';\n  scrollAmount?: number; // pixels per tick\n}\n\nexport interface MarkerValidationResponse {\n  success: boolean;\n  resolved?: boolean;\n  ref?: string;\n  center?: { x: number; y: number };\n  tool?: { name: string; ok: boolean; error?: string };\n  error?: string;\n}\n\nexport interface MarkerQuery {\n  url?: string; // If present, query by URL match; otherwise list all\n}\n"
  },
  {
    "path": "app/chrome-extension/common/message-types.ts",
    "content": "/**\n * Consolidated message type constants for Chrome extension communication\n * Note: Native message types are imported from the shared package\n */\n\nimport type { RealtimeEvent } from 'chrome-mcp-shared';\n\n// Message targets for routing\nexport enum MessageTarget {\n  Offscreen = 'offscreen',\n  ContentScript = 'content_script',\n  Background = 'background',\n}\n\n// Background script message types\nexport const BACKGROUND_MESSAGE_TYPES = {\n  SWITCH_SEMANTIC_MODEL: 'switch_semantic_model',\n  GET_MODEL_STATUS: 'get_model_status',\n  UPDATE_MODEL_STATUS: 'update_model_status',\n  GET_STORAGE_STATS: 'get_storage_stats',\n  CLEAR_ALL_DATA: 'clear_all_data',\n  GET_SERVER_STATUS: 'get_server_status',\n  REFRESH_SERVER_STATUS: 'refresh_server_status',\n  SERVER_STATUS_CHANGED: 'server_status_changed',\n  INITIALIZE_SEMANTIC_ENGINE: 'initialize_semantic_engine',\n  // Record & Replay background control and queries\n  RR_START_RECORDING: 'rr_start_recording',\n  RR_STOP_RECORDING: 'rr_stop_recording',\n  RR_PAUSE_RECORDING: 'rr_pause_recording',\n  RR_RESUME_RECORDING: 'rr_resume_recording',\n  RR_GET_RECORDING_STATUS: 'rr_get_recording_status',\n  RR_LIST_FLOWS: 'rr_list_flows',\n  RR_FLOWS_CHANGED: 'rr_flows_changed',\n  RR_GET_FLOW: 'rr_get_flow',\n  RR_DELETE_FLOW: 'rr_delete_flow',\n  RR_PUBLISH_FLOW: 'rr_publish_flow',\n  RR_UNPUBLISH_FLOW: 'rr_unpublish_flow',\n  RR_RUN_FLOW: 'rr_run_flow',\n  RR_SAVE_FLOW: 'rr_save_flow',\n  RR_EXPORT_FLOW: 'rr_export_flow',\n  RR_EXPORT_ALL: 'rr_export_all',\n  RR_IMPORT_FLOW: 'rr_import_flow',\n  RR_LIST_RUNS: 'rr_list_runs',\n  // Triggers\n  RR_LIST_TRIGGERS: 'rr_list_triggers',\n  RR_SAVE_TRIGGER: 'rr_save_trigger',\n  RR_DELETE_TRIGGER: 'rr_delete_trigger',\n  RR_REFRESH_TRIGGERS: 'rr_refresh_triggers',\n  // Scheduling\n  RR_SCHEDULE_FLOW: 'rr_schedule_flow',\n  RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',\n  RR_LIST_SCHEDULES: 'rr_list_schedules',\n  // Element marker management\n  ELEMENT_MARKER_LIST_ALL: 'element_marker_list_all',\n  ELEMENT_MARKER_LIST_FOR_URL: 'element_marker_list_for_url',\n  ELEMENT_MARKER_SAVE: 'element_marker_save',\n  ELEMENT_MARKER_UPDATE: 'element_marker_update',\n  ELEMENT_MARKER_DELETE: 'element_marker_delete',\n  ELEMENT_MARKER_VALIDATE: 'element_marker_validate',\n  ELEMENT_MARKER_START: 'element_marker_start_from_popup',\n  // Element picker (human-in-the-loop element selection)\n  ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',\n  ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',\n  // Web editor (in-page visual editing)\n  WEB_EDITOR_TOGGLE: 'web_editor_toggle',\n  WEB_EDITOR_APPLY: 'web_editor_apply',\n  WEB_EDITOR_STATUS_QUERY: 'web_editor_status_query',\n  // Web editor <-> AgentChat integration (Phase 1.1)\n  WEB_EDITOR_APPLY_BATCH: 'web_editor_apply_batch',\n  WEB_EDITOR_TX_CHANGED: 'web_editor_tx_changed',\n  WEB_EDITOR_HIGHLIGHT_ELEMENT: 'web_editor_highlight_element',\n  // Web editor <-> AgentChat integration (Phase 2 - Revert)\n  WEB_EDITOR_REVERT_ELEMENT: 'web_editor_revert_element',\n  // Web editor <-> AgentChat integration - Selection sync\n  WEB_EDITOR_SELECTION_CHANGED: 'web_editor_selection_changed',\n  // Web editor <-> AgentChat integration - Clear selection (sidepanel -> web-editor)\n  WEB_EDITOR_CLEAR_SELECTION: 'web_editor_clear_selection',\n  // Web editor <-> AgentChat integration - Cancel execution\n  WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',\n  // Web editor props (Phase 7.1.6 early injection)\n  WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',\n  // Web editor props - open source file in VSCode\n  WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',\n  // Quick Panel <-> AgentChat integration\n  QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',\n  QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',\n  // Quick Panel Search - Tabs bridge\n  QUICK_PANEL_TABS_QUERY: 'quick_panel_tabs_query',\n  QUICK_PANEL_TAB_ACTIVATE: 'quick_panel_tab_activate',\n  QUICK_PANEL_TAB_CLOSE: 'quick_panel_tab_close',\n} as const;\n\n// Offscreen message types\nexport const OFFSCREEN_MESSAGE_TYPES = {\n  SIMILARITY_ENGINE_INIT: 'similarityEngineInit',\n  SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute',\n  SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute',\n  SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus',\n  // GIF encoding\n  GIF_ADD_FRAME: 'gifAddFrame',\n  GIF_FINISH: 'gifFinish',\n  GIF_RESET: 'gifReset',\n} as const;\n\n// Content script message types\nexport const CONTENT_MESSAGE_TYPES = {\n  WEB_FETCHER_GET_TEXT_CONTENT: 'webFetcherGetTextContent',\n  WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',\n  NETWORK_CAPTURE_PING: 'network_capture_ping',\n  CLICK_HELPER_PING: 'click_helper_ping',\n  FILL_HELPER_PING: 'fill_helper_ping',\n  KEYBOARD_HELPER_PING: 'keyboard_helper_ping',\n  SCREENSHOT_HELPER_PING: 'screenshot_helper_ping',\n  INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping',\n  ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping',\n  WAIT_HELPER_PING: 'wait_helper_ping',\n  DOM_OBSERVER_PING: 'dom_observer_ping',\n} as const;\n\n// Tool action message types (for chrome.runtime.sendMessage)\nexport const TOOL_MESSAGE_TYPES = {\n  // Screenshot related\n  SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE: 'preparePageForCapture',\n  SCREENSHOT_GET_PAGE_DETAILS: 'getPageDetails',\n  SCREENSHOT_GET_ELEMENT_DETAILS: 'getElementDetails',\n  SCREENSHOT_SCROLL_PAGE: 'scrollPage',\n  SCREENSHOT_RESET_PAGE_AFTER_CAPTURE: 'resetPageAfterCapture',\n\n  // Web content fetching\n  WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',\n  WEB_FETCHER_GET_TEXT_CONTENT: 'getTextContent',\n\n  // User interactions\n  CLICK_ELEMENT: 'clickElement',\n  FILL_ELEMENT: 'fillElement',\n  SIMULATE_KEYBOARD: 'simulateKeyboard',\n\n  // Interactive elements\n  GET_INTERACTIVE_ELEMENTS: 'getInteractiveElements',\n\n  // Accessibility tree\n  GENERATE_ACCESSIBILITY_TREE: 'generateAccessibilityTree',\n  RESOLVE_REF: 'resolveRef',\n  ENSURE_REF_FOR_SELECTOR: 'ensureRefForSelector',\n  VERIFY_FINGERPRINT: 'verifyFingerprint',\n  DISPATCH_HOVER_FOR_REF: 'dispatchHoverForRef',\n\n  // Network requests\n  NETWORK_SEND_REQUEST: 'sendPureNetworkRequest',\n\n  // Wait helper\n  WAIT_FOR_TEXT: 'waitForText',\n\n  // Semantic similarity engine\n  SIMILARITY_ENGINE_INIT: 'similarityEngineInit',\n  SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch',\n  // Record & Replay content script bridge\n  RR_RECORDER_CONTROL: 'rr_recorder_control',\n  RR_RECORDER_EVENT: 'rr_recorder_event',\n  // Record & Replay timeline feed (background -> content overlay)\n  RR_TIMELINE_UPDATE: 'rr_timeline_update',\n  // Quick Panel AI streaming events (background -> content script)\n  QUICK_PANEL_AI_EVENT: 'quick_panel_ai_event',\n  // DOM observer trigger bridge\n  SET_DOM_TRIGGERS: 'set_dom_triggers',\n  DOM_TRIGGER_FIRED: 'dom_trigger_fired',\n  // Record & Replay overlay: variable collection\n  COLLECT_VARIABLES: 'collectVariables',\n  // Element marker overlay control (content-side)\n  ELEMENT_MARKER_START: 'element_marker_start',\n  // Element picker (tool-driven, background <-> content scripts)\n  ELEMENT_PICKER_START: 'elementPickerStart',\n  ELEMENT_PICKER_STOP: 'elementPickerStop',\n  ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',\n  ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',\n  ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',\n  ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',\n  ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',\n} as const;\n\n// Type unions for type safety\nexport type BackgroundMessageType =\n  (typeof BACKGROUND_MESSAGE_TYPES)[keyof typeof BACKGROUND_MESSAGE_TYPES];\nexport type OffscreenMessageType =\n  (typeof OFFSCREEN_MESSAGE_TYPES)[keyof typeof OFFSCREEN_MESSAGE_TYPES];\nexport type ContentMessageType = (typeof CONTENT_MESSAGE_TYPES)[keyof typeof CONTENT_MESSAGE_TYPES];\nexport type ToolMessageType = (typeof TOOL_MESSAGE_TYPES)[keyof typeof TOOL_MESSAGE_TYPES];\n\n// Legacy enum for backward compatibility (will be deprecated)\nexport enum SendMessageType {\n  // Screenshot related message types\n  ScreenshotPreparePageForCapture = 'preparePageForCapture',\n  ScreenshotGetPageDetails = 'getPageDetails',\n  ScreenshotGetElementDetails = 'getElementDetails',\n  ScreenshotScrollPage = 'scrollPage',\n  ScreenshotResetPageAfterCapture = 'resetPageAfterCapture',\n\n  // Web content fetching related message types\n  WebFetcherGetHtmlContent = 'getHtmlContent',\n  WebFetcherGetTextContent = 'getTextContent',\n\n  // Click related message types\n  ClickElement = 'clickElement',\n\n  // Input filling related message types\n  FillElement = 'fillElement',\n\n  // Interactive elements related message types\n  GetInteractiveElements = 'getInteractiveElements',\n\n  // Network request capture related message types\n  NetworkSendRequest = 'sendPureNetworkRequest',\n\n  // Keyboard event related message types\n  SimulateKeyboard = 'simulateKeyboard',\n\n  // Semantic similarity engine related message types\n  SimilarityEngineInit = 'similarityEngineInit',\n  SimilarityEngineComputeBatch = 'similarityEngineComputeBatch',\n}\n\n// ============================================================\n// Quick Panel <-> AgentChat Message Contracts\n// ============================================================\n\n/**\n * Context information that can be attached to a Quick Panel AI request.\n * Allows passing page-specific data to enhance the AI's understanding.\n */\nexport interface QuickPanelAIContext {\n  /** Current page URL */\n  pageUrl?: string;\n  /** User's text selection on the page */\n  selectedText?: string;\n  /**\n   * Optional element metadata from the page.\n   * Kept as unknown to avoid tight coupling with specific element types.\n   */\n  elementInfo?: unknown;\n}\n\n/**\n * Payload for sending a message to AI via Quick Panel.\n */\nexport interface QuickPanelSendToAIPayload {\n  /** The user's instruction/question for the AI */\n  instruction: string;\n  /** Optional contextual information from the page */\n  context?: QuickPanelAIContext;\n}\n\n/**\n * Response from QUICK_PANEL_SEND_TO_AI message handler.\n */\nexport type QuickPanelSendToAIResponse =\n  | { success: true; requestId: string; sessionId: string }\n  | { success: false; error: string };\n\n/**\n * Message structure for sending to AI.\n */\nexport interface QuickPanelSendToAIMessage {\n  type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI;\n  payload: QuickPanelSendToAIPayload;\n}\n\n/**\n * Payload for cancelling an active AI request.\n */\nexport interface QuickPanelCancelAIPayload {\n  /** The request ID to cancel */\n  requestId: string;\n  /**\n   * Optional session ID for fallback when background state is missing.\n   * This can happen after MV3 Service Worker restarts.\n   */\n  sessionId?: string;\n}\n\n/**\n * Response from QUICK_PANEL_CANCEL_AI message handler.\n */\nexport type QuickPanelCancelAIResponse = { success: true } | { success: false; error: string };\n\n/**\n * Message structure for cancelling AI request.\n */\nexport interface QuickPanelCancelAIMessage {\n  type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI;\n  payload: QuickPanelCancelAIPayload;\n}\n\n/**\n * Message pushed from background to content script with AI streaming events.\n * Uses the same RealtimeEvent type as AgentChat for consistency.\n */\nexport interface QuickPanelAIEventMessage {\n  action: typeof TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT;\n  requestId: string;\n  sessionId: string;\n  event: RealtimeEvent;\n}\n\n// ============================================================\n// Quick Panel Search - Tabs Bridge Contracts\n// ============================================================\n\n/**\n * Payload for querying open tabs.\n */\nexport interface QuickPanelTabsQueryPayload {\n  /**\n   * When true (default), query tabs across all windows.\n   * When false, restrict results to the sender's window.\n   */\n  includeAllWindows?: boolean;\n}\n\n/**\n * Summary of a single tab returned from the background.\n */\nexport interface QuickPanelTabSummary {\n  tabId: number;\n  windowId: number;\n  title: string;\n  url: string;\n  favIconUrl?: string;\n  active: boolean;\n  pinned: boolean;\n  audible: boolean;\n  muted: boolean;\n  index: number;\n  lastAccessed?: number;\n}\n\n/**\n * Response from QUICK_PANEL_TABS_QUERY message handler.\n */\nexport type QuickPanelTabsQueryResponse =\n  | {\n      success: true;\n      tabs: QuickPanelTabSummary[];\n      currentTabId: number | null;\n      currentWindowId: number | null;\n    }\n  | { success: false; error: string };\n\n/**\n * Message structure for querying tabs.\n */\nexport interface QuickPanelTabsQueryMessage {\n  type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY;\n  payload?: QuickPanelTabsQueryPayload;\n}\n\n/**\n * Payload for activating a tab.\n */\nexport interface QuickPanelActivateTabPayload {\n  tabId: number;\n  windowId?: number;\n}\n\n/**\n * Response from QUICK_PANEL_TAB_ACTIVATE message handler.\n */\nexport type QuickPanelActivateTabResponse = { success: true } | { success: false; error: string };\n\n/**\n * Message structure for activating a tab.\n */\nexport interface QuickPanelActivateTabMessage {\n  type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE;\n  payload: QuickPanelActivateTabPayload;\n}\n\n/**\n * Payload for closing a tab.\n */\nexport interface QuickPanelCloseTabPayload {\n  tabId: number;\n}\n\n/**\n * Response from QUICK_PANEL_TAB_CLOSE message handler.\n */\nexport type QuickPanelCloseTabResponse = { success: true } | { success: false; error: string };\n\n/**\n * Message structure for closing a tab.\n */\nexport interface QuickPanelCloseTabMessage {\n  type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE;\n  payload: QuickPanelCloseTabPayload;\n}\n"
  },
  {
    "path": "app/chrome-extension/common/node-types.ts",
    "content": "// node-types.ts — centralized node type constants for Builder/UI layer\n// Combines all executable Step types with UI-only nodes (e.g., trigger, delay)\n\nimport { STEP_TYPES } from './step-types';\n\nexport const NODE_TYPES = {\n  // Executable step types (spread from STEP_TYPES)\n  ...STEP_TYPES,\n  // UI-only nodes\n  TRIGGER: 'trigger',\n  DELAY: 'delay',\n} as const;\n\nexport type NodeTypeConst = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];\n"
  },
  {
    "path": "app/chrome-extension/common/rr-v3-keepalive-protocol.ts",
    "content": "/**\n * @fileoverview RR V3 Keepalive Protocol Constants\n * @description Shared protocol constants for Background-Offscreen keepalive communication\n */\n\n/** Keepalive Port 名称 */\nexport const RR_V3_KEEPALIVE_PORT_NAME = 'rr_v3_keepalive' as const;\n\n/** Keepalive 消息类型 */\nexport type KeepaliveMessageType =\n  | 'keepalive.ping'\n  | 'keepalive.pong'\n  | 'keepalive.start'\n  | 'keepalive.stop';\n\n/** Keepalive 消息 */\nexport interface KeepaliveMessage {\n  type: KeepaliveMessageType;\n  timestamp: number;\n}\n\n/** 默认心跳间隔（毫秒） - Offscreen 每隔这个间隔发送 ping */\nexport const DEFAULT_KEEPALIVE_PING_INTERVAL_MS = 20_000;\n\n/** 最大心跳间隔（毫秒）- Chrome MV3 SW 约 30s 空闲后终止 */\nexport const MAX_KEEPALIVE_PING_INTERVAL_MS = 25_000;\n"
  },
  {
    "path": "app/chrome-extension/common/step-types.ts",
    "content": "// step-types.ts — re-export shared constants to keep single source of truth\nexport { STEP_TYPES } from 'chrome-mcp-shared';\nexport type StepTypeConst =\n  (typeof import('chrome-mcp-shared'))['STEP_TYPES'][keyof (typeof import('chrome-mcp-shared'))['STEP_TYPES']];\n"
  },
  {
    "path": "app/chrome-extension/common/tool-handler.ts",
    "content": "import type { CallToolResult, TextContent, ImageContent } from '@modelcontextprotocol/sdk/types.js';\n\nexport interface ToolResult extends CallToolResult {\n  content: (TextContent | ImageContent)[];\n  isError: boolean;\n}\n\nexport interface ToolExecutor {\n  execute(args: any): Promise<ToolResult>;\n}\n\nexport const createErrorResponse = (\n  message: string = 'Unknown error, please try again',\n): ToolResult => {\n  return {\n    content: [\n      {\n        type: 'text',\n        text: message,\n      },\n    ],\n    isError: true,\n  };\n};\n"
  },
  {
    "path": "app/chrome-extension/common/web-editor-types.ts",
    "content": "/**\n * Web Editor V2 - Shared Type Definitions\n *\n * This module defines types shared between:\n * - Background script (injection control)\n * - Inject script (web-editor-v2.ts)\n * - Future: UI panels\n */\n\n// =============================================================================\n// Editor State\n// =============================================================================\n\n/** Current state of the web editor */\nexport interface WebEditorState {\n  /** Whether the editor is currently active */\n  active: boolean;\n  /** Editor version for compatibility checks */\n  version: 2;\n}\n\n// =============================================================================\n// Message Protocol (Background <-> Inject Script)\n// =============================================================================\n\n/**\n * Action types for web editor V2 messages\n *\n * IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid\n * conflicts with V1 when both scripts might be injected in the same tab.\n * This prevents double-response race conditions.\n *\n * V1 uses: web_editor_ping, web_editor_toggle, etc.\n * V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc.\n */\nexport const WEB_EDITOR_V2_ACTIONS = {\n  /** Check if V2 editor is injected and get status */\n  PING: 'web_editor_ping_v2',\n  /** Toggle V2 editor on/off */\n  TOGGLE: 'web_editor_toggle_v2',\n  /** Start V2 editor */\n  START: 'web_editor_start_v2',\n  /** Stop V2 editor */\n  STOP: 'web_editor_stop_v2',\n  /** Highlight an element (from sidepanel hover) */\n  HIGHLIGHT_ELEMENT: 'web_editor_highlight_element_v2',\n  /** Revert an element to its original state (Phase 2 - Selective Undo) */\n  REVERT_ELEMENT: 'web_editor_revert_element_v2',\n  /** Clear selection (from sidepanel after send) */\n  CLEAR_SELECTION: 'web_editor_clear_selection_v2',\n} as const;\n\n/**\n * Legacy V1 action types (for reference and background compatibility)\n * These are used when USE_WEB_EDITOR_V2 is false\n */\nexport const WEB_EDITOR_V1_ACTIONS = {\n  PING: 'web_editor_ping',\n  TOGGLE: 'web_editor_toggle',\n  START: 'web_editor_start',\n  STOP: 'web_editor_stop',\n  APPLY: 'web_editor_apply',\n} as const;\n\nexport type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS];\nexport type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS];\n\n/** Editor version literal type */\nexport type WebEditorVersion = 1 | 2;\n\n/** Ping request (V2) */\nexport interface WebEditorV2PingRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.PING;\n}\n\n/** Ping response (V2) */\nexport interface WebEditorV2PingResponse {\n  status: 'pong';\n  active: boolean;\n  version: 2;\n}\n\n/** Toggle request (V2) */\nexport interface WebEditorV2ToggleRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE;\n}\n\n/** Toggle response (V2) */\nexport interface WebEditorV2ToggleResponse {\n  active: boolean;\n}\n\n/** Start request (V2) */\nexport interface WebEditorV2StartRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.START;\n}\n\n/** Start response (V2) */\nexport interface WebEditorV2StartResponse {\n  active: boolean;\n}\n\n/** Stop request (V2) */\nexport interface WebEditorV2StopRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.STOP;\n}\n\n/** Stop response (V2) */\nexport interface WebEditorV2StopResponse {\n  active: boolean;\n}\n\n/** Union types for V2 type-safe message handling */\nexport type WebEditorV2Request =\n  | WebEditorV2PingRequest\n  | WebEditorV2ToggleRequest\n  | WebEditorV2StartRequest\n  | WebEditorV2StopRequest;\n\nexport type WebEditorV2Response =\n  | WebEditorV2PingResponse\n  | WebEditorV2ToggleResponse\n  | WebEditorV2StartResponse\n  | WebEditorV2StopResponse;\n\n// =============================================================================\n// Element Locator (Phase 1 - Basic Structure)\n// =============================================================================\n\n/**\n * Framework debug source information\n * Extracted from React Fiber or Vue component instance\n */\nexport interface DebugSource {\n  /** Source file path */\n  file: string;\n  /** Line number (1-based) */\n  line?: number;\n  /** Column number (1-based) */\n  column?: number;\n  /** Component name (if available) */\n  componentName?: string;\n}\n\n/**\n * Element Locator - Primary key for element identification\n *\n * Uses multiple strategies to locate elements, supporting:\n * - HMR/DOM changes recovery\n * - Cross-session persistence\n * - Framework-agnostic identification\n */\nexport interface ElementLocator {\n  /** CSS selector candidates (ordered by specificity) */\n  selectors: string[];\n  /** Structural fingerprint for similarity matching */\n  fingerprint: string;\n  /** Framework debug information (React/Vue) */\n  debugSource?: DebugSource;\n  /** DOM tree path (child indices from root) */\n  path: number[];\n  /** iframe selector chain (from top to target frame) - Phase 4 */\n  frameChain?: string[];\n  /** Shadow DOM host selector chain - Phase 2 */\n  shadowHostChain?: string[];\n}\n\n// =============================================================================\n// Transaction System (Phase 1 - Basic Structure, Low Priority)\n// =============================================================================\n\n/** Transaction operation types */\nexport type TransactionType = 'style' | 'text' | 'class' | 'move' | 'structure';\n\n/**\n * Transaction snapshot for undo/redo\n * Captures element state before/after changes\n */\nexport interface TransactionSnapshot {\n  /** Element locator for re-identification */\n  locator: ElementLocator;\n  /** innerHTML snapshot (for structure changes) */\n  html?: string;\n  /** Changed style properties */\n  styles?: Record<string, string>;\n  /** Class list tokens (from `class` attribute) */\n  classes?: string[];\n  /** Text content */\n  text?: string;\n}\n\n/**\n * Move position data\n * Captures a concrete insertion point under a parent element\n */\nexport interface MoveOperationData {\n  /** Target parent element locator */\n  parentLocator: ElementLocator;\n  /** Insert position index (among element children) */\n  insertIndex: number;\n  /** Anchor sibling element locator (for stable positioning) */\n  anchorLocator?: ElementLocator;\n  /** Position relative to anchor */\n  anchorPosition: 'before' | 'after';\n}\n\n/**\n * Move transaction data\n * Captures both source and destination for undo/redo\n */\nexport interface MoveTransactionData {\n  /** Original location before move */\n  from: MoveOperationData;\n  /** Target location after move */\n  to: MoveOperationData;\n}\n\n/**\n * Structure operation data\n * For wrap/unwrap/delete/duplicate operations (Phase 5.5)\n */\nexport interface StructureOperationData {\n  /** Structure action type */\n  action: 'wrap' | 'unwrap' | 'delete' | 'duplicate';\n  /** Wrapper tag for wrap/unwrap actions */\n  wrapperTag?: string;\n  /** Wrapper inline styles for wrap/unwrap actions */\n  wrapperStyles?: Record<string, string>;\n  /**\n   * Deterministic insertion position for undo/redo.\n   * Required for delete (restore) and duplicate (re-create).\n   */\n  position?: MoveOperationData;\n  /**\n   * Serialized element HTML for undo/redo.\n   * Must be a single-root element outerHTML string.\n   * Used by delete (restore original) and duplicate (re-create clone).\n   */\n  html?: string;\n}\n\n/**\n * Transaction record for undo/redo system\n */\nexport interface Transaction {\n  /** Unique transaction ID */\n  id: string;\n  /** Operation type */\n  type: TransactionType;\n  /** Target element locator */\n  targetLocator: ElementLocator;\n  /**\n   * Stable element identifier for cross-transaction grouping.\n   * Used by AgentChat integration for element chips aggregation.\n   * Optional for backward compatibility with existing transactions.\n   */\n  elementKey?: string;\n  /** State before change */\n  before: TransactionSnapshot;\n  /** State after change */\n  after: TransactionSnapshot;\n  /** Move-specific data */\n  moveData?: MoveTransactionData;\n  /** Structure-specific data */\n  structureData?: StructureOperationData;\n  /** Timestamp */\n  timestamp: number;\n  /** Whether merged with previous transaction */\n  merged: boolean;\n}\n\n// =============================================================================\n// AgentChat Integration Types (Phase 1.1)\n// =============================================================================\n\n/** Stable element identifier for aggregating transactions across UI contexts */\nexport type WebEditorElementKey = string;\n\n/**\n * Net effect payload for a single element aggregated from the undo stack.\n * Designed to be directly consumable by prompt builders.\n */\nexport interface NetEffectPayload {\n  /** Stable element key */\n  elementKey: WebEditorElementKey;\n  /** Locator snapshot for element re-identification */\n  locator: ElementLocator;\n  /**\n   * Aggregated style changes (first before -> last after).\n   * Contains ONLY the affected properties, not a full style snapshot.\n   * Empty string value means the property was removed/unset.\n   */\n  styleChanges?: {\n    before: Record<string, string>;\n    after: Record<string, string>;\n  };\n  /** Aggregated text change (first before -> last after) */\n  textChange?: {\n    before: string;\n    after: string;\n  };\n  /** Aggregated class changes (first before -> last after) */\n  classChanges?: {\n    before: string[];\n    after: string[];\n  };\n}\n\n/** High-level change category for UI display */\nexport type ElementChangeType = 'style' | 'text' | 'class' | 'mixed';\n\n/**\n * Element change summary for Chips rendering in AgentChat.\n * Aggregates multiple transactions for the same element.\n */\nexport interface ElementChangeSummary {\n  /** Stable element identifier */\n  elementKey: WebEditorElementKey;\n  /** Short label for Chips display (e.g., \"button#submit\") */\n  label: string;\n  /** Full label for tooltips with more context */\n  fullLabel: string;\n  /** Locator snapshot for highlighting and element recovery */\n  locator: ElementLocator;\n  /** High-level change category */\n  type: ElementChangeType;\n  /** Detailed change statistics for UI tooltips */\n  changes: {\n    style?: {\n      /** Number of new style properties added */\n      added: number;\n      /** Number of style properties removed */\n      removed: number;\n      /** Number of style properties modified */\n      modified: number;\n      /** List of affected style property names */\n      details: string[];\n    };\n    text?: {\n      /** Truncated preview of original text */\n      beforePreview: string;\n      /** Truncated preview of new text */\n      afterPreview: string;\n    };\n    class?: {\n      /** Classes added */\n      added: string[];\n      /** Classes removed */\n      removed: string[];\n    };\n  };\n  /** Contributing transaction IDs in chronological order */\n  transactionIds: string[];\n  /** Net effect payload for batch Apply */\n  netEffect: NetEffectPayload;\n  /** Timestamp of the most recent transaction */\n  updatedAt: number;\n  /** Debug source information if available */\n  debugSource?: DebugSource;\n}\n\n/** Action types for TX change events */\nexport type WebEditorTxChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback';\n\n/**\n * TX change broadcast payload sent to Sidepanel/AgentChat.\n * Emitted when the undo stack changes (push, undo, redo, clear).\n */\nexport interface WebEditorTxChangedPayload {\n  /** Source tab ID for multi-tab isolation */\n  tabId: number;\n  /** Action that triggered this change (for UI animations/incremental updates) */\n  action: WebEditorTxChangeAction;\n  /** Aggregated element-level summaries from the current undo stack */\n  elements: ElementChangeSummary[];\n  /** Current undo stack size */\n  undoCount: number;\n  /** Current redo stack size */\n  redoCount: number;\n  /** Whether there are applicable changes (style/text/class) */\n  hasApplicableChanges: boolean;\n  /** Page URL for context */\n  pageUrl?: string;\n}\n\n/**\n * Batch Apply payload sent from web-editor to background.\n */\nexport interface WebEditorApplyBatchPayload {\n  /** Source tab ID */\n  tabId: number;\n  /** Element changes to apply */\n  elements: ElementChangeSummary[];\n  /** Element keys excluded by user */\n  excludedKeys: WebEditorElementKey[];\n  /** Page URL for context */\n  pageUrl?: string;\n}\n\n/**\n * Highlight element request sent from AgentChat to the active tab.\n */\nexport interface WebEditorHighlightElementPayload {\n  /** Target tab ID */\n  tabId: number;\n  /** Element key to highlight */\n  elementKey: WebEditorElementKey;\n  /** Locator for element identification */\n  locator: ElementLocator;\n  /** Highlight mode: 'hover' to show, 'clear' to hide */\n  mode: 'hover' | 'clear';\n}\n\n/**\n * Revert element request sent from AgentChat to the active tab.\n * Used for Phase 2 - Selective Undo (reverting individual element changes).\n */\nexport interface WebEditorRevertElementPayload {\n  /** Target tab ID */\n  tabId: number;\n  /** Element key to revert */\n  elementKey: WebEditorElementKey;\n}\n\n/**\n * Revert element response from content script.\n */\nexport interface WebEditorRevertElementResponse {\n  /** Whether the revert was successful */\n  success: boolean;\n  /** What was reverted (for UI feedback) */\n  reverted?: {\n    style?: boolean;\n    text?: boolean;\n    class?: boolean;\n  };\n  /** Error message if revert failed */\n  error?: string;\n}\n\n// =============================================================================\n// Selection Sync Types\n// =============================================================================\n\n/**\n * Summary of currently selected element.\n * Lightweight payload for selection sync (no transaction data).\n */\nexport interface SelectedElementSummary {\n  /** Stable element identifier */\n  elementKey: WebEditorElementKey;\n  /** Locator for element identification and highlighting */\n  locator: ElementLocator;\n  /** Short display label (e.g., \"div#app\") */\n  label: string;\n  /** Full label with context (e.g., \"body > div#app\") */\n  fullLabel: string;\n  /** Tag name of the element */\n  tagName: string;\n  /** Timestamp for deduplication */\n  updatedAt: number;\n}\n\n/**\n * Selection change broadcast payload.\n * Sent immediately when user selects/deselects elements (no debounce).\n */\nexport interface WebEditorSelectionChangedPayload {\n  /** Source tab ID (filled by background from sender.tab.id) */\n  tabId: number;\n  /** Currently selected element, or null if deselected */\n  selected: SelectedElementSummary | null;\n  /** Page URL for context */\n  pageUrl?: string;\n}\n\n// =============================================================================\n// Execution Cancel Types\n// =============================================================================\n\n/**\n * Payload for canceling an ongoing Apply execution.\n * Sent from web-editor toolbar or sidepanel to background.\n */\nexport interface WebEditorCancelExecutionPayload {\n  /** Session ID of the execution to cancel */\n  sessionId: string;\n  /** Request ID of the execution to cancel */\n  requestId: string;\n}\n\n/**\n * Response from cancel execution request.\n */\nexport interface WebEditorCancelExecutionResponse {\n  /** Whether the cancel request was successful */\n  success: boolean;\n  /** Error message if cancellation failed */\n  error?: string;\n}\n\n// =============================================================================\n// Public API Interface\n// =============================================================================\n\n/**\n * Web Editor V2 Public API\n * Exposed on window.__MCP_WEB_EDITOR_V2__\n */\nexport interface WebEditorV2Api {\n  /** Start the editor */\n  start: () => void;\n  /** Stop the editor */\n  stop: () => void;\n  /** Toggle editor on/off, returns new state */\n  toggle: () => boolean;\n  /** Get current state */\n  getState: () => WebEditorState;\n  /**\n   * Revert a specific element to its original state (Phase 2 - Selective Undo).\n   * Creates a compensating transaction that can be undone.\n   */\n  revertElement: (elementKey: WebEditorElementKey) => Promise<WebEditorRevertElementResponse>;\n  /**\n   * Clear current selection (called from sidepanel after send).\n   * Triggers deselect and broadcasts null selection.\n   */\n  clearSelection: () => void;\n}\n\n// =============================================================================\n// Global Declaration\n// =============================================================================\n\ndeclare global {\n  interface Window {\n    __MCP_WEB_EDITOR_V2__?: WebEditorV2Api;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/element-marker/element-marker-storage.ts",
    "content": "// IndexedDB storage for element markers (URL -> marked selectors)\n// Uses the shared IndexedDbClient for robust transaction handling.\n\nimport { IndexedDbClient } from '@/utils/indexeddb-client';\nimport type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';\n\nconst DB_NAME = 'element_marker_storage';\nconst DB_VERSION = 1;\nconst STORE = 'markers';\n\nconst idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {\n  switch (oldVersion) {\n    case 0: {\n      const store = db.createObjectStore(STORE, { keyPath: 'id' });\n      // Useful indexes for lookups\n      store.createIndex('by_host', 'host', { unique: false });\n      store.createIndex('by_origin', 'origin', { unique: false });\n      store.createIndex('by_path', 'path', { unique: false });\n    }\n  }\n});\n\nfunction normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {\n  try {\n    const u = new URL(raw);\n    return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };\n  } catch {\n    return { url: raw, origin: '', host: '', path: '' };\n  }\n}\n\nfunction now(): number {\n  return Date.now();\n}\n\nexport async function listAllMarkers(): Promise<ElementMarker[]> {\n  return idb.getAll<ElementMarker>(STORE);\n}\n\nexport async function listMarkersForUrl(url: string): Promise<ElementMarker[]> {\n  const { origin, path, host } = normalizeUrl(url);\n  const all = await idb.getAll<ElementMarker>(STORE);\n  // Simple matching policy:\n  // - exact: origin + path must match exactly\n  // - prefix: origin matches and marker.path is a prefix of current path\n  // - host: host matches regardless of path\n  return all.filter((m) => {\n    if (!m) return false;\n    if (m.matchType === 'exact') return m.origin === origin && m.path === path;\n    if (m.matchType === 'host') return !!m.host && m.host === host;\n    // default 'prefix'\n    return m.origin === origin && (m.path ? path.startsWith(m.path) : true);\n  });\n}\n\nexport async function saveMarker(req: UpsertMarkerRequest): Promise<ElementMarker> {\n  const { url: rawUrl, selector } = req;\n  if (!rawUrl || !selector) throw new Error('url and selector are required');\n  const { url, origin, host, path } = normalizeUrl(rawUrl);\n  const ts = now();\n  const marker: ElementMarker = {\n    id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),\n    url,\n    origin,\n    host,\n    path,\n    matchType: req.matchType || 'prefix',\n    name: req.name || selector,\n    selector,\n    selectorType: req.selectorType || 'css',\n    listMode: req.listMode || false,\n    action: req.action || 'custom',\n    createdAt: ts,\n    updatedAt: ts,\n  };\n  await idb.put<ElementMarker>(STORE, marker);\n  return marker;\n}\n\nexport async function updateMarker(marker: ElementMarker): Promise<void> {\n  const existing = await idb.get<ElementMarker>(STORE, marker.id);\n  if (!existing) throw new Error('marker not found');\n\n  // Preserve createdAt from existing record, only update updatedAt\n  const updated: ElementMarker = {\n    ...marker,\n    createdAt: existing.createdAt, // Never overwrite createdAt\n    updatedAt: now(),\n  };\n  await idb.put<ElementMarker>(STORE, updated);\n}\n\nexport async function deleteMarker(id: string): Promise<void> {\n  await idb.delete(STORE, id);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/element-marker/index.ts",
    "content": "import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport type {\n  UpsertMarkerRequest,\n  ElementMarker,\n  MarkerValidationRequest,\n  MarkerValidationAction,\n} from '@/common/element-marker-types';\nimport {\n  deleteMarker,\n  listAllMarkers,\n  listMarkersForUrl,\n  saveMarker,\n  updateMarker,\n} from './element-marker-storage';\nimport { computerTool } from '@/entrypoints/background/tools/browser/computer';\nimport { clickTool } from '@/entrypoints/background/tools/browser/interaction';\nimport { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard';\n\nconst CONTEXT_MENU_ID = 'element_marker_mark';\n\n/**\n * Extract error message from MCP tool result\n */\nfunction extractToolError(result: any): string | undefined {\n  if (!result) return undefined;\n\n  // Check for error in result content array\n  if (Array.isArray(result.content)) {\n    for (const item of result.content) {\n      if (item?.text) {\n        try {\n          const parsed = JSON.parse(item.text);\n          if (parsed?.error) return parsed.error;\n          if (parsed?.message) return parsed.message;\n        } catch {\n          // Not JSON, use as-is\n          return item.text;\n        }\n      }\n    }\n  }\n\n  // Fallback to direct error field\n  return result.error || (result.isError ? 'unknown tool error' : undefined);\n}\n\nasync function ensureContextMenu() {\n  try {\n    // Guard: contextMenus permission may be missing\n    if (!(chrome as any).contextMenus?.create) return;\n    // Remove and re-create our single menu to avoid duplication\n    try {\n      await chrome.contextMenus.remove(CONTEXT_MENU_ID);\n    } catch {}\n    await chrome.contextMenus.create({\n      id: CONTEXT_MENU_ID,\n      title: '标注元素',\n      contexts: ['all'],\n    });\n  } catch (e) {\n    console.warn('ElementMarker: ensureContextMenu failed:', e);\n  }\n}\n\n/**\n * Check if element-marker.js is already injected in the tab\n * Uses a short timeout to avoid hanging on unresponsive tabs\n */\nasync function isMarkerInjected(tabId: number): Promise<boolean> {\n  try {\n    const response = await Promise.race([\n      chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),\n      new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),\n    ]);\n    return response?.status === 'pong';\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Inject element-marker.js into the tab if not already injected\n */\nasync function injectMarkerHelper(tabId: number) {\n  // Check if already injected via ping\n  const alreadyInjected = await isMarkerInjected(tabId);\n\n  if (!alreadyInjected) {\n    try {\n      await chrome.scripting.executeScript({\n        target: { tabId, allFrames: true },\n        files: ['inject-scripts/element-marker.js'],\n        world: 'ISOLATED',\n      } as any);\n    } catch (e) {\n      // Script injection may fail on some pages (e.g., chrome:// URLs)\n      console.warn('ElementMarker: script injection failed:', e);\n    }\n  }\n\n  try {\n    await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any);\n  } catch (e) {\n    console.warn('ElementMarker: start overlay failed:', e);\n  }\n}\n\nexport function initElementMarkerListeners() {\n  // Ensure context menu on startup\n  ensureContextMenu().catch(() => {});\n\n  // Respond to RR triggers refresh by re-ensuring our menu a bit later\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    try {\n      switch (message?.type) {\n        // Handle element marker start from popup\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: {\n          const tabId = message.tabId;\n          if (typeof tabId !== 'number') {\n            sendResponse({ success: false, error: 'invalid tabId' });\n            return true;\n          }\n          injectMarkerHelper(tabId)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: {\n          listAllMarkers()\n            .then((markers) => sendResponse({ success: true, markers }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: {\n          const url = String(message.url || '');\n          listMarkersForUrl(url)\n            .then((markers) => sendResponse({ success: true, markers }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: {\n          const req = message.marker as UpsertMarkerRequest;\n          saveMarker(req)\n            .then((marker) => sendResponse({ success: true, marker }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: {\n          const marker = message.marker as ElementMarker;\n          updateMarker(marker)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: {\n          const id = String(message.id || '');\n          if (!id) {\n            sendResponse({ success: false, error: 'invalid id' });\n            return true;\n          }\n          deleteMarker(id)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: {\n          // Validate via MCP tool chain\n          (async () => {\n            const req = message as {\n              selector: string;\n              selectorType?: 'css' | 'xpath';\n              action: MarkerValidationAction;\n              listMode?: boolean;\n              text?: string;\n              keys?: string;\n              button?: 'left' | 'right' | 'middle';\n              bubbles?: boolean;\n              cancelable?: boolean;\n              modifiers?: any;\n              coordinates?: { x: number; y: number };\n              offsetX?: number;\n              offsetY?: number;\n              relativeTo?: 'element' | 'viewport';\n            };\n            // enrich typing with optional nav + scroll params\n            (req as any).waitForNavigation = (message as any).waitForNavigation;\n            (req as any).timeoutMs = (message as any).timeoutMs;\n            (req as any).scrollDirection = (message as any).scrollDirection;\n            (req as any).scrollAmount = (message as any).scrollAmount;\n            const selector = String(req.selector || '').trim();\n            const selectorType = (req.selectorType || 'css') as 'css' | 'xpath';\n            const action = req.action as MarkerValidationAction;\n            if (!selector) return sendResponse({ success: false, error: 'selector is required' });\n            const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n            const tab = tabs[0];\n            if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' });\n\n            // 1) Ensure helper\n            try {\n              await chrome.scripting.executeScript({\n                target: { tabId: tab.id, allFrames: true },\n                files: ['inject-scripts/accessibility-tree-helper.js'],\n                world: 'ISOLATED',\n              } as any);\n            } catch {}\n\n            // 2) Resolve selector -> ref/center via helper (same as tools)\n            let ensured: any;\n            try {\n              ensured = await chrome.tabs.sendMessage(tab.id, {\n                action: 'ensureRefForSelector',\n                selector,\n                isXPath: selectorType === 'xpath',\n                allowMultiple: !!req.listMode,\n              } as any);\n            } catch (e) {\n              return sendResponse({\n                success: false,\n                error: String(e instanceof Error ? e.message : e),\n              });\n            }\n            if (!ensured || !ensured.success || !ensured.ref) {\n              return sendResponse({\n                success: false,\n                error: ensured?.error || 'failed to resolve selector',\n              });\n            }\n\n            const base = {\n              success: true,\n              resolved: true,\n              ref: ensured.ref,\n              center: ensured.center,\n            } as any;\n\n            // Compute optional coordinates from offsets\n            let coords: { x: number; y: number } | undefined = undefined;\n            if (\n              req.coordinates &&\n              typeof req.coordinates.x === 'number' &&\n              typeof req.coordinates.y === 'number'\n            ) {\n              coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) };\n            } else if (\n              req.relativeTo === 'element' &&\n              ensured.center &&\n              (typeof req.offsetX === 'number' || typeof req.offsetY === 'number')\n            ) {\n              const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0;\n              const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0;\n              coords = { x: ensured.center.x + dx, y: ensured.center.y + dy };\n            }\n\n            // 3) Dispatch to appropriate tool for end-to-end validation\n            try {\n              switch (action) {\n                case 'hover': {\n                  const r = await computerTool.execute(\n                    coords\n                      ? { action: 'hover', coordinates: coords }\n                      : ({ action: 'hover', ref: ensured.ref } as any),\n                  );\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'computer.hover', ok: !r.isError, error };\n                  break;\n                }\n                case 'left_click': {\n                  const r = await clickTool.execute({\n                    ...(coords ? { coordinates: coords } : { ref: ensured.ref }),\n                    waitForNavigation: !!req.waitForNavigation,\n                    timeout: Number.isFinite(req.timeoutMs as any)\n                      ? (req.timeoutMs as number)\n                      : 3000,\n                    button: (req.button || 'left') as any,\n                    modifiers: req.modifiers || {},\n                  } as any);\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'interaction.click', ok: !r.isError, error };\n                  break;\n                }\n                case 'double_click': {\n                  const r = await clickTool.execute({\n                    ...(coords ? { coordinates: coords } : { ref: ensured.ref }),\n                    double: true,\n                    waitForNavigation: !!req.waitForNavigation,\n                    timeout: Number.isFinite(req.timeoutMs as any)\n                      ? (req.timeoutMs as number)\n                      : 3000,\n                    button: (req.button || 'left') as any,\n                    modifiers: req.modifiers || {},\n                  } as any);\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'interaction.click(double)', ok: !r.isError, error };\n                  break;\n                }\n                case 'right_click': {\n                  const r = await clickTool.execute({\n                    ...(coords ? { coordinates: coords } : { ref: ensured.ref }),\n                    waitForNavigation: !!req.waitForNavigation,\n                    timeout: Number.isFinite(req.timeoutMs as any)\n                      ? (req.timeoutMs as number)\n                      : 3000,\n                    button: 'right',\n                    modifiers: req.modifiers || {},\n                  } as any);\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'interaction.click(right)', ok: !r.isError, error };\n                  break;\n                }\n                case 'scroll': {\n                  const direction = (req as any).scrollDirection || 'down';\n                  const amount = Number.isFinite((req as any).scrollAmount)\n                    ? Number((req as any).scrollAmount)\n                    : 300;\n                  const payload = coords\n                    ? {\n                        action: 'scroll',\n                        scrollDirection: direction,\n                        scrollAmount: amount,\n                        coordinates: coords,\n                      }\n                    : ({\n                        action: 'scroll',\n                        scrollDirection: direction,\n                        scrollAmount: amount,\n                        ref: ensured.ref,\n                      } as any);\n                  const r = await computerTool.execute(payload as any);\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'computer.scroll', ok: !r.isError, error };\n                  break;\n                }\n                case 'type_text': {\n                  const text = String(req.text || '');\n                  const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text });\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'computer.type', ok: !r.isError, error };\n                  break;\n                }\n                case 'press_keys': {\n                  const keys = String(req.keys || '');\n                  // Focus first by ref to ensure key target\n                  try {\n                    await clickTool.execute({\n                      ref: ensured.ref,\n                      waitForNavigation: false,\n                      timeout: 2000,\n                    });\n                  } catch {}\n                  const r = await keyboardTool.execute({ keys, delay: 0 } as any);\n                  const error = r.isError ? extractToolError(r) : undefined;\n                  base.tool = { name: 'keyboard.simulate', ok: !r.isError, error };\n                  break;\n                }\n                default: {\n                  base.tool = { name: 'noop', ok: true };\n                }\n              }\n            } catch (e) {\n              console.warn('[ElementMarker] Validation failed before tool execution', e);\n              base.tool = {\n                name: 'unknown',\n                ok: false,\n                error: String(e instanceof Error ? e.message : e),\n              };\n            }\n\n            // Log tool failures for debugging\n            if (base.tool && base.tool.ok === false) {\n              console.warn('[ElementMarker] Tool validation failure', {\n                action,\n                toolName: base.tool.name,\n                error: base.tool.error,\n                selector,\n                selectorType,\n              });\n            }\n\n            return sendResponse(base);\n          })();\n          return true;\n        }\n        // When RR refresh (or similar) happens, re-add our menu\n        case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS:\n        case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER:\n        case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {\n          setTimeout(() => ensureContextMenu().catch(() => {}), 300);\n          break;\n        }\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: (e as any)?.message || String(e) });\n    }\n    return false;\n  });\n\n  // Context menu click routing\n  if ((chrome as any).contextMenus?.onClicked?.addListener) {\n    chrome.contextMenus.onClicked.addListener(async (info, tab) => {\n      try {\n        if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {\n          await injectMarkerHelper(tab.id);\n        }\n      } catch (e) {\n        console.warn('ElementMarker: context menu click failed:', e);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/index.ts",
    "content": "import { initNativeHostListener } from './native-host';\nimport {\n  initSemanticSimilarityListener,\n  initializeSemanticEngineIfCached,\n} from './semantic-similarity';\nimport { initStorageManagerListener } from './storage-manager';\nimport { cleanupModelCache } from '@/utils/semantic-similarity-engine';\nimport { initRecordReplayListeners } from './record-replay';\nimport { initElementMarkerListeners } from './element-marker';\nimport { initWebEditorListeners } from './web-editor';\nimport { initQuickPanelAgentHandler } from './quick-panel/agent-handler';\nimport { initQuickPanelCommands } from './quick-panel/commands';\nimport { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';\n\n// Record-Replay V3 (feature flag)\nimport { bootstrapV3 } from './record-replay-v3/bootstrap';\n\n/**\n * Feature flag for RR-V3\n * Set to true to enable the new Record-Replay V3 engine\n */\nconst ENABLE_RR_V3 = true;\n\n/**\n * Background script entry point\n * Initializes all background services and listeners\n */\nexport default defineBackground(() => {\n  // Open welcome page on first install\n  chrome.runtime.onInstalled.addListener((details) => {\n    if (details.reason === 'install') {\n      // Open the welcome/onboarding page for new installations\n      chrome.tabs.create({\n        url: chrome.runtime.getURL('/welcome.html'),\n      });\n    }\n  });\n\n  // Initialize core services\n  initNativeHostListener();\n  initSemanticSimilarityListener();\n  initStorageManagerListener();\n  // Record & Replay V1/V2 listeners\n  initRecordReplayListeners();\n\n  // Record & Replay V3 (new engine)\n  if (ENABLE_RR_V3) {\n    bootstrapV3()\n      .then((runtime) => {\n        console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`);\n      })\n      .catch((error) => {\n        console.error('[RR-V3] Bootstrap failed:', error);\n      });\n  }\n\n  // Element marker: context menu + CRUD listeners\n  initElementMarkerListeners();\n  // Web editor: toggle edit-mode overlay\n  initWebEditorListeners();\n  // Quick Panel: send messages to AgentChat via background-stream bridge\n  initQuickPanelAgentHandler();\n  // Quick Panel: tabs search bridge for content script UI\n  initQuickPanelTabsHandler();\n  // Quick Panel: keyboard shortcut handler\n  initQuickPanelCommands();\n\n  // Conditionally initialize semantic similarity engine if model cache exists\n  initializeSemanticEngineIfCached()\n    .then((initialized) => {\n      if (initialized) {\n        console.log('Background: Semantic similarity engine initialized from cache');\n      } else {\n        console.log(\n          'Background: Semantic similarity engine initialization skipped (no cache found)',\n        );\n      }\n    })\n    .catch((error) => {\n      console.warn('Background: Failed to conditionally initialize semantic engine:', error);\n    });\n\n  // Initial cleanup on startup\n  cleanupModelCache().catch((error) => {\n    console.warn('Background: Initial cache cleanup failed:', error);\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/keepalive-manager.ts",
    "content": "/**\n * @fileoverview Keepalive Manager\n * @description Global singleton service for managing Service Worker keepalive.\n *\n * This module provides a unified interface for acquiring and releasing keepalive\n * references. Multiple modules can acquire keepalive independently using tags,\n * and the underlying keepalive mechanism will remain active as long as at least\n * one reference is held.\n */\n\nimport {\n  createOffscreenKeepaliveController,\n  type KeepaliveController,\n} from './record-replay-v3/engine/keepalive/offscreen-keepalive';\n\nconst LOG_PREFIX = '[KeepaliveManager]';\n\n/**\n * Singleton keepalive controller instance.\n * Created lazily to avoid initialization issues during module loading.\n */\nlet controller: KeepaliveController | null = null;\n\n/**\n * Get or create the singleton keepalive controller.\n */\nfunction getController(): KeepaliveController {\n  if (!controller) {\n    controller = createOffscreenKeepaliveController({ logger: console });\n    console.debug(`${LOG_PREFIX} Controller initialized`);\n  }\n  return controller;\n}\n\n/**\n * Acquire a keepalive reference with a tag.\n *\n * @param tag - Identifier for the reference (e.g., 'native-host', 'rr-engine')\n * @returns A release function to call when keepalive is no longer needed\n *\n * @example\n * ```typescript\n * const release = acquireKeepalive('native-host');\n * // ... do work that needs SW to stay alive ...\n * release(); // Release when done\n * ```\n */\nexport function acquireKeepalive(tag: string): () => void {\n  try {\n    const release = getController().acquire(tag);\n    console.debug(`${LOG_PREFIX} Acquired keepalive for tag: ${tag}`);\n    return () => {\n      try {\n        release();\n        console.debug(`${LOG_PREFIX} Released keepalive for tag: ${tag}`);\n      } catch (error) {\n        console.warn(`${LOG_PREFIX} Failed to release keepalive for ${tag}:`, error);\n      }\n    };\n  } catch (error) {\n    console.warn(`${LOG_PREFIX} Failed to acquire keepalive for ${tag}:`, error);\n    return () => {};\n  }\n}\n\n/**\n * Check if keepalive is currently active (any references held).\n */\nexport function isKeepaliveActive(): boolean {\n  try {\n    return getController().isActive();\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get the current keepalive reference count.\n * Useful for debugging.\n */\nexport function getKeepaliveRefCount(): number {\n  try {\n    return getController().getRefCount();\n  } catch {\n    return 0;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/native-host.ts",
    "content": "import { NativeMessageType } from 'chrome-mcp-shared';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants';\nimport { handleCallTool } from './tools';\nimport { listPublished, getFlow } from './record-replay/flow-store';\nimport { acquireKeepalive } from './keepalive-manager';\n\nconst LOG_PREFIX = '[NativeHost]';\n\nlet nativePort: chrome.runtime.Port | null = null;\nexport const HOST_NAME = NATIVE_HOST.NAME;\n\n// ==================== Reconnect Configuration ====================\n\nconst RECONNECT_BASE_DELAY_MS = 500;\nconst RECONNECT_MAX_DELAY_MS = 60_000;\nconst RECONNECT_MAX_FAST_ATTEMPTS = 8;\nconst RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000;\n\n// ==================== Auto-connect State ====================\n\nlet keepaliveRelease: (() => void) | null = null;\nlet autoConnectEnabled = true;\nlet autoConnectLoaded = false;\nlet ensurePromise: Promise<boolean> | null = null;\nlet reconnectTimer: ReturnType<typeof setTimeout> | null = null;\nlet reconnectAttempts = 0;\nlet manualDisconnect = false;\n\n/**\n * Server status management interface\n */\ninterface ServerStatus {\n  isRunning: boolean;\n  port?: number;\n  lastUpdated: number;\n}\n\nlet currentServerStatus: ServerStatus = {\n  isRunning: false,\n  lastUpdated: Date.now(),\n};\n\n/**\n * Save server status to chrome.storage\n */\nasync function saveServerStatus(status: ServerStatus): Promise<void> {\n  try {\n    await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status });\n  } catch (error) {\n    console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error);\n  }\n}\n\n/**\n * Load server status from chrome.storage\n */\nasync function loadServerStatus(): Promise<ServerStatus> {\n  try {\n    const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]);\n    if (result[STORAGE_KEYS.SERVER_STATUS]) {\n      return result[STORAGE_KEYS.SERVER_STATUS];\n    }\n  } catch (error) {\n    console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);\n  }\n  return {\n    isRunning: false,\n    lastUpdated: Date.now(),\n  };\n}\n\n/**\n * Broadcast server status change to all listeners\n */\nfunction broadcastServerStatusChange(status: ServerStatus): void {\n  chrome.runtime\n    .sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED,\n      payload: status,\n    })\n    .catch(() => {\n      // Ignore errors if no listeners are present\n    });\n}\n\n// ==================== Port Normalization ====================\n\n/**\n * Normalize a port value to a valid port number or null.\n */\nfunction normalizePort(value: unknown): number | null {\n  const n =\n    typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;\n  if (!Number.isFinite(n)) return null;\n  const port = Math.floor(n);\n  if (port <= 0 || port > 65535) return null;\n  return port;\n}\n\n// ==================== Reconnect Utilities ====================\n\n/**\n * Add jitter to a delay value to avoid thundering herd.\n */\nfunction withJitter(ms: number): number {\n  const ratio = 0.7 + Math.random() * 0.6;\n  return Math.max(0, Math.round(ms * ratio));\n}\n\n/**\n * Calculate reconnect delay based on attempt number.\n * Uses exponential backoff with jitter, then switches to cooldown interval.\n */\nfunction getReconnectDelayMs(attempt: number): number {\n  if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) {\n    return withJitter(RECONNECT_COOLDOWN_DELAY_MS);\n  }\n  const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS);\n  return withJitter(delay);\n}\n\n/**\n * Clear the reconnect timer if active.\n */\nfunction clearReconnectTimer(): void {\n  if (!reconnectTimer) return;\n  clearTimeout(reconnectTimer);\n  reconnectTimer = null;\n}\n\n/**\n * Reset reconnect state after successful connection.\n */\nfunction resetReconnectState(): void {\n  reconnectAttempts = 0;\n  clearReconnectTimer();\n}\n\n// ==================== Keepalive Management ====================\n\n/**\n * Sync keepalive hold based on autoConnectEnabled state.\n * When auto-connect is enabled, we hold a keepalive reference to keep SW alive.\n */\nfunction syncKeepaliveHold(): void {\n  if (autoConnectEnabled) {\n    if (!keepaliveRelease) {\n      keepaliveRelease = acquireKeepalive('native-host');\n      console.debug(`${LOG_PREFIX} Acquired keepalive`);\n    }\n    return;\n  }\n  if (keepaliveRelease) {\n    try {\n      keepaliveRelease();\n      console.debug(`${LOG_PREFIX} Released keepalive`);\n    } catch {\n      // Ignore\n    }\n    keepaliveRelease = null;\n  }\n}\n\n// ==================== Auto-connect Settings ====================\n\n/**\n * Load the nativeAutoConnectEnabled setting from storage.\n */\nasync function loadNativeAutoConnectEnabled(): Promise<boolean> {\n  try {\n    const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]);\n    const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED];\n    if (typeof raw === 'boolean') return raw;\n  } catch (error) {\n    console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error);\n  }\n  return true; // Default to enabled\n}\n\n/**\n * Set the nativeAutoConnectEnabled setting and persist to storage.\n */\nasync function setNativeAutoConnectEnabled(enabled: boolean): Promise<void> {\n  autoConnectEnabled = enabled;\n  autoConnectLoaded = true;\n  try {\n    await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled });\n    console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`);\n  } catch (error) {\n    console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error);\n  }\n  syncKeepaliveHold();\n}\n\n// ==================== Port Preference ====================\n\n/**\n * Get the preferred port for connecting to native server.\n * Priority: explicit override > user preference > last known port > default\n */\nasync function getPreferredPort(override?: unknown): Promise<number> {\n  const explicit = normalizePort(override);\n  if (explicit) return explicit;\n\n  try {\n    const result = await chrome.storage.local.get([\n      STORAGE_KEYS.NATIVE_SERVER_PORT,\n      STORAGE_KEYS.SERVER_STATUS,\n    ]);\n\n    const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]);\n    if (userPort) return userPort;\n\n    const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial<ServerStatus> | undefined;\n    const statusPort = normalizePort(status?.port);\n    if (statusPort) return statusPort;\n  } catch (error) {\n    console.warn(`${LOG_PREFIX} Failed to read preferred port`, error);\n  }\n\n  const inMemoryPort = normalizePort(currentServerStatus.port);\n  if (inMemoryPort) return inMemoryPort;\n\n  return NATIVE_HOST.DEFAULT_PORT;\n}\n\n// ==================== Reconnect Scheduling ====================\n\n/**\n * Schedule a reconnect attempt with exponential backoff.\n */\nfunction scheduleReconnect(reason: string): void {\n  if (nativePort) return;\n  if (manualDisconnect) return;\n  if (!autoConnectEnabled) return;\n  if (reconnectTimer) return;\n\n  const delay = getReconnectDelayMs(reconnectAttempts);\n  console.debug(\n    `${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`,\n  );\n\n  reconnectTimer = setTimeout(() => {\n    reconnectTimer = null;\n    if (nativePort) return;\n    if (manualDisconnect || !autoConnectEnabled) return;\n\n    reconnectAttempts += 1;\n    void ensureNativeConnected(`reconnect:${reason}`).catch(() => {});\n  }, delay);\n}\n\n// ==================== Server Status Update ====================\n\n/**\n * Mark server as stopped and broadcast the change.\n */\nasync function markServerStopped(reason: string): Promise<void> {\n  currentServerStatus = {\n    isRunning: false,\n    port: currentServerStatus.port,\n    lastUpdated: Date.now(),\n  };\n  try {\n    await saveServerStatus(currentServerStatus);\n  } catch {\n    // Ignore\n  }\n  broadcastServerStatusChange(currentServerStatus);\n  console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`);\n}\n\n// ==================== Core Ensure Function ====================\n\n/**\n * Ensure native connection is established.\n * This is the main entry point for auto-connect logic.\n *\n * @param trigger - Description of what triggered this call (for logging)\n * @param portOverride - Optional explicit port to use\n * @returns Whether the connection is now established\n */\nasync function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise<boolean> {\n  // Concurrency protection: only one ensure flow at a time\n  if (ensurePromise) return ensurePromise;\n\n  ensurePromise = (async () => {\n    // Load auto-connect setting if not yet loaded\n    if (!autoConnectLoaded) {\n      autoConnectEnabled = await loadNativeAutoConnectEnabled();\n      autoConnectLoaded = true;\n      syncKeepaliveHold();\n    }\n\n    // If auto-connect is disabled, do nothing\n    if (!autoConnectEnabled) {\n      console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`);\n      return false;\n    }\n\n    // Sync keepalive hold\n    syncKeepaliveHold();\n\n    // Already connected\n    if (nativePort) {\n      console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`);\n      return true;\n    }\n\n    // Get the port to use\n    const port = await getPreferredPort(portOverride);\n    console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`);\n\n    // Attempt connection\n    const ok = connectNativeHost(port);\n    if (!ok) {\n      console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`);\n      scheduleReconnect(`connect_failed:${trigger}`);\n      return false;\n    }\n\n    console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`);\n    // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.\n    // Chrome may return a Port but disconnect immediately if native host is missing.\n    return true;\n  })().finally(() => {\n    ensurePromise = null;\n  });\n\n  return ensurePromise;\n}\n\n/**\n * Connect to the native messaging host\n * @returns Whether the connection was initiated successfully\n */\nexport function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean {\n  if (nativePort) {\n    return true;\n  }\n\n  try {\n    nativePort = chrome.runtime.connectNative(HOST_NAME);\n\n    nativePort.onMessage.addListener(async (message) => {\n      if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {\n        const requestId = message.requestId;\n        const requestPayload = message.payload;\n\n        nativePort?.postMessage({\n          responseToRequestId: requestId,\n          payload: {\n            status: 'success',\n            message: SUCCESS_MESSAGES.TOOL_EXECUTED,\n            data: requestPayload,\n          },\n        });\n      } else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {\n        const requestId = message.requestId;\n        try {\n          const result = await handleCallTool(message.payload);\n          nativePort?.postMessage({\n            responseToRequestId: requestId,\n            payload: {\n              status: 'success',\n              message: SUCCESS_MESSAGES.TOOL_EXECUTED,\n              data: result,\n            },\n          });\n        } catch (error) {\n          nativePort?.postMessage({\n            responseToRequestId: requestId,\n            payload: {\n              status: 'error',\n              message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED,\n              error: error instanceof Error ? error.message : String(error),\n            },\n          });\n        }\n      } else if (message.type === 'rr_list_published_flows' && message.requestId) {\n        const requestId = message.requestId;\n        try {\n          const published = await listPublished();\n          const items = [] as any[];\n          for (const p of published) {\n            const flow = await getFlow(p.id);\n            if (!flow) continue;\n            items.push({\n              id: p.id,\n              slug: p.slug,\n              version: p.version,\n              name: p.name,\n              description: p.description || flow.description || '',\n              variables: flow.variables || [],\n              meta: flow.meta || {},\n            });\n          }\n          nativePort?.postMessage({\n            responseToRequestId: requestId,\n            payload: { status: 'success', items },\n          });\n        } catch (error: any) {\n          nativePort?.postMessage({\n            responseToRequestId: requestId,\n            payload: { status: 'error', error: error?.message || String(error) },\n          });\n        }\n      } else if (message.type === NativeMessageType.SERVER_STARTED) {\n        const port = message.payload?.port;\n        currentServerStatus = {\n          isRunning: true,\n          port: port,\n          lastUpdated: Date.now(),\n        };\n        await saveServerStatus(currentServerStatus);\n        broadcastServerStatusChange(currentServerStatus);\n        // Server is confirmed running - now we can reset reconnect state\n        resetReconnectState();\n        console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);\n      } else if (message.type === NativeMessageType.SERVER_STOPPED) {\n        currentServerStatus = {\n          isRunning: false,\n          port: currentServerStatus.port, // Keep last known port for reconnection\n          lastUpdated: Date.now(),\n        };\n        await saveServerStatus(currentServerStatus);\n        broadcastServerStatusChange(currentServerStatus);\n        console.log(SUCCESS_MESSAGES.SERVER_STOPPED);\n      } else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {\n        console.error('Error from native host:', message.payload?.message || 'Unknown error');\n      } else if (message.type === 'file_operation_response') {\n        // Forward file operation response back to the requesting tool\n        chrome.runtime.sendMessage(message).catch(() => {\n          // Ignore if no listeners\n        });\n      }\n    });\n\n    nativePort.onDisconnect.addListener(() => {\n      console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);\n      nativePort = null;\n\n      // Mark server as stopped since native host disconnection means server is down\n      void markServerStopped('native_port_disconnected');\n\n      // Handle reconnection based on disconnect reason\n      if (manualDisconnect) {\n        manualDisconnect = false;\n        return;\n      }\n      if (!autoConnectEnabled) return;\n      scheduleReconnect('native_port_disconnected');\n    });\n\n    nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });\n    // Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.\n    // Chrome may return a Port but disconnect immediately if native host is missing.\n    return true;\n  } catch (error) {\n    console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);\n    nativePort = null;\n    return false;\n  }\n}\n\n/**\n * Initialize native host listeners and load initial state\n */\nexport const initNativeHostListener = () => {\n  // Initialize server status from storage\n  loadServerStatus()\n    .then((status) => {\n      currentServerStatus = status;\n    })\n    .catch((error) => {\n      console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);\n    });\n\n  // Auto-connect on SW activation (covers SW restart after idle termination)\n  void ensureNativeConnected('sw_startup').catch(() => {});\n\n  // Auto-connect on Chrome browser startup\n  chrome.runtime.onStartup.addListener(() => {\n    void ensureNativeConnected('onStartup').catch(() => {});\n  });\n\n  // Auto-connect on extension install/update\n  chrome.runtime.onInstalled.addListener(() => {\n    void ensureNativeConnected('onInstalled').catch(() => {});\n  });\n\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    // Allow UI to call tools directly\n    if (message && message.type === 'call_tool' && message.name) {\n      handleCallTool({ name: message.name, args: message.args })\n        .then((res) => sendResponse({ success: true, result: res }))\n        .catch((err) =>\n          sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }),\n        );\n      return true;\n    }\n\n    const msgType = typeof message === 'string' ? message : message?.type;\n\n    // ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled\n    if (msgType === NativeMessageType.ENSURE_NATIVE) {\n      const portOverride = typeof message === 'object' ? message.port : undefined;\n      ensureNativeConnected('ui_ensure', portOverride)\n        .then((connected) => {\n          sendResponse({ success: true, connected, autoConnectEnabled });\n        })\n        .catch((e) => {\n          sendResponse({ success: false, connected: nativePort !== null, error: String(e) });\n        });\n      return true;\n    }\n\n    // CONNECT_NATIVE: Explicit user connect, re-enables auto-connect\n    if (msgType === NativeMessageType.CONNECT_NATIVE) {\n      const portOverride = typeof message === 'object' ? message.port : undefined;\n      const normalized = normalizePort(portOverride);\n\n      (async () => {\n        // Explicit user connect: re-enable auto-connect\n        await setNativeAutoConnectEnabled(true);\n\n        if (normalized) {\n          // Best-effort: persist preferred port\n          try {\n            await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized });\n          } catch {\n            // Ignore\n          }\n        }\n\n        return ensureNativeConnected('ui_connect', normalized ?? undefined);\n      })()\n        .then((connected) => {\n          sendResponse({ success: true, connected });\n        })\n        .catch((e) => {\n          sendResponse({ success: false, connected: nativePort !== null, error: String(e) });\n        });\n      return true;\n    }\n\n    if (msgType === NativeMessageType.PING_NATIVE) {\n      const connected = nativePort !== null;\n      sendResponse({ connected, autoConnectEnabled });\n      return true;\n    }\n\n    // DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect\n    if (msgType === NativeMessageType.DISCONNECT_NATIVE) {\n      (async () => {\n        // Explicit user disconnect: disable auto-connect and stop reconnect loop\n        await setNativeAutoConnectEnabled(false);\n        clearReconnectTimer();\n        reconnectAttempts = 0;\n        syncKeepaliveHold();\n\n        if (nativePort) {\n          // Only set manualDisconnect if we actually have a port to disconnect.\n          // This prevents the flag from persisting when there's no active connection.\n          manualDisconnect = true;\n          try {\n            nativePort.disconnect();\n          } catch {\n            // Ignore\n          }\n          nativePort = null;\n        }\n        await markServerStopped('manual_disconnect');\n      })()\n        .then(() => {\n          sendResponse({ success: true });\n        })\n        .catch((e) => {\n          sendResponse({ success: false, error: String(e) });\n        });\n      return true;\n    }\n\n    if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) {\n      sendResponse({\n        success: true,\n        serverStatus: currentServerStatus,\n        connected: nativePort !== null,\n      });\n      return true;\n    }\n\n    if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) {\n      loadServerStatus()\n        .then((storedStatus) => {\n          currentServerStatus = storedStatus;\n          sendResponse({\n            success: true,\n            serverStatus: currentServerStatus,\n            connected: nativePort !== null,\n          });\n        })\n        .catch((error) => {\n          console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);\n          sendResponse({\n            success: false,\n            error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED,\n            serverStatus: currentServerStatus,\n            connected: nativePort !== null,\n          });\n        });\n      return true;\n    }\n\n    // Forward file operation messages to native host\n    if (message.type === 'forward_to_native' && message.message) {\n      if (nativePort) {\n        nativePort.postMessage(message.message);\n        sendResponse({ success: true });\n      } else {\n        sendResponse({ success: false, error: 'Native host not connected' });\n      }\n      return true;\n    }\n  });\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/quick-panel/agent-handler.ts",
    "content": "/**\n * Quick Panel Agent Handler\n *\n * Background service that bridges Quick Panel (content script) with the native-server Agent.\n * Handles message routing, SSE streaming, and lifecycle management for AI chat requests.\n *\n * Architecture:\n * - Quick Panel sends QUICK_PANEL_SEND_TO_AI via chrome.runtime.sendMessage\n * - This handler subscribes to SSE first, then fires POST /act\n * - Incoming RealtimeEvents are filtered by requestId and forwarded to the originating tab\n * - Keepalive is explicitly managed to prevent MV3 Service Worker suspension during streaming\n *\n * @see https://developer.chrome.com/docs/extensions/mv3/service_workers/\n */\n\nimport type { AgentActRequest, RealtimeEvent } from 'chrome-mcp-shared';\nimport { NativeMessageType } from 'chrome-mcp-shared';\n\nimport { NATIVE_HOST, STORAGE_KEYS } from '@/common/constants';\nimport {\n  BACKGROUND_MESSAGE_TYPES,\n  TOOL_MESSAGE_TYPES,\n  type QuickPanelAIEventMessage,\n  type QuickPanelCancelAIMessage,\n  type QuickPanelCancelAIResponse,\n  type QuickPanelSendToAIMessage,\n  type QuickPanelSendToAIResponse,\n} from '@/common/message-types';\nimport { acquireKeepalive } from '../keepalive-manager';\nimport { openAgentChatSidepanel } from '../utils/sidepanel';\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst LOG_PREFIX = '[QuickPanelAgent]';\nconst KEEPALIVE_TAG = 'quick-panel-ai';\n\n/** Storage key for AgentChat selected session ID (owned by sidepanel composables) */\nconst STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';\n\n/** Timeout for initial SSE connection establishment */\nconst SSE_CONNECT_TIMEOUT_MS = 3000;\n\n/** Safety timeout for entire request lifecycle (15 minutes) */\nconst REQUEST_TIMEOUT_MS = 15 * 60 * 1000;\n\n/** Flag indicating SSE connection was successful */\nconst SSE_CONNECTED = Symbol('SSE_CONNECTED');\n\n/** Flag indicating SSE connection timed out but we should continue */\nconst SSE_TIMEOUT = Symbol('SSE_TIMEOUT');\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Represents an active streaming request from Quick Panel.\n *\n * Background maintains this state to:\n * 1. Route SSE events to the correct tab\n * 2. Manage keepalive lifecycle\n * 3. Handle cancellation and cleanup\n */\ninterface ActiveRequest {\n  readonly requestId: string;\n  readonly sessionId: string;\n  readonly instruction: string;\n  readonly tabId: number;\n  readonly windowId?: number;\n  readonly frameId?: number;\n  readonly port: number;\n  readonly createdAt: number;\n  readonly abortController: AbortController;\n  readonly releaseKeepalive: () => void;\n  readonly timeoutId: ReturnType<typeof setTimeout>;\n}\n\n// ============================================================\n// State\n// ============================================================\n\n/** Active streaming requests indexed by requestId */\nconst activeRequests = new Map<string, ActiveRequest>();\n\n/** Initialization flag to prevent duplicate listeners */\nlet initialized = false;\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value : '';\n}\n\nfunction normalizePort(value: unknown): number | null {\n  const num =\n    typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;\n\n  if (!Number.isFinite(num)) return null;\n\n  const port = Math.floor(num);\n  if (port <= 0 || port > 65535) return null;\n\n  return port;\n}\n\nfunction createRequestId(): string {\n  // Prefer crypto.randomUUID for proper UUID format\n  try {\n    const id = crypto?.randomUUID?.();\n    if (id) return id;\n  } catch {\n    // Fallback for environments without crypto.randomUUID\n  }\n  return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction isTerminalStatus(status: string): boolean {\n  return status === 'completed' || status === 'error' || status === 'cancelled';\n}\n\n// ============================================================\n// Event Factories\n// ============================================================\n\nfunction createErrorEvent(sessionId: string, requestId: string, error: string): RealtimeEvent {\n  return {\n    type: 'error',\n    error: error || 'Unknown error',\n    data: { sessionId, requestId },\n  };\n}\n\nfunction createCancelledStatusEvent(\n  sessionId: string,\n  requestId: string,\n  message?: string,\n): RealtimeEvent {\n  return {\n    type: 'status',\n    data: {\n      sessionId,\n      status: 'cancelled',\n      requestId,\n      message: message || 'Cancelled by user',\n    },\n  };\n}\n\n// ============================================================\n// Event Forwarding\n// ============================================================\n\n/**\n * Forward a RealtimeEvent to the Quick Panel in the originating tab.\n * Handles receiver unavailability gracefully by cleaning up the request.\n */\nfunction forwardEventToQuickPanel(request: ActiveRequest, event: RealtimeEvent): void {\n  const message: QuickPanelAIEventMessage = {\n    action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,\n    requestId: request.requestId,\n    sessionId: request.sessionId,\n    event,\n  };\n\n  const sendOptions =\n    typeof request.frameId === 'number' ? { frameId: request.frameId } : undefined;\n\n  const sendPromise = sendOptions\n    ? chrome.tabs.sendMessage(request.tabId, message, sendOptions)\n    : chrome.tabs.sendMessage(request.tabId, message);\n\n  sendPromise.catch((err) => {\n    const msg = err instanceof Error ? err.message : String(err);\n\n    // Detect receiver unavailability (tab closed, navigated, Quick Panel closed)\n    const receiverGone =\n      msg.includes('Receiving end does not exist') ||\n      msg.includes('No tab with id') ||\n      msg.includes('The message port closed');\n\n    if (receiverGone) {\n      cleanupRequest(request.requestId, 'receiver_unavailable');\n    }\n  });\n}\n\n// ============================================================\n// Request Lifecycle Management\n// ============================================================\n\n/**\n * Clean up an active request and release all associated resources.\n * Idempotent - safe to call multiple times.\n */\nfunction cleanupRequest(requestId: string, reason: string): void {\n  const request = activeRequests.get(requestId);\n  if (!request) return;\n\n  activeRequests.delete(requestId);\n\n  // Clear timeout\n  try {\n    clearTimeout(request.timeoutId);\n  } catch {\n    // Ignore\n  }\n\n  // Abort SSE connection\n  try {\n    request.abortController.abort();\n  } catch {\n    // Ignore\n  }\n\n  // Release keepalive\n  try {\n    request.releaseKeepalive();\n  } catch {\n    // Ignore\n  }\n\n  console.debug(`${LOG_PREFIX} Cleaned up request ${requestId} (${reason})`);\n}\n\n// ============================================================\n// Session Validation\n// ============================================================\n\n/**\n * Validate that the selected session exists on the native server.\n * Returns false if the session is invalid or server is unreachable.\n */\nasync function validateSession(port: number, sessionId: string): Promise<boolean> {\n  const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}`;\n  try {\n    const response = await fetch(url);\n    return response.ok;\n  } catch {\n    return false;\n  }\n}\n\n// ============================================================\n// SSE Event Filtering\n// ============================================================\n\n/**\n * Determine if a RealtimeEvent should be forwarded for a specific requestId.\n *\n * Events without requestId (connected, heartbeat) are session-level signals\n * and are not forwarded to avoid confusion with request-specific events.\n */\nfunction shouldForwardEvent(event: RealtimeEvent, requestId: string): boolean {\n  switch (event.type) {\n    case 'message':\n      return event.data?.requestId === requestId;\n    case 'status':\n      return event.data?.requestId === requestId;\n    case 'usage':\n      return event.data?.requestId === requestId;\n    case 'error':\n      return event.data?.requestId === requestId;\n    case 'connected':\n    case 'heartbeat':\n      // Session-level signals, not request-scoped\n      return false;\n    default:\n      return false;\n  }\n}\n\n// ============================================================\n// SSE Subscription\n// ============================================================\n\ninterface SseSubscription {\n  /**\n   * Resolves with true when SSE connection is established.\n   * Resolves with false if connection failed (request was cleaned up).\n   */\n  ready: Promise<boolean>;\n  /** Resolves when SSE stream ends (normally or due to error/abort) */\n  done: Promise<void>;\n}\n\n/**\n * Create an SSE subscription for the request's session.\n *\n * The subscription:\n * 1. Connects to the session's /stream endpoint\n * 2. Filters events by requestId\n * 3. Forwards matching events to Quick Panel\n * 4. Triggers cleanup on terminal status\n *\n * @returns SseSubscription with ready promise that resolves to:\n *   - true: SSE connected successfully\n *   - false: SSE failed (request was cleaned up, don't send /act)\n */\nfunction createSseSubscription(request: ActiveRequest): SseSubscription {\n  // Track whether ready has been resolved\n  let readySettled = false;\n  let readyResolve: (connected: boolean) => void;\n\n  const ready = new Promise<boolean>((resolve) => {\n    readyResolve = resolve;\n  });\n\n  // Helper to resolve ready exactly once\n  const settleReady = (connected: boolean): void => {\n    if (readySettled) return;\n    readySettled = true;\n    readyResolve(connected);\n  };\n\n  const done = (async () => {\n    const sseUrl = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/stream`;\n\n    try {\n      const response = await fetch(sseUrl, {\n        method: 'GET',\n        headers: { Accept: 'text/event-stream' },\n        signal: request.abortController.signal,\n      });\n\n      if (!response.ok || !response.body) {\n        throw new Error(`SSE stream unavailable (HTTP ${response.status})`);\n      }\n\n      // Signal that SSE is connected successfully\n      settleReady(true);\n\n      const reader = response.body.getReader();\n      const decoder = new TextDecoder();\n      let buffer = '';\n\n      // Read and parse SSE stream\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n\n        buffer += decoder.decode(value, { stream: true });\n        const lines = buffer.split('\\n');\n        buffer = lines.pop() ?? '';\n\n        for (const line of lines) {\n          if (!line.startsWith('data:')) continue;\n          const raw = line.slice(5).trim();\n          if (!raw) continue;\n\n          try {\n            const event = JSON.parse(raw) as RealtimeEvent;\n\n            // Filter by requestId to prevent cross-request leakage\n            if (!shouldForwardEvent(event, request.requestId)) {\n              continue;\n            }\n\n            forwardEventToQuickPanel(request, event);\n\n            // Cleanup on terminal status\n            if (event.type === 'status' && event.data?.requestId === request.requestId) {\n              if (isTerminalStatus(event.data.status)) {\n                cleanupRequest(request.requestId, `terminal_status:${event.data.status}`);\n                return;\n              }\n            }\n          } catch {\n            // Ignore parse errors (best-effort stream processing)\n          }\n        }\n      }\n    } catch (err) {\n      // AbortError is intentional (cancellation or cleanup)\n      if (err instanceof Error && err.name === 'AbortError') {\n        // Signal not connected if aborted before connecting\n        settleReady(false);\n        return;\n      }\n\n      // Surface error to UI and cleanup if request is still active\n      if (activeRequests.has(request.requestId)) {\n        const msg = err instanceof Error ? err.message : String(err);\n        forwardEventToQuickPanel(\n          request,\n          createErrorEvent(request.sessionId, request.requestId, msg),\n        );\n        cleanupRequest(request.requestId, 'sse_error');\n      }\n\n      // Signal failed connection\n      settleReady(false);\n    }\n  })();\n\n  return { ready, done };\n}\n\n// ============================================================\n// Agent API\n// ============================================================\n\n/**\n * Send the act request to native-server.\n * The server will emit events via SSE which are already being subscribed.\n *\n * @param request - Active request context\n * @throws Error if request was cancelled/aborted or HTTP request fails\n */\nasync function postActRequest(request: ActiveRequest): Promise<void> {\n  // Check if request was cancelled before sending\n  if (request.abortController.signal.aborted) {\n    throw new Error('Request was cancelled');\n  }\n\n  const url = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/act`;\n\n  const payload: AgentActRequest = {\n    instruction: request.instruction,\n    // Ensures session-level config is loaded (engine, model, options, project binding)\n    dbSessionId: request.sessionId,\n    // Enables SSE-first flow and requestId filtering on session-scoped streams\n    requestId: request.requestId,\n  };\n\n  const response = await fetch(url, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify(payload),\n    signal: request.abortController.signal,\n  });\n\n  if (!response.ok) {\n    const text = await response.text().catch(() => '');\n    throw new Error(text || `HTTP ${response.status}`);\n  }\n}\n\n/**\n * Cancel an active request on the native-server.\n */\nasync function cancelRequestOnServer(\n  port: number,\n  sessionId: string,\n  requestId: string,\n): Promise<void> {\n  const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`;\n  try {\n    await fetch(url, { method: 'DELETE' });\n  } catch {\n    // Best-effort: cancellation might still succeed if request already ended\n  }\n}\n\n// ============================================================\n// Request Orchestration\n// ============================================================\n\n/**\n * Check if the request is still active and not cancelled.\n * Used as a guard before each async operation to handle race conditions.\n */\nfunction isRequestStillActive(request: ActiveRequest): boolean {\n  return activeRequests.has(request.requestId) && !request.abortController.signal.aborted;\n}\n\n/**\n * Main orchestration function for starting a Quick Panel AI request.\n *\n * Flow:\n * 1. Ensure native server is running\n * 2. Validate session exists\n * 3. Open sidepanel (best-effort)\n * 4. Start SSE subscription (wait for connection)\n * 5. Fire act request\n * 6. Let SSE handle event forwarding and cleanup\n *\n * @remarks\n * Guards are placed after each async operation to handle cancellation races.\n */\nasync function startRequest(request: ActiveRequest): Promise<void> {\n  try {\n    // Best-effort: ensure native server is running\n    await chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => null);\n\n    // Guard: check if cancelled during ENSURE_NATIVE\n    if (!isRequestStillActive(request)) return;\n\n    // Validate session still exists\n    const sessionValid = await validateSession(request.port, request.sessionId);\n\n    // Guard: check if cancelled during validation\n    if (!isRequestStillActive(request)) return;\n\n    if (!sessionValid) {\n      forwardEventToQuickPanel(\n        request,\n        createErrorEvent(\n          request.sessionId,\n          request.requestId,\n          'Selected Agent session is not available. Please open AgentChat and select a valid session.',\n        ),\n      );\n      // Open sidepanel without deep-linking to invalid session\n      openAgentChatSidepanel(request.tabId, request.windowId).catch(() => {});\n      cleanupRequest(request.requestId, 'session_invalid');\n      return;\n    }\n\n    // Best-effort: open sidepanel deep-linked to current session\n    openAgentChatSidepanel(request.tabId, request.windowId, request.sessionId).catch(() => {});\n\n    // Start SSE subscription BEFORE sending act request to avoid missing early events\n    const sse = createSseSubscription(request);\n\n    // Wait for SSE connection with timeout\n    // The race returns either:\n    // - boolean from sse.ready (true=connected, false=failed)\n    // - undefined from timeout (treat as \"proceed with caution\")\n    const sseResult = await Promise.race([\n      sse.ready,\n      sleep(SSE_CONNECT_TIMEOUT_MS).then(() => SSE_TIMEOUT),\n    ]);\n\n    // Guard: check if cancelled during SSE connection\n    if (!isRequestStillActive(request)) return;\n\n    // If SSE explicitly failed (returned false), don't send /act\n    // The SSE subscription already cleaned up and sent error to UI\n    if (sseResult === false) {\n      console.debug(`${LOG_PREFIX} SSE failed for ${request.requestId}, not sending /act`);\n      return;\n    }\n\n    // If SSE timed out, log warning but continue (degraded experience)\n    if (sseResult === SSE_TIMEOUT) {\n      console.warn(\n        `${LOG_PREFIX} SSE connection timed out for ${request.requestId}, proceeding anyway`,\n      );\n    }\n\n    // Fire the act request\n    await postActRequest(request);\n\n    // SSE subscription continues running and will handle cleanup on terminal status\n    void sse.done;\n  } catch (err) {\n    // Abort errors are expected during cancellation\n    if (err instanceof Error && err.name === 'AbortError') {\n      return;\n    }\n\n    // Request may have been cleaned up already\n    if (!activeRequests.has(request.requestId)) return;\n\n    const msg = err instanceof Error ? err.message : String(err);\n    forwardEventToQuickPanel(request, createErrorEvent(request.sessionId, request.requestId, msg));\n    cleanupRequest(request.requestId, 'start_failed');\n  }\n}\n\n// ============================================================\n// Message Handlers\n// ============================================================\n\n/**\n * Handle QUICK_PANEL_SEND_TO_AI message.\n * Creates a new streaming request and starts the orchestration flow.\n */\nasync function handleSendToAI(\n  message: QuickPanelSendToAIMessage,\n  sender: chrome.runtime.MessageSender,\n): Promise<QuickPanelSendToAIResponse> {\n  const tabId = sender?.tab?.id;\n  const windowId = sender?.tab?.windowId;\n  const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;\n\n  if (typeof tabId !== 'number') {\n    return { success: false, error: 'Quick Panel request must originate from a tab.' };\n  }\n\n  const instruction = normalizeString(message?.payload?.instruction).trim();\n  if (!instruction) {\n    return { success: false, error: 'instruction is required' };\n  }\n\n  // Read server port and selected session from storage\n  const stored = await chrome.storage.local.get([\n    STORAGE_KEYS.NATIVE_SERVER_PORT,\n    STORAGE_KEY_SELECTED_SESSION,\n  ]);\n\n  const port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;\n  const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim();\n\n  if (!sessionId) {\n    // No session selected: open sidepanel for user to select/create one\n    openAgentChatSidepanel(tabId, windowId).catch(() => {});\n    return {\n      success: false,\n      error:\n        'No Agent session selected. Please open AgentChat, select or create a session, then try again.',\n    };\n  }\n\n  // Create request state\n  const requestId = createRequestId();\n  const releaseKeepalive = acquireKeepalive(KEEPALIVE_TAG);\n  const abortController = new AbortController();\n\n  // Safety timeout to prevent infinite streaming\n  const timeoutId = setTimeout(() => {\n    const activeRequest = activeRequests.get(requestId);\n    if (!activeRequest) return;\n\n    forwardEventToQuickPanel(\n      activeRequest,\n      createErrorEvent(\n        activeRequest.sessionId,\n        activeRequest.requestId,\n        'Quick Panel stream timed out. Please continue in AgentChat sidepanel.',\n      ),\n    );\n    cleanupRequest(requestId, 'timeout');\n  }, REQUEST_TIMEOUT_MS);\n\n  const request: ActiveRequest = {\n    requestId,\n    sessionId,\n    instruction,\n    tabId,\n    windowId: typeof windowId === 'number' ? windowId : undefined,\n    frameId,\n    port,\n    createdAt: Date.now(),\n    abortController,\n    releaseKeepalive,\n    timeoutId,\n  };\n\n  activeRequests.set(requestId, request);\n\n  // Start the request asynchronously (don't await)\n  void startRequest(request);\n\n  return { success: true, requestId, sessionId };\n}\n\n/**\n * Handle QUICK_PANEL_CANCEL_AI message.\n * Cancels an active request both locally and on the server.\n */\nasync function handleCancelAI(\n  message: QuickPanelCancelAIMessage,\n  sender: chrome.runtime.MessageSender,\n): Promise<QuickPanelCancelAIResponse> {\n  const tabId = sender?.tab?.id;\n  const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;\n\n  if (typeof tabId !== 'number') {\n    return { success: false, error: 'Cancel request must originate from a tab.' };\n  }\n\n  const requestId = normalizeString(message?.payload?.requestId).trim();\n  const fallbackSessionId = normalizeString(message?.payload?.sessionId).trim();\n\n  if (!requestId) {\n    return { success: false, error: 'requestId is required' };\n  }\n\n  const activeRequest = activeRequests.get(requestId);\n  const sessionId = activeRequest?.sessionId || fallbackSessionId;\n\n  if (!sessionId) {\n    return {\n      success: false,\n      error: 'Unknown sessionId for this request. Please cancel from AgentChat sidepanel.',\n    };\n  }\n\n  // Abort SSE immediately for responsive UX\n  if (activeRequest) {\n    try {\n      activeRequest.abortController.abort();\n    } catch {\n      // Ignore\n    }\n  }\n\n  // Determine port\n  let port = activeRequest?.port;\n  if (!port) {\n    const stored = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_SERVER_PORT]);\n    port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;\n  }\n\n  // Cancel on server (async, don't await)\n  void cancelRequestOnServer(port, sessionId, requestId);\n\n  // Send synthetic cancelled status to UI\n  const cancelledEvent = createCancelledStatusEvent(sessionId, requestId);\n  const eventMessage: QuickPanelAIEventMessage = {\n    action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,\n    requestId,\n    sessionId,\n    event: cancelledEvent,\n  };\n\n  const sendOptions = typeof frameId === 'number' ? { frameId } : undefined;\n  const sendPromise = sendOptions\n    ? chrome.tabs.sendMessage(tabId, eventMessage, sendOptions)\n    : chrome.tabs.sendMessage(tabId, eventMessage);\n\n  sendPromise\n    .catch(() => {})\n    .finally(() => {\n      cleanupRequest(requestId, 'cancelled_by_user');\n    });\n\n  return { success: true };\n}\n\n// ============================================================\n// Initialization\n// ============================================================\n\n/**\n * Initialize the Quick Panel Agent Handler.\n * Sets up message listeners and tab cleanup handlers.\n */\nexport function initQuickPanelAgentHandler(): void {\n  if (initialized) return;\n  initialized = true;\n\n  // Message listener for Quick Panel messages\n  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n    // Handle QUICK_PANEL_SEND_TO_AI\n    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI) {\n      handleSendToAI(message as QuickPanelSendToAIMessage, sender)\n        .then(sendResponse)\n        .catch((err) => {\n          const msg = err instanceof Error ? err.message : String(err);\n          sendResponse({ success: false, error: msg || 'Unknown error' });\n        });\n      return true; // Async response\n    }\n\n    // Handle QUICK_PANEL_CANCEL_AI\n    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI) {\n      handleCancelAI(message as QuickPanelCancelAIMessage, sender)\n        .then(sendResponse)\n        .catch((err) => {\n          const msg = err instanceof Error ? err.message : String(err);\n          sendResponse({ success: false, error: msg || 'Unknown error' });\n        });\n      return true; // Async response\n    }\n\n    return false;\n  });\n\n  // Clean up requests when their tab is closed\n  chrome.tabs.onRemoved.addListener((tabId) => {\n    for (const [requestId, request] of activeRequests) {\n      if (request.tabId === tabId) {\n        cleanupRequest(requestId, 'tab_removed');\n      }\n    }\n  });\n\n  console.debug(`${LOG_PREFIX} Initialized`);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/quick-panel/commands.ts",
    "content": "/**\n * Quick Panel Commands Handler\n *\n * Handles keyboard shortcuts for Quick Panel functionality.\n * Listens for the 'toggle_quick_panel' command and sends toggle message\n * to the content script in the active tab.\n */\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst COMMAND_KEY = 'toggle_quick_panel';\nconst LOG_PREFIX = '[QuickPanelCommands]';\n\n// ============================================================\n// Helpers\n// ============================================================\n\n/**\n * Get the ID of the currently active tab\n */\nasync function getActiveTabId(): Promise<number | null> {\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    return tab?.id ?? null;\n  } catch (err) {\n    console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);\n    return null;\n  }\n}\n\n/**\n * Check if a tab can receive content scripts\n */\nfunction isValidTabUrl(url?: string): boolean {\n  if (!url) return false;\n\n  // Cannot inject into browser internal pages\n  const invalidPrefixes = [\n    'chrome://',\n    'chrome-extension://',\n    'edge://',\n    'about:',\n    'moz-extension://',\n    'devtools://',\n    'view-source:',\n    'data:',\n    // 'file://',\n  ];\n\n  return !invalidPrefixes.some((prefix) => url.startsWith(prefix));\n}\n\n// ============================================================\n// Main Handler\n// ============================================================\n\n/**\n * Toggle Quick Panel in the active tab\n */\nasync function toggleQuickPanelInActiveTab(): Promise<void> {\n  const tabId = await getActiveTabId();\n  if (tabId === null) {\n    console.warn(`${LOG_PREFIX} No active tab found`);\n    return;\n  }\n\n  // Get tab info to check URL validity\n  try {\n    const tab = await chrome.tabs.get(tabId);\n    if (!isValidTabUrl(tab.url)) {\n      console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);\n      return;\n    }\n  } catch (err) {\n    console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);\n    return;\n  }\n\n  // Send toggle message to content script\n  try {\n    const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });\n    if (response?.success) {\n      console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);\n    } else {\n      console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);\n    }\n  } catch (err) {\n    // Content script may not be loaded yet; this is expected on some pages\n    console.warn(\n      `${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,\n      err,\n    );\n  }\n}\n\n// ============================================================\n// Initialization\n// ============================================================\n\n/**\n * Initialize Quick Panel keyboard command listener\n */\nexport function initQuickPanelCommands(): void {\n  console.log(`${LOG_PREFIX} initQuickPanelCommands called`);\n  chrome.commands.onCommand.addListener(async (command) => {\n    console.log(`${LOG_PREFIX} onCommand received:`, command);\n    if (command !== COMMAND_KEY) {\n      console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);\n      return;\n    }\n    console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);\n\n    try {\n      await toggleQuickPanelInActiveTab();\n      console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);\n    } catch (err) {\n      console.error(`${LOG_PREFIX} Command handler error:`, err);\n    }\n  });\n\n  console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/quick-panel/tabs-handler.ts",
    "content": "/**\n * Quick Panel Tabs Handler\n *\n * Background service worker bridge for Quick Panel (content script) to:\n * - Enumerate tabs for search suggestions\n * - Activate a selected tab\n * - Close a tab\n *\n * Note: Content scripts cannot access chrome.tabs.* directly.\n */\n\nimport {\n  BACKGROUND_MESSAGE_TYPES,\n  type QuickPanelActivateTabMessage,\n  type QuickPanelActivateTabResponse,\n  type QuickPanelCloseTabMessage,\n  type QuickPanelCloseTabResponse,\n  type QuickPanelTabSummary,\n  type QuickPanelTabsQueryMessage,\n  type QuickPanelTabsQueryResponse,\n} from '@/common/message-types';\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst LOG_PREFIX = '[QuickPanelTabs]';\n\n// ============================================================\n// Helpers\n// ============================================================\n\nfunction isValidTabId(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value) && value > 0;\n}\n\nfunction isValidWindowId(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value) && value > 0;\n}\n\nfunction normalizeBoolean(value: unknown): boolean {\n  return value === true;\n}\n\nfunction getLastAccessed(tab: chrome.tabs.Tab): number | undefined {\n  const anyTab = tab as unknown as { lastAccessed?: unknown };\n  const value = anyTab.lastAccessed;\n  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\nfunction safeErrorMessage(err: unknown): string {\n  if (err instanceof Error) {\n    return err.message || String(err);\n  }\n  return String(err);\n}\n\n/**\n * Convert a chrome.tabs.Tab to our summary format.\n * Returns null if tab is invalid.\n */\nfunction toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {\n  if (!isValidTabId(tab.id)) return null;\n\n  const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;\n  if (windowId === null) return null;\n\n  return {\n    tabId: tab.id,\n    windowId,\n    title: tab.title ?? '',\n    url: tab.url ?? '',\n    favIconUrl: tab.favIconUrl ?? undefined,\n    active: normalizeBoolean(tab.active),\n    pinned: normalizeBoolean(tab.pinned),\n    audible: normalizeBoolean(tab.audible),\n    muted: normalizeBoolean(tab.mutedInfo?.muted),\n    index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,\n    lastAccessed: getLastAccessed(tab),\n  };\n}\n\n// ============================================================\n// Message Handlers\n// ============================================================\n\nasync function handleTabsQuery(\n  message: QuickPanelTabsQueryMessage,\n  sender: chrome.runtime.MessageSender,\n): Promise<QuickPanelTabsQueryResponse> {\n  try {\n    const includeAllWindows = message.payload?.includeAllWindows ?? true;\n\n    // Extract current context from sender\n    const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;\n    const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;\n\n    // Quick Panel should only be called from content scripts (which have sender.tab)\n    // Reject requests without valid sender tab context for security\n    if (!includeAllWindows && currentWindowId === null) {\n      return {\n        success: false,\n        error: 'Invalid request: sender tab context required for window-scoped queries',\n      };\n    }\n\n    // Build query info based on scope\n    const queryInfo: chrome.tabs.QueryInfo = includeAllWindows\n      ? {}\n      : { windowId: currentWindowId! };\n\n    const tabs = await chrome.tabs.query(queryInfo);\n\n    // Convert to summaries, filtering out invalid tabs\n    const summaries: QuickPanelTabSummary[] = [];\n    for (const tab of tabs) {\n      const summary = toTabSummary(tab);\n      if (summary) {\n        summaries.push(summary);\n      }\n    }\n\n    return {\n      success: true,\n      tabs: summaries,\n      currentTabId,\n      currentWindowId,\n    };\n  } catch (err) {\n    console.warn(`${LOG_PREFIX} Error querying tabs:`, err);\n    return {\n      success: false,\n      error: safeErrorMessage(err) || 'Failed to query tabs',\n    };\n  }\n}\n\nasync function handleActivateTab(\n  message: QuickPanelActivateTabMessage,\n): Promise<QuickPanelActivateTabResponse> {\n  try {\n    const tabId = message.payload?.tabId;\n    const windowId = message.payload?.windowId;\n\n    if (!isValidTabId(tabId)) {\n      return { success: false, error: 'Invalid tabId' };\n    }\n\n    // Focus the window first if provided\n    if (isValidWindowId(windowId)) {\n      try {\n        await chrome.windows.update(windowId, { focused: true });\n      } catch {\n        // Best-effort: tab activation may still succeed without focusing window.\n      }\n    }\n\n    // Activate the tab\n    await chrome.tabs.update(tabId, { active: true });\n\n    return { success: true };\n  } catch (err) {\n    console.warn(`${LOG_PREFIX} Error activating tab:`, err);\n    return {\n      success: false,\n      error: safeErrorMessage(err) || 'Failed to activate tab',\n    };\n  }\n}\n\nasync function handleCloseTab(\n  message: QuickPanelCloseTabMessage,\n): Promise<QuickPanelCloseTabResponse> {\n  try {\n    const tabId = message.payload?.tabId;\n\n    if (!isValidTabId(tabId)) {\n      return { success: false, error: 'Invalid tabId' };\n    }\n\n    await chrome.tabs.remove(tabId);\n\n    return { success: true };\n  } catch (err) {\n    console.warn(`${LOG_PREFIX} Error closing tab:`, err);\n    return {\n      success: false,\n      error: safeErrorMessage(err) || 'Failed to close tab',\n    };\n  }\n}\n\n// ============================================================\n// Initialization\n// ============================================================\n\nlet initialized = false;\n\n/**\n * Initialize the Quick Panel Tabs handler.\n * Safe to call multiple times - subsequent calls are no-ops.\n */\nexport function initQuickPanelTabsHandler(): void {\n  if (initialized) return;\n  initialized = true;\n\n  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n    // Tabs query\n    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {\n      handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);\n      return true; // Will respond asynchronously\n    }\n\n    // Tab activate\n    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {\n      handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);\n      return true;\n    }\n\n    // Tab close\n    if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {\n      handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);\n      return true;\n    }\n\n    return false; // Not handled by this listener\n  });\n\n  console.debug(`${LOG_PREFIX} Initialized`);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/adapter.ts",
    "content": "/**\n * Adapter Layer: Step ↔ Action\n *\n * Provides conversion utilities between the legacy Step system and the new Action system.\n * This adapter enables gradual migration while maintaining backward compatibility.\n *\n * Architecture:\n * - `stepToAction`: Converts a Step to an ExecutableAction\n * - `execCtxToActionCtx`: Converts ExecCtx to ActionExecutionContext\n * - `actionResultToExecResult`: Converts ActionExecutionResult to ExecResult\n * - `createStepExecutor`: Factory for a Step executor backed by ActionRegistry\n */\n\nimport type { ExecCtx, ExecResult } from '../nodes/types';\nimport type { Step } from '../types';\nimport type { ActionRegistry } from './registry';\nimport type {\n  ActionExecutionContext,\n  ActionExecutionResult,\n  ActionPolicy,\n  ExecutableAction,\n  ExecutableActionType,\n  ExecutionFlags,\n  VariableStore,\n} from './types';\n\n// ================================\n// Type Mapping\n// ================================\n\n/**\n * Map legacy step types to new action types\n * Most types map 1:1, but some require special handling\n */\nconst STEP_TYPE_TO_ACTION_TYPE: Record<string, ExecutableActionType> = {\n  // Interaction\n  click: 'click',\n  dblclick: 'dblclick',\n  fill: 'fill',\n  key: 'key',\n  scroll: 'scroll',\n  drag: 'drag',\n\n  // Timing\n  wait: 'wait',\n  delay: 'delay',\n\n  // Validation\n  assert: 'assert',\n\n  // Data\n  extract: 'extract',\n  script: 'script',\n  http: 'http',\n  screenshot: 'screenshot',\n\n  // Navigation / Tabs\n  navigate: 'navigate',\n  openTab: 'openTab',\n  switchTab: 'switchTab',\n  closeTab: 'closeTab',\n  handleDownload: 'handleDownload',\n\n  // Control Flow\n  if: 'if',\n  foreach: 'foreach',\n  while: 'while',\n  switchFrame: 'switchFrame',\n\n  // TODO: Add when handlers are implemented\n  // triggerEvent: 'triggerEvent',\n  // setAttribute: 'setAttribute',\n  // loopElements: 'loopElements',\n  // executeFlow: 'executeFlow',\n};\n\n// ================================\n// Context Conversion\n// ================================\n\n/**\n * Convert legacy ExecCtx to ActionExecutionContext\n */\nexport function execCtxToActionCtx(\n  ctx: ExecCtx,\n  tabId: number,\n  options?: {\n    stepId?: string;\n    runId?: string;\n    pushLog?: (entry: unknown) => void;\n    /** Execution flags to pass to action handlers */\n    execution?: ExecutionFlags;\n  },\n): ActionExecutionContext {\n  // Use provided stepId for proper log attribution, fallback to 'action' only if not provided\n  const logStepId = options?.stepId || 'action';\n  return {\n    vars: ctx.vars as VariableStore,\n    tabId,\n    frameId: ctx.frameId,\n    runId: options?.runId,\n    log: (message: string, level?: 'info' | 'warn' | 'error') => {\n      ctx.logger({\n        stepId: logStepId,\n        status: level === 'error' ? 'failed' : level === 'warn' ? 'warning' : 'success',\n        message,\n      });\n    },\n    pushLog: options?.pushLog,\n    execution: options?.execution,\n  };\n}\n\n// ================================\n// Step → Action Conversion\n// ================================\n\n/**\n * Convert a legacy Step to an ExecutableAction\n *\n * The conversion maps step properties to action params and policy.\n * Unknown step types are passed through as-is for forward compatibility.\n */\nexport function stepToAction(step: Step): ExecutableAction | null {\n  const actionType = STEP_TYPE_TO_ACTION_TYPE[step.type];\n\n  if (!actionType) {\n    // Unsupported step type\n    return null;\n  }\n\n  // Build policy if step has timeout or retry config\n  let policy: ActionPolicy | undefined;\n  if (step.timeoutMs || step.retry) {\n    policy = {};\n\n    if (step.timeoutMs) {\n      policy.timeout = { ms: step.timeoutMs };\n    }\n\n    if (step.retry) {\n      policy.retry = {\n        retries: step.retry.count ?? 0,\n        intervalMs: step.retry.intervalMs ?? 0,\n        // Step backoff only supports 'none' | 'exp', map to Action backoff type\n        backoff: step.retry.backoff === 'exp' ? 'exp' : 'none',\n      };\n    }\n  }\n\n  // Build base action - use type assertion for generic action\n  // Note: Step doesn't have name/disabled at base level, they are on NodeBase\n  const action = {\n    id: step.id,\n    type: actionType,\n    params: extractParams(step),\n    policy,\n  } as ExecutableAction;\n\n  return action;\n}\n\n/**\n * Legacy SelectorCandidate format: { type, value, weight? }\n * Action SelectorCandidate format: { type, selector/xpath/text/etc, weight? }\n */\ninterface LegacySelectorCandidate {\n  type: string;\n  value: string;\n  weight?: number;\n}\n\ninterface LegacyTargetLocator {\n  ref?: string;\n  candidates: LegacySelectorCandidate[];\n  // Additional fields from recorder\n  selector?: string;\n  tag?: string;\n}\n\n/**\n * Parse legacy ARIA value format\n * Formats:\n * - \"role[name=...]\" (e.g., \"button[name=\\\"Submit\\\"]\")\n * - \"aria-label=...\" (role-less, just name)\n */\nfunction parseAriaValue(value: string): { role?: string; name: string } {\n  // Try \"role[name=...]\" format\n  const roleMatch = value.match(/^([a-zA-Z]+)\\[name=[\"']?(.+?)[\"']?\\]$/);\n  if (roleMatch) {\n    return { role: roleMatch[1], name: roleMatch[2] };\n  }\n\n  // Try \"aria-label=...\" format\n  const labelMatch = value.match(/^aria-label=[\"']?(.+?)[\"']?$/);\n  if (labelMatch) {\n    return { name: labelMatch[1] };\n  }\n\n  // Fallback: treat entire value as name\n  return { name: value };\n}\n\n/**\n * Convert legacy SelectorCandidate to Action SelectorCandidate\n */\nfunction convertSelectorCandidate(legacy: LegacySelectorCandidate): Record<string, unknown> {\n  const base: Record<string, unknown> = { type: legacy.type };\n  if (typeof legacy.weight === 'number') {\n    base.weight = legacy.weight;\n  }\n\n  switch (legacy.type) {\n    case 'css':\n    case 'attr':\n      // CSS and attr use 'selector' field\n      base.selector = legacy.value;\n      break;\n    case 'xpath':\n      // XPath uses 'xpath' field\n      base.xpath = legacy.value;\n      break;\n    case 'text':\n      // Text uses 'text' field\n      base.text = legacy.value;\n      break;\n    case 'aria': {\n      // ARIA: parse \"role[name=...]\" or \"aria-label=...\" format\n      const parsed = parseAriaValue(legacy.value);\n      if (parsed.role) {\n        base.role = parsed.role;\n      }\n      base.name = parsed.name;\n      break;\n    }\n    default:\n      // Unknown type, pass through as-is\n      base.value = legacy.value;\n  }\n\n  return base;\n}\n\n/**\n * Convert legacy TargetLocator to Action ElementTarget\n * Preserves additional fields like selector and tag for locator optimization\n */\nfunction convertTargetLocator(target: LegacyTargetLocator): Record<string, unknown> {\n  const result: Record<string, unknown> = {};\n\n  if (target.ref) {\n    result.ref = target.ref;\n  }\n\n  // Preserve selector field for fast-path (e.g., #id selectors)\n  if (typeof target.selector === 'string' && target.selector.trim()) {\n    result.selector = target.selector;\n  }\n\n  // Preserve tag hint for text/aria matching\n  if (typeof target.tag === 'string' && target.tag.trim()) {\n    result.hint = { tagName: target.tag };\n  }\n\n  if (Array.isArray(target.candidates) && target.candidates.length > 0) {\n    result.candidates = target.candidates.map(convertSelectorCandidate);\n  }\n\n  return result;\n}\n\n/**\n * Check if a value looks like a legacy TargetLocator that needs conversion\n *\n * Detection criteria:\n * 1. Must be an object with candidates array\n * 2. Candidates must use legacy format (has 'value' field, not 'selector'/'xpath'/'text')\n *\n * This prevents double-conversion of already-converted Action format targets.\n */\nfunction isLegacyTargetLocator(value: unknown): value is LegacyTargetLocator {\n  if (!value || typeof value !== 'object') return false;\n  const obj = value as Record<string, unknown>;\n\n  // Must have candidates array\n  if (!Array.isArray(obj.candidates)) {\n    // If only has ref without candidates, check if it's legacy format\n    return typeof obj.ref === 'string' && !obj.hint;\n  }\n\n  // Check first candidate to determine format\n  const firstCandidate = obj.candidates[0];\n  if (!firstCandidate || typeof firstCandidate !== 'object') {\n    return false;\n  }\n\n  const candidate = firstCandidate as Record<string, unknown>;\n  // Legacy format uses 'value' field\n  // Action format uses 'selector', 'xpath', 'text', etc. (NOT 'value')\n  const hasValueField = typeof candidate.value === 'string';\n  const hasActionFields =\n    typeof candidate.selector === 'string' ||\n    typeof candidate.xpath === 'string' ||\n    typeof candidate.text === 'string' ||\n    typeof candidate.name === 'string';\n\n  // It's legacy if it has 'value' field and doesn't have action-specific fields\n  return hasValueField && !hasActionFields;\n}\n\n/**\n * Extract action params from step\n * Each step type has its own param structure\n *\n * This function also converts legacy data structures to Action-compatible formats:\n * - TargetLocator.candidates: { type, value } -> { type, selector/xpath/text }\n */\nfunction extractParams(step: Step): Record<string, unknown> {\n  // The step already contains params inline, so we extract them\n  // excluding common fields that go into the action base\n  // Use unknown first to satisfy TypeScript's type narrowing\n  const stepObj = step as unknown as Record<string, unknown>;\n  const { id, type, timeoutMs, retry, screenshotOnFail, ...params } = stepObj;\n\n  // Convert TargetLocator fields if present\n  const converted: Record<string, unknown> = {};\n  for (const [key, value] of Object.entries(params)) {\n    if (key === 'target' && isLegacyTargetLocator(value)) {\n      converted[key] = convertTargetLocator(value);\n    } else if (key === 'start' && isLegacyTargetLocator(value)) {\n      // For drag step\n      converted[key] = convertTargetLocator(value);\n    } else if (key === 'end' && isLegacyTargetLocator(value)) {\n      // For drag step\n      converted[key] = convertTargetLocator(value);\n    } else {\n      converted[key] = value;\n    }\n  }\n\n  return converted;\n}\n\n// ================================\n// Result Conversion\n// ================================\n\n/**\n * Convert ActionExecutionResult to legacy ExecResult\n */\nexport function actionResultToExecResult(result: ActionExecutionResult): ExecResult {\n  const execResult: ExecResult = {};\n\n  // Map nextLabel for control flow\n  if (result.nextLabel) {\n    execResult.nextLabel = result.nextLabel;\n  }\n\n  // Map control directives\n  if (result.control) {\n    execResult.control = result.control;\n  }\n\n  // If action already handled logging, mark it\n  if (result.status === 'success') {\n    execResult.alreadyLogged = false; // Let StepRunner handle logging\n  }\n\n  return execResult;\n}\n\n// ================================\n// Executor Factory\n// ================================\n\n/**\n * Result from attempting to execute a step via actions\n */\nexport type StepExecutionAttempt =\n  | { supported: true; result: ExecResult }\n  | { supported: false; reason: string };\n\n/**\n * Options for step executor\n */\nexport interface StepExecutorOptions {\n  runId?: string;\n  pushLog?: (entry: unknown) => void;\n  /**\n   * If true, throws on unsupported step types instead of returning { supported: false }\n   * Use this in strict mode where all steps must go through ActionRegistry\n   */\n  strict?: boolean;\n  /**\n   * Skip ActionRegistry retry policy.\n   * When true, the action's retry policy is removed before execution.\n   * Use this when StepRunner already handles retry via withRetry().\n   */\n  skipRetry?: boolean;\n  /**\n   * Skip navigation waiting inside action handlers.\n   * When true, handlers like click/navigate skip their internal nav-wait logic.\n   * Use this when StepRunner already handles navigation waiting.\n   */\n  skipNavWait?: boolean;\n}\n\n/**\n * Create a step executor that uses ActionRegistry\n *\n * This is the main integration point - it creates a function that can\n * replace the legacy `executeStep` call in StepRunner.\n *\n * The executor returns a discriminated union indicating whether the step\n * was supported by ActionRegistry. This allows hybrid mode to fall back\n * to legacy execution gracefully.\n */\nexport function createStepExecutor(registry: ActionRegistry) {\n  return async function executeStepViaActions(\n    ctx: ExecCtx,\n    step: Step,\n    tabId: number,\n    options?: StepExecutorOptions,\n  ): Promise<StepExecutionAttempt> {\n    // Convert step to action\n    let action = stepToAction(step);\n\n    if (!action) {\n      const reason = `Unsupported step type for ActionRegistry: ${step.type}`;\n      if (options?.strict) {\n        throw new Error(reason);\n      }\n      return { supported: false, reason };\n    }\n\n    // Skip retry policy if StepRunner handles it\n    // This avoids double retry: StepRunner.withRetry() + ActionRegistry.retry\n    if (options?.skipRetry === true && action.policy?.retry) {\n      action = { ...action, policy: { ...action.policy, retry: undefined } };\n    }\n\n    // Check if handler exists\n    const handler = registry.get(action.type);\n    if (!handler) {\n      const reason = `No handler registered for action type: ${action.type}`;\n      if (options?.strict) {\n        throw new Error(reason);\n      }\n      return { supported: false, reason };\n    }\n\n    // Build execution flags for handlers\n    const execution: ExecutionFlags | undefined =\n      options?.skipNavWait === true ? { skipNavWait: true } : undefined;\n\n    // Convert context with proper stepId for log attribution\n    const actionCtx = execCtxToActionCtx(ctx, tabId, {\n      stepId: step.id,\n      runId: options?.runId,\n      pushLog: options?.pushLog,\n      execution,\n    });\n\n    // Execute via registry (includes retry, timeout, hooks)\n    const result = await registry.execute(actionCtx, action);\n\n    // Handle failure - still return as supported, but throw the error\n    if (result.status === 'failed') {\n      const error = result.error;\n      throw new Error(\n        error?.message || `Action ${action.type} failed: ${error?.code || 'UNKNOWN'}`,\n      );\n    }\n\n    // Sync vars back (in case action modified them)\n    Object.assign(ctx.vars, actionCtx.vars);\n\n    // Sync frameId back (in case switchFrame modified it)\n    if (actionCtx.frameId !== undefined) {\n      ctx.frameId = actionCtx.frameId;\n    }\n\n    // Sync tabId back (in case openTab/switchTab changed it)\n    // Chrome tabId is always a positive safe integer\n    if (result.status === 'success') {\n      const nextTabId = result.newTabId;\n      if (typeof nextTabId === 'number' && Number.isSafeInteger(nextTabId) && nextTabId > 0) {\n        ctx.tabId = nextTabId;\n      }\n    }\n\n    // Convert result\n    return { supported: true, result: actionResultToExecResult(result) };\n  };\n}\n\n// ================================\n// Type Guards\n// ================================\n\n/**\n * Check if a step type is supported by ActionRegistry\n */\nexport function isActionSupported(stepType: string): boolean {\n  return stepType in STEP_TYPE_TO_ACTION_TYPE;\n}\n\n/**\n * Get the action type for a step type\n */\nexport function getActionType(stepType: string): ExecutableActionType | undefined {\n  return STEP_TYPE_TO_ACTION_TYPE[stepType];\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/assert.ts",
    "content": "/**\n * Assert Action Handler\n *\n * Validates page state against specified conditions:\n * - exists: Selector can be resolved to an element\n * - visible: Element exists and has non-zero dimensions\n * - textPresent: Text appears in the page content\n * - attribute: Element attribute equals/matches/exists\n */\n\nimport { failed, invalid, ok, tryResolveString } from '../registry';\nimport type { ActionHandler, Assertion, VariableStore } from '../types';\n\n/** Default timeout for polling assertions (ms) */\nconst DEFAULT_ASSERT_TIMEOUT_MS = 5000;\n\n/** Polling interval for retry assertions (ms) */\nconst POLL_INTERVAL_MS = 200;\n\n/** Maximum attribute name length */\nconst MAX_ATTR_NAME_LENGTH = 256;\n\n/**\n * Validates assertion configuration at build time\n */\nfunction validateAssertion(assert: Assertion): { ok: true } | { ok: false; error: string } {\n  switch (assert.kind) {\n    case 'exists':\n    case 'visible':\n      if (assert.selector === undefined) {\n        return { ok: false, error: `Assertion \"${assert.kind}\" requires a selector` };\n      }\n      break;\n\n    case 'textPresent':\n      if (assert.text === undefined) {\n        return { ok: false, error: 'Assertion \"textPresent\" requires a text value' };\n      }\n      break;\n\n    case 'attribute':\n      if (assert.selector === undefined) {\n        return { ok: false, error: 'Assertion \"attribute\" requires a selector' };\n      }\n      if (assert.name === undefined) {\n        return { ok: false, error: 'Assertion \"attribute\" requires an attribute name' };\n      }\n      // Must have at least equals or matches (or neither for existence check)\n      break;\n\n    default: {\n      const exhaustive: never = assert;\n      return { ok: false, error: `Unknown assertion kind: ${(exhaustive as Assertion).kind}` };\n    }\n  }\n\n  return { ok: true };\n}\n\n/**\n * Resolve assertion parameters at runtime\n */\nfunction resolveAssertionParams(\n  assert: Assertion,\n  vars: VariableStore,\n): { ok: true; resolved: ResolvedAssertion } | { ok: false; error: string } {\n  switch (assert.kind) {\n    case 'exists':\n    case 'visible': {\n      const selectorResult = tryResolveString(assert.selector, vars);\n      if (!selectorResult.ok) return selectorResult;\n      const selector = selectorResult.value.trim();\n      if (!selector) return { ok: false, error: `Empty selector for \"${assert.kind}\" assertion` };\n      return {\n        ok: true,\n        resolved: { kind: assert.kind, selector },\n      };\n    }\n\n    case 'textPresent': {\n      const textResult = tryResolveString(assert.text, vars);\n      if (!textResult.ok) return textResult;\n      const text = textResult.value;\n      if (!text) return { ok: false, error: 'Empty text for \"textPresent\" assertion' };\n      return {\n        ok: true,\n        resolved: { kind: 'textPresent', text },\n      };\n    }\n\n    case 'attribute': {\n      const selectorResult = tryResolveString(assert.selector, vars);\n      if (!selectorResult.ok) return selectorResult;\n      const selector = selectorResult.value.trim();\n      if (!selector) return { ok: false, error: 'Empty selector for \"attribute\" assertion' };\n\n      const nameResult = tryResolveString(assert.name, vars);\n      if (!nameResult.ok) return nameResult;\n      const attrName = nameResult.value.trim();\n      if (!attrName) return { ok: false, error: 'Empty attribute name' };\n      if (attrName.length > MAX_ATTR_NAME_LENGTH) {\n        return { ok: false, error: `Attribute name exceeds ${MAX_ATTR_NAME_LENGTH} characters` };\n      }\n\n      let equals: string | undefined;\n      let matches: string | undefined;\n\n      if (assert.equals !== undefined) {\n        const equalsResult = tryResolveString(assert.equals, vars);\n        if (!equalsResult.ok) return equalsResult;\n        equals = equalsResult.value;\n      }\n\n      if (assert.matches !== undefined) {\n        const matchesResult = tryResolveString(assert.matches, vars);\n        if (!matchesResult.ok) return matchesResult;\n        matches = matchesResult.value;\n        // Validate regex\n        try {\n          new RegExp(matches);\n        } catch {\n          return { ok: false, error: `Invalid regex pattern: ${matches}` };\n        }\n      }\n\n      return {\n        ok: true,\n        resolved: { kind: 'attribute', selector, attrName, equals, matches },\n      };\n    }\n  }\n}\n\n/**\n * Resolved assertion with all variables interpolated\n */\ntype ResolvedAssertion =\n  | { kind: 'exists'; selector: string }\n  | { kind: 'visible'; selector: string }\n  | { kind: 'textPresent'; text: string }\n  | { kind: 'attribute'; selector: string; attrName: string; equals?: string; matches?: string };\n\n/**\n * Execute assertion check in page context\n */\nasync function checkAssertionInPage(\n  tabId: number,\n  frameId: number | undefined,\n  resolved: ResolvedAssertion,\n): Promise<{ passed: boolean; message?: string }> {\n  const frameIds = typeof frameId === 'number' ? [frameId] : undefined;\n\n  try {\n    const injected = await chrome.scripting.executeScript({\n      target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n      world: 'MAIN',\n      func: (assertion: ResolvedAssertion) => {\n        try {\n          switch (assertion.kind) {\n            case 'exists': {\n              const el = document.querySelector(assertion.selector);\n              return el ? { passed: true } : { passed: false, message: 'Element not found' };\n            }\n\n            case 'visible': {\n              const el = document.querySelector(assertion.selector);\n              if (!el) return { passed: false, message: 'Element not found' };\n              const rect = el.getBoundingClientRect();\n              const hasSize = rect.width > 0 && rect.height > 0;\n              if (!hasSize) return { passed: false, message: 'Element has zero dimensions' };\n\n              // Check if element is visible in viewport\n              const style = window.getComputedStyle(el);\n              if (\n                style.display === 'none' ||\n                style.visibility === 'hidden' ||\n                style.opacity === '0'\n              ) {\n                return { passed: false, message: 'Element is hidden via CSS' };\n              }\n              return { passed: true };\n            }\n\n            case 'textPresent': {\n              const text = assertion.text;\n              const bodyText = document.body?.textContent || '';\n              if (bodyText.includes(text)) return { passed: true };\n              return { passed: false, message: `Text \"${text}\" not found in page` };\n            }\n\n            case 'attribute': {\n              const el = document.querySelector(assertion.selector);\n              if (!el) return { passed: false, message: 'Element not found' };\n\n              const attrValue = el.getAttribute(assertion.attrName);\n\n              // Check existence only\n              if (assertion.equals === undefined && assertion.matches === undefined) {\n                return attrValue !== null\n                  ? { passed: true }\n                  : { passed: false, message: `Attribute \"${assertion.attrName}\" not found` };\n              }\n\n              // Check equals\n              if (assertion.equals !== undefined) {\n                if (attrValue === assertion.equals) return { passed: true };\n                return {\n                  passed: false,\n                  message: `Attribute \"${assertion.attrName}\" is \"${attrValue}\", expected \"${assertion.equals}\"`,\n                };\n              }\n\n              // Check matches (regex)\n              if (assertion.matches !== undefined) {\n                if (attrValue === null) {\n                  return { passed: false, message: `Attribute \"${assertion.attrName}\" not found` };\n                }\n                const regex = new RegExp(assertion.matches);\n                if (regex.test(attrValue)) return { passed: true };\n                return {\n                  passed: false,\n                  message: `Attribute \"${assertion.attrName}\" value \"${attrValue}\" does not match pattern \"${assertion.matches}\"`,\n                };\n              }\n\n              return { passed: true };\n            }\n          }\n        } catch (e) {\n          return { passed: false, message: e instanceof Error ? e.message : String(e) };\n        }\n      },\n      args: [resolved],\n    });\n\n    const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n    if (!result || typeof result !== 'object') {\n      return { passed: false, message: 'Assertion script returned invalid result' };\n    }\n\n    return result as { passed: boolean; message?: string };\n  } catch (e) {\n    return {\n      passed: false,\n      message: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,\n    };\n  }\n}\n\n/**\n * Poll assertion until it passes or timeout\n */\nasync function pollAssertion(\n  tabId: number,\n  frameId: number | undefined,\n  resolved: ResolvedAssertion,\n  timeoutMs: number,\n): Promise<{ passed: boolean; message?: string }> {\n  const startTime = Date.now();\n  let lastResult: { passed: boolean; message?: string } = {\n    passed: false,\n    message: 'Timeout before first check',\n  };\n\n  while (Date.now() - startTime < timeoutMs) {\n    lastResult = await checkAssertionInPage(tabId, frameId, resolved);\n    if (lastResult.passed) return lastResult;\n\n    // Wait before next poll\n    const remaining = timeoutMs - (Date.now() - startTime);\n    if (remaining > 0) {\n      await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)));\n    }\n  }\n\n  return {\n    passed: false,\n    message: `${lastResult.message || 'Assertion failed'} (timeout: ${timeoutMs}ms)`,\n  };\n}\n\nexport const assertHandler: ActionHandler<'assert'> = {\n  type: 'assert',\n\n  validate: (action) => {\n    const validation = validateAssertion(action.params.assert);\n    if (!validation.ok) {\n      return invalid(validation.error);\n    }\n    return ok();\n  },\n\n  describe: (action) => {\n    const assert = action.params.assert;\n    switch (assert.kind) {\n      case 'exists':\n        return `Assert exists: ${truncate(String(assert.selector), 30)}`;\n      case 'visible':\n        return `Assert visible: ${truncate(String(assert.selector), 30)}`;\n      case 'textPresent':\n        return `Assert text: \"${truncate(String(assert.text), 25)}\"`;\n      case 'attribute':\n        return `Assert attr: ${truncate(String(assert.name), 15)}`;\n      default:\n        return 'Assert';\n    }\n  },\n\n  run: async (ctx, action) => {\n    const tabId = ctx.tabId;\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for assert action');\n    }\n\n    // Resolve assertion parameters\n    const resolved = resolveAssertionParams(action.params.assert, ctx.vars);\n    if (!resolved.ok) {\n      return failed('VALIDATION_ERROR', resolved.error);\n    }\n\n    // Determine timeout from policy or default\n    const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_ASSERT_TIMEOUT_MS;\n    const failStrategy = action.params.failStrategy ?? 'stop';\n\n    // Execute assertion with polling\n    const result = await pollAssertion(tabId, ctx.frameId, resolved.resolved, timeoutMs);\n\n    if (result.passed) {\n      return { status: 'success' };\n    }\n\n    // Handle failure based on strategy\n    const errorMessage = result.message || 'Assertion failed';\n\n    switch (failStrategy) {\n      case 'warn':\n        ctx.log(`Assertion warning: ${errorMessage}`, 'warn');\n        return { status: 'success' };\n\n      case 'retry':\n        // Return failed with retryable error code\n        // The scheduler should handle retry based on policy\n        return failed('ASSERTION_FAILED', errorMessage);\n\n      case 'stop':\n      default:\n        return failed('ASSERTION_FAILED', errorMessage);\n    }\n  },\n};\n\n/** Truncate string for display */\nfunction truncate(str: string, maxLen: number): string {\n  if (typeof str !== 'string') return '(dynamic)';\n  return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/click.ts",
    "content": "/**\n * Click and Double-Click Action Handlers\n *\n * Handles click interactions:\n * - Single click\n * - Double click\n * - Post-click navigation/network wait\n * - Selector fallback with logging\n */\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ENGINE_CONSTANTS } from '../../engine/constants';\nimport {\n  maybeQuickWaitForNav,\n  waitForNavigationDone,\n  waitForNetworkIdle,\n} from '../../engine/policies/wait';\nimport { failed, invalid, ok } from '../registry';\nimport type {\n  Action,\n  ActionExecutionContext,\n  ActionExecutionResult,\n  ActionHandler,\n} from '../types';\nimport {\n  clampInt,\n  ensureElementVisible,\n  logSelectorFallback,\n  readTabUrl,\n  selectorLocator,\n  toSelectorTarget,\n} from './common';\n\n/**\n * Shared click execution logic for both click and dblclick\n */\nasync function executeClick<T extends 'click' | 'dblclick'>(\n  ctx: ActionExecutionContext,\n  action: Action<T>,\n): Promise<ActionExecutionResult<T>> {\n  const vars = ctx.vars;\n  const tabId = ctx.tabId;\n  // Check if StepRunner owns nav-wait (skip internal nav-wait logic)\n  const skipNavWait = ctx.execution?.skipNavWait === true;\n\n  if (typeof tabId !== 'number') {\n    return failed('TAB_NOT_FOUND', 'No active tab found');\n  }\n\n  // Ensure page is read before locating element\n  await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n\n  // Only read beforeUrl if we need to do nav-wait\n  const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);\n  const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(\n    action.params.target,\n    vars,\n  );\n\n  // Locate element using shared selector locator\n  const located = await selectorLocator.locate(tabId, selectorTarget, {\n    frameId: ctx.frameId,\n    preferRef: false,\n  });\n\n  const frameId = located?.frameId ?? ctx.frameId;\n  const refToUse = located?.ref ?? selectorTarget.ref;\n  const selectorToUse = !located?.ref ? firstCssOrAttr : undefined;\n\n  if (!refToUse && !selectorToUse) {\n    return failed('TARGET_NOT_FOUND', 'Could not locate target element');\n  }\n\n  // Verify element visibility if we have a ref\n  if (located?.ref) {\n    const isVisible = await ensureElementVisible(tabId, located.ref, frameId);\n    if (!isVisible) {\n      return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');\n    }\n  }\n\n  // Execute click with tool timeout\n  const toolTimeout = clampInt(action.policy?.timeout?.ms ?? 10000, 1000, 30000);\n\n  const clickResult = await handleCallTool({\n    name: TOOL_NAMES.BROWSER.CLICK,\n    args: {\n      ref: refToUse,\n      selector: selectorToUse,\n      waitForNavigation: false,\n      timeout: toolTimeout,\n      frameId,\n      tabId,\n      double: action.type === 'dblclick',\n    },\n  });\n\n  if ((clickResult as { isError?: boolean })?.isError) {\n    const errorContent = (clickResult as { content?: Array<{ text?: string }> })?.content;\n    const errorMsg = errorContent?.[0]?.text || `${action.type} action failed`;\n    return failed('UNKNOWN', errorMsg);\n  }\n\n  // Log selector fallback if used\n  const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n  const fallbackUsed =\n    resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;\n\n  if (fallbackUsed) {\n    logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));\n  }\n\n  // Skip post-click wait if StepRunner handles it\n  if (skipNavWait) {\n    return { status: 'success' };\n  }\n\n  // Post-click wait handling (only when handler owns nav-wait)\n  const waitMs = clampInt(\n    action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,\n    0,\n    ENGINE_CONSTANTS.MAX_WAIT_MS,\n  );\n  const after = action.params.after ?? {};\n\n  if (after.waitForNavigation) {\n    await waitForNavigationDone(beforeUrl, waitMs);\n  } else if (after.waitForNetworkIdle) {\n    const totalMs = clampInt(waitMs, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);\n    const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));\n    await waitForNetworkIdle(totalMs, idleMs);\n  } else {\n    // Quick sniff for navigation that might have been triggered\n    await maybeQuickWaitForNav(beforeUrl, waitMs);\n  }\n\n  return { status: 'success' };\n}\n\n/**\n * Validate click target configuration\n */\nfunction validateClickTarget(target: {\n  ref?: string;\n  candidates?: unknown[];\n}): { ok: true } | { ok: false; errors: [string, ...string[]] } {\n  const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;\n  const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;\n\n  if (hasRef || hasCandidates) {\n    return ok();\n  }\n  return invalid('Missing target selector or ref');\n}\n\nexport const clickHandler: ActionHandler<'click'> = {\n  type: 'click',\n\n  validate: (action) =>\n    validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),\n\n  describe: (action) => {\n    const target = action.params.target;\n    if (typeof (target as { ref?: string }).ref === 'string') {\n      return `Click element ${(target as { ref: string }).ref}`;\n    }\n    return 'Click element';\n  },\n\n  run: async (ctx, action) => {\n    return await executeClick(ctx, action);\n  },\n};\n\nexport const dblclickHandler: ActionHandler<'dblclick'> = {\n  type: 'dblclick',\n\n  validate: (action) =>\n    validateClickTarget(action.params.target as { ref?: string; candidates?: unknown[] }),\n\n  describe: (action) => {\n    const target = action.params.target;\n    if (typeof (target as { ref?: string }).ref === 'string') {\n      return `Double-click element ${(target as { ref: string }).ref}`;\n    }\n    return 'Double-click element';\n  },\n\n  run: async (ctx, action) => {\n    return await executeClick(ctx, action);\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/common.ts",
    "content": "/**\n * Common utilities for Action handlers\n *\n * Shared helpers for:\n * - Variable resolution and template interpolation\n * - Selector target conversion\n * - Element visibility verification\n * - Logging utilities\n */\n\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport {\n  createChromeSelectorLocator,\n  type SelectorCandidate as SharedSelectorCandidate,\n  type SelectorCandidateSource,\n  type SelectorStability,\n  type SelectorTarget,\n} from '@/shared/selector';\nimport { tryResolveString } from '../registry';\nimport type { ActionExecutionContext, ElementTarget, Resolvable, VariableStore } from '../types';\n\n// ================================\n// Selector Locator Instance\n// ================================\n\nexport const selectorLocator = createChromeSelectorLocator();\n\n// ================================\n// String Resolution Utilities\n// ================================\n\n/**\n * Interpolate {varName} placeholders in a string using variable store\n */\nexport function interpolateBraces(template: string, vars: VariableStore): string {\n  return String(template || '').replace(/\\{([^}]+)\\}/g, (_match, key) => {\n    const value = (vars as Record<string, unknown>)[key];\n    return value == null ? '' : String(value);\n  });\n}\n\n/**\n * Resolve a Resolvable<string> value with template interpolation\n */\nexport function resolveString(\n  value: Resolvable<string>,\n  vars: VariableStore,\n): { ok: true; value: string } | { ok: false; error: string } {\n  const resolved = tryResolveString(value, vars);\n  if (!resolved.ok) return resolved;\n  return { ok: true, value: interpolateBraces(resolved.value, vars) };\n}\n\n/**\n * Resolve an optional Resolvable<string> value\n */\nexport function resolveOptionalString(\n  value: Resolvable<string> | undefined,\n  vars: VariableStore,\n): string | undefined {\n  if (value === undefined) return undefined;\n  const resolved = resolveString(value, vars);\n  if (!resolved.ok) return undefined;\n  const out = resolved.value.trim();\n  return out.length > 0 ? out : undefined;\n}\n\n// ================================\n// Number Utilities\n// ================================\n\n/**\n * Clamp a number to a range with integer conversion\n */\nexport function clampInt(value: number, min: number, max: number): number {\n  const n = Number(value);\n  if (!Number.isFinite(n)) return min;\n  return Math.min(max, Math.max(min, Math.floor(n)));\n}\n\n// ================================\n// Selector Target Conversion\n// ================================\n\nexport interface ConvertedSelectorTarget {\n  selectorTarget: SelectorTarget;\n  /** Type of the first candidate (for fallback logging) */\n  firstCandidateType?: string;\n  /** First CSS or attr selector value (for tool fallback) */\n  firstCssOrAttr?: string;\n}\n\n/**\n * Convert Action ElementTarget to shared SelectorTarget\n *\n * Handles:\n * - Resolvable candidate values\n * - Template interpolation\n * - Weight assignment for locator priority\n */\nexport function toSelectorTarget(\n  target: ElementTarget,\n  vars: VariableStore,\n): ConvertedSelectorTarget {\n  const srcCandidates = Array.isArray(target.candidates) ? target.candidates : [];\n  const firstCandidateType =\n    srcCandidates.length > 0\n      ? String((srcCandidates[0] as { type?: string })?.type || '') || undefined\n      : undefined;\n\n  // Find first CSS/attr selector for tool fallback\n  let firstCssOrAttr: string | undefined;\n  for (const c of srcCandidates) {\n    if (c.type !== 'css' && c.type !== 'attr') continue;\n    const resolved = resolveString(c.selector, vars);\n    if (resolved.ok && resolved.value.trim()) {\n      firstCssOrAttr = resolved.value;\n      break;\n    }\n  }\n\n  // Extract selector from target if present\n  const primaryRaw =\n    typeof (target as { selector?: string }).selector === 'string'\n      ? String((target as { selector?: string }).selector).trim()\n      : '';\n  const selectorInterpolated = primaryRaw ? interpolateBraces(primaryRaw, vars).trim() : '';\n  const selector = selectorInterpolated || undefined;\n\n  // Extract tagName hint\n  const tagName =\n    typeof (target as { tag?: string }).tag === 'string'\n      ? String((target as { tag?: string }).tag)\n      : typeof (target as { hint?: { tagName?: string } }).hint?.tagName === 'string'\n        ? String((target as { hint?: { tagName?: string } }).hint!.tagName)\n        : undefined;\n\n  // Convert candidates with weight assignment\n  // Preserve user-defined weights while keeping text candidates as last resort\n  let nonTextIndex = 0;\n  let textIndex = 0;\n  const candidates: SharedSelectorCandidate[] = [];\n\n  for (const c of srcCandidates) {\n    const idx = c.type === 'text' ? textIndex++ : nonTextIndex++;\n    // Respect user-defined weight if present, otherwise use position-based weight\n    const userWeight =\n      typeof (c as { weight?: number }).weight === 'number' &&\n      Number.isFinite((c as { weight?: number }).weight)\n        ? (c as { weight: number }).weight\n        : 0;\n    // Non-text candidates get higher base weight\n    const weightBase = c.type === 'text' ? 0 : 1000;\n    const weight = weightBase + userWeight - idx;\n\n    // Preserve source and stability metadata from original candidate\n    // Type-safely extract optional source and stability fields\n    const rawSource = (c as { source?: SelectorCandidateSource }).source;\n    const rawStability = (c as { stability?: SelectorStability }).stability;\n    const meta: Pick<SharedSelectorCandidate, 'weight' | 'source' | 'stability'> = {\n      weight,\n      ...(rawSource && { source: rawSource }),\n      ...(rawStability && { stability: rawStability }),\n    };\n\n    switch (c.type) {\n      case 'css': {\n        const resolved = resolveString(c.selector, vars);\n        if (!resolved.ok) continue;\n        candidates.push({ type: 'css', value: resolved.value, ...meta });\n        break;\n      }\n      case 'attr': {\n        const resolved = resolveString(c.selector, vars);\n        if (!resolved.ok) continue;\n        candidates.push({ type: 'attr', value: resolved.value, ...meta });\n        break;\n      }\n      case 'xpath': {\n        const resolved = resolveString(c.xpath, vars);\n        if (!resolved.ok) continue;\n        candidates.push({ type: 'xpath', value: resolved.value, ...meta });\n        break;\n      }\n      case 'text': {\n        const resolved = resolveString(c.text, vars);\n        if (!resolved.ok) continue;\n        candidates.push({\n          type: 'text',\n          value: resolved.value,\n          ...meta,\n          match: c.match,\n          tagNameHint: c.tagNameHint ?? tagName,\n        });\n        break;\n      }\n      case 'aria': {\n        const role = resolveOptionalString(c.role, vars);\n        const name = resolveOptionalString(c.name, vars);\n        // Skip aria candidate if no name provided (would produce useless selector)\n        if (!name) break;\n        // Avoid injecting fake role; use aria-label format when role is not specified\n        const value = role\n          ? `${role}[name=${JSON.stringify(name)}]`\n          : `aria-label=${JSON.stringify(name)}`;\n        candidates.push({ type: 'aria', value, ...meta, role, name });\n        break;\n      }\n    }\n  }\n\n  // Ensure at least one candidate\n  const ensuredCandidates: [SharedSelectorCandidate, ...SharedSelectorCandidate[]] =\n    candidates.length > 0\n      ? (candidates as [SharedSelectorCandidate, ...SharedSelectorCandidate[]])\n      : [{ type: 'css', value: '' }];\n\n  return {\n    selectorTarget: {\n      selector,\n      candidates: ensuredCandidates,\n      tagName,\n      ref:\n        typeof (target as { ref?: string }).ref === 'string'\n          ? String((target as { ref?: string }).ref)\n          : undefined,\n    },\n    firstCandidateType,\n    firstCssOrAttr,\n  };\n}\n\n// ================================\n// Chrome Message Utilities\n// ================================\n\n/**\n * Result type for sendMessageToTab\n */\nexport type SendMessageResult<T = unknown> = { ok: true; value: T } | { ok: false; error: string };\n\n/**\n * Send message to tab with optional frameId\n * Returns structured result to avoid silent failures\n */\nexport async function sendMessageToTab<T = unknown>(\n  tabId: number,\n  message: unknown,\n  frameId?: number,\n): Promise<SendMessageResult<T>> {\n  try {\n    let response: T;\n    if (typeof frameId === 'number') {\n      response = await chrome.tabs.sendMessage(tabId, message, { frameId });\n    } else {\n      response = await chrome.tabs.sendMessage(tabId, message);\n    }\n    return { ok: true, value: response };\n  } catch (e) {\n    return { ok: false, error: e instanceof Error ? e.message : String(e) };\n  }\n}\n\n// ================================\n// Element Verification\n// ================================\n\n/**\n * Verify element is visible by checking its bounding rect\n */\nexport async function ensureElementVisible(\n  tabId: number,\n  ref: string,\n  frameId: number | undefined,\n): Promise<boolean> {\n  const result = await sendMessageToTab<{ rect?: { width: number; height: number } }>(\n    tabId,\n    { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref },\n    frameId,\n  );\n  if (!result.ok) return false;\n  const rect = result.value?.rect;\n  return !!rect && rect.width > 0 && rect.height > 0;\n}\n\n/**\n * Get current tab URL\n */\nexport async function readTabUrl(tabId: number): Promise<string> {\n  try {\n    const tab = await chrome.tabs.get(tabId);\n    return tab?.url || '';\n  } catch {\n    return '';\n  }\n}\n\n// ================================\n// Logging Utilities\n// ================================\n\nexport interface FallbackLogEntry {\n  stepId: string;\n  status: 'success';\n  message: string;\n  fallbackUsed: boolean;\n  fallbackFrom: string;\n  fallbackTo: string;\n}\n\n/**\n * Log selector fallback usage for debugging\n */\nexport function logSelectorFallback(\n  ctx: Pick<ActionExecutionContext, 'pushLog'>,\n  actionId: string,\n  from: string,\n  to: string,\n): void {\n  try {\n    ctx.pushLog?.({\n      stepId: actionId,\n      status: 'success',\n      message: `Selector fallback used (${from} -> ${to})`,\n      fallbackUsed: true,\n      fallbackFrom: from,\n      fallbackTo: to,\n    });\n  } catch {\n    // Ignore logging errors\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/control-flow.ts",
    "content": "/**\n * Control Flow Action Handlers\n *\n * Handles flow control operations:\n * - if: Conditional branching\n * - foreach: Loop over array\n * - while: Loop with condition\n * - switchFrame: Switch to a different frame\n *\n * Note: The actual loop iteration is handled by the Scheduler.\n * These handlers return control directives that tell the Scheduler how to proceed.\n */\n\nimport {\n  failed,\n  invalid,\n  ok,\n  tryResolveNumber,\n  tryResolveString,\n  tryResolveValue,\n} from '../registry';\nimport type {\n  ActionHandler,\n  Condition,\n  ControlDirective,\n  EdgeLabel,\n  VariableStore,\n} from '../types';\n\n/** Default max iterations for while loops */\nconst DEFAULT_MAX_ITERATIONS = 1000;\n\n// ================================\n// Condition Evaluation\n// ================================\n\n/**\n * Evaluate a condition against variables\n */\nfunction evaluateCondition(condition: Condition, vars: VariableStore): boolean {\n  switch (condition.kind) {\n    case 'expr': {\n      // Expression evaluation not supported in default resolver\n      // Return false for safety\n      return false;\n    }\n\n    case 'compare': {\n      const leftResult = tryResolveValue(condition.left, vars);\n      const rightResult = tryResolveValue(condition.right, vars);\n\n      if (!leftResult.ok || !rightResult.ok) return false;\n\n      const left = leftResult.value;\n      const right = rightResult.value;\n\n      switch (condition.op) {\n        case 'eq':\n          return left === right;\n        case 'eqi':\n          return String(left).toLowerCase() === String(right).toLowerCase();\n        case 'neq':\n          return left !== right;\n        case 'gt':\n          return Number(left) > Number(right);\n        case 'gte':\n          return Number(left) >= Number(right);\n        case 'lt':\n          return Number(left) < Number(right);\n        case 'lte':\n          return Number(left) <= Number(right);\n        case 'contains':\n          return String(left).includes(String(right));\n        case 'containsI':\n          return String(left).toLowerCase().includes(String(right).toLowerCase());\n        case 'notContains':\n          return !String(left).includes(String(right));\n        case 'notContainsI':\n          return !String(left).toLowerCase().includes(String(right).toLowerCase());\n        case 'startsWith':\n          return String(left).startsWith(String(right));\n        case 'endsWith':\n          return String(left).endsWith(String(right));\n        case 'regex': {\n          try {\n            const regex = new RegExp(String(right));\n            return regex.test(String(left));\n          } catch {\n            return false;\n          }\n        }\n        default:\n          return false;\n      }\n    }\n\n    case 'truthy': {\n      const result = tryResolveValue(condition.value, vars);\n      if (!result.ok) return false;\n      return Boolean(result.value);\n    }\n\n    case 'falsy': {\n      const result = tryResolveValue(condition.value, vars);\n      if (!result.ok) return true;\n      return !result.value;\n    }\n\n    case 'not':\n      return !evaluateCondition(condition.condition, vars);\n\n    case 'and':\n      return condition.conditions.every((c) => evaluateCondition(c, vars));\n\n    case 'or':\n      return condition.conditions.some((c) => evaluateCondition(c, vars));\n\n    default:\n      return false;\n  }\n}\n\n// ================================\n// if Handler\n// ================================\n\nexport const ifHandler: ActionHandler<'if'> = {\n  type: 'if',\n\n  validate: (action) => {\n    const params = action.params;\n\n    if (params.mode === 'binary') {\n      if (!params.condition) {\n        return invalid('Binary if requires a condition');\n      }\n    } else if (params.mode === 'branches') {\n      if (!params.branches || params.branches.length === 0) {\n        return invalid('Branches if requires at least one branch');\n      }\n    } else {\n      return invalid(`Unknown if mode: ${String((params as { mode: string }).mode)}`);\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    if (action.params.mode === 'binary') {\n      return 'If condition';\n    }\n    const branchCount = action.params.mode === 'branches' ? action.params.branches.length : 0;\n    return `If (${branchCount} branches)`;\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    if (params.mode === 'binary') {\n      const result = evaluateCondition(params.condition, ctx.vars);\n      const label: EdgeLabel = result\n        ? (params.trueLabel ?? 'true')\n        : (params.falseLabel ?? 'false');\n      return { status: 'success', nextLabel: label };\n    }\n\n    // Branches mode\n    if (params.mode === 'branches') {\n      for (const branch of params.branches) {\n        if (evaluateCondition(branch.condition, ctx.vars)) {\n          return { status: 'success', nextLabel: branch.label };\n        }\n      }\n      // No branch matched, use else label\n      const elseLabel = params.elseLabel ?? 'default';\n      return { status: 'success', nextLabel: elseLabel };\n    }\n\n    return failed('VALIDATION_ERROR', 'Invalid if mode');\n  },\n};\n\n// ================================\n// foreach Handler\n// ================================\n\nexport const foreachHandler: ActionHandler<'foreach'> = {\n  type: 'foreach',\n\n  validate: (action) => {\n    const params = action.params;\n\n    if (!params.listVar) {\n      return invalid('foreach requires a listVar');\n    }\n\n    if (!params.subflowId) {\n      return invalid('foreach requires a subflowId');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    return `For each in ${action.params.listVar}`;\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    // Check if listVar exists and is an array\n    const list = ctx.vars[params.listVar];\n    if (!Array.isArray(list)) {\n      return failed('VALIDATION_ERROR', `Variable \"${params.listVar}\" is not an array`);\n    }\n\n    if (list.length === 0) {\n      // Empty list, nothing to iterate\n      return { status: 'success' };\n    }\n\n    // Return control directive for scheduler to handle\n    const directive: ControlDirective = {\n      kind: 'foreach',\n      listVar: params.listVar,\n      itemVar: params.itemVar || 'item',\n      subflowId: params.subflowId,\n      concurrency: params.concurrency,\n    };\n\n    return { status: 'success', control: directive };\n  },\n};\n\n// ================================\n// while Handler\n// ================================\n\nexport const whileHandler: ActionHandler<'while'> = {\n  type: 'while',\n\n  validate: (action) => {\n    const params = action.params;\n\n    if (!params.condition) {\n      return invalid('while requires a condition');\n    }\n\n    if (!params.subflowId) {\n      return invalid('while requires a subflowId');\n    }\n\n    return ok();\n  },\n\n  describe: () => {\n    return 'While loop';\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    // Check if condition is currently true\n    const conditionResult = evaluateCondition(params.condition, ctx.vars);\n\n    if (!conditionResult) {\n      // Condition is false, don't enter loop\n      return { status: 'success' };\n    }\n\n    // Return control directive for scheduler to handle\n    const directive: ControlDirective = {\n      kind: 'while',\n      condition: params.condition,\n      subflowId: params.subflowId,\n      maxIterations: params.maxIterations ?? DEFAULT_MAX_ITERATIONS,\n    };\n\n    return { status: 'success', control: directive };\n  },\n};\n\n// ================================\n// switchFrame Handler\n// ================================\n\nexport const switchFrameHandler: ActionHandler<'switchFrame'> = {\n  type: 'switchFrame',\n\n  validate: (action) => {\n    const target = action.params.target;\n\n    if (!target) {\n      return invalid('switchFrame requires a target');\n    }\n\n    if (target.kind !== 'top' && target.kind !== 'index' && target.kind !== 'urlContains') {\n      return invalid(`Unknown frame target kind: ${String((target as { kind: string }).kind)}`);\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const target = action.params.target;\n    if (target.kind === 'top') return 'Switch to top frame';\n    if (target.kind === 'index') return `Switch to frame #${target.index}`;\n    if (target.kind === 'urlContains') return 'Switch frame (by URL)';\n    return 'Switch frame';\n  },\n\n  run: async (ctx, action) => {\n    const target = action.params.target;\n    const tabId = ctx.tabId;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found');\n    }\n\n    try {\n      if (target.kind === 'top') {\n        // Reset to main frame (frameId = 0)\n        ctx.frameId = 0;\n        return { status: 'success' };\n      }\n\n      // Get all frames in the tab\n      const frames = await chrome.webNavigation.getAllFrames({ tabId });\n      if (!frames || frames.length === 0) {\n        return failed('FRAME_NOT_FOUND', 'No frames found in tab');\n      }\n\n      let targetFrame: chrome.webNavigation.GetAllFrameResultDetails | undefined;\n\n      if (target.kind === 'index') {\n        const indexResult = tryResolveNumber(target.index, ctx.vars);\n        if (!indexResult.ok) {\n          return failed('VALIDATION_ERROR', `Failed to resolve frame index: ${indexResult.error}`);\n        }\n        const index = Math.floor(indexResult.value);\n\n        // Find frame by index (excluding main frame which is 0)\n        const childFrames = frames.filter((f) => f.frameId !== 0);\n        if (index < 0 || index >= childFrames.length) {\n          return failed(\n            'FRAME_NOT_FOUND',\n            `Frame index ${index} out of bounds (${childFrames.length} frames)`,\n          );\n        }\n        targetFrame = childFrames[index];\n      } else if (target.kind === 'urlContains') {\n        const urlResult = tryResolveString(target.value, ctx.vars);\n        if (!urlResult.ok) {\n          return failed('VALIDATION_ERROR', `Failed to resolve URL pattern: ${urlResult.error}`);\n        }\n        const urlPattern = urlResult.value.trim().toLowerCase();\n\n        // Empty pattern is invalid\n        if (!urlPattern) {\n          return failed('VALIDATION_ERROR', 'URL pattern cannot be empty');\n        }\n\n        targetFrame = frames.find((f) => f.url && f.url.toLowerCase().includes(urlPattern));\n      }\n\n      if (!targetFrame) {\n        return failed('FRAME_NOT_FOUND', 'No matching frame found');\n      }\n\n      // The frameId will be used by subsequent actions\n      // Store it in context (this is typically handled by scheduler)\n      ctx.frameId = targetFrame.frameId;\n\n      return { status: 'success' };\n    } catch (e) {\n      return failed(\n        'FRAME_NOT_FOUND',\n        `Failed to switch frame: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/delay.ts",
    "content": "/**\n * Delay Action Handler\n *\n * Provides a simple pause in execution flow.\n * Supports variable resolution for dynamic delay times.\n */\n\nimport { failed, invalid, ok, tryResolveNumber } from '../registry';\nimport type { ActionHandler } from '../types';\n\n/** Maximum delay time to prevent integer overflow in setTimeout */\nconst MAX_DELAY_MS = 2_147_483_647;\n\nexport const delayHandler: ActionHandler<'delay'> = {\n  type: 'delay',\n\n  validate: (action) => {\n    if (action.params.sleep === undefined) {\n      return invalid('Missing sleep parameter');\n    }\n    return ok();\n  },\n\n  describe: (action) => {\n    const ms = typeof action.params.sleep === 'number' ? action.params.sleep : '(dynamic)';\n    return `Delay ${ms}ms`;\n  },\n\n  run: async (ctx, action) => {\n    const resolved = tryResolveNumber(action.params.sleep, ctx.vars);\n    if (!resolved.ok) {\n      return failed('VALIDATION_ERROR', resolved.error);\n    }\n\n    const ms = Math.max(0, Math.min(MAX_DELAY_MS, Math.floor(resolved.value)));\n\n    if (ms > 0) {\n      await new Promise((resolve) => setTimeout(resolve, ms));\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/dom.ts",
    "content": "/**\n * DOM Tools Action Handlers\n *\n * Handles DOM manipulation actions:\n * - triggerEvent: Dispatch a custom DOM Event on an element\n * - setAttribute: Set or remove an attribute on an element\n *\n * Design notes:\n * - Both handlers follow the same pattern as click.ts\n * - Element location uses selectorLocator from shared code\n * - CSS selector resolution supports ref fallback\n */\n\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok, tryResolveJson } from '../registry';\nimport type {\n  ActionExecutionResult,\n  ActionHandler,\n  ElementTarget,\n  JsonValue,\n  VariableStore,\n} from '../types';\nimport {\n  interpolateBraces,\n  logSelectorFallback,\n  resolveString,\n  selectorLocator,\n  sendMessageToTab,\n  toSelectorTarget,\n} from './common';\n\n// ================================\n// Type Definitions\n// ================================\n\ninterface ResolveRefResponse {\n  success?: boolean;\n  selector?: string;\n  error?: string;\n}\n\ninterface DomScriptResult {\n  success: boolean;\n  error?: string;\n}\n\ninterface ResolvedTarget {\n  selector: string;\n  frameId: number | undefined;\n  firstCandidateType?: string;\n  resolvedBy?: string;\n}\n\n// ================================\n// Shared Utilities\n// ================================\n\n/**\n * Check if target has valid ref or candidates\n * Accepts unknown to safely handle malformed input in validate()\n */\nfunction hasValidTarget(target: unknown): boolean {\n  if (typeof target !== 'object' || target === null) return false;\n  const t = target as { ref?: unknown; candidates?: unknown };\n  const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;\n  const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;\n  return hasRef || hasCandidates;\n}\n\n/**\n * Strip frame prefix from composite selector (e.g., \"frame|>selector\" -> \"selector\")\n */\nfunction stripCompositePrefix(selector: string): string {\n  const raw = String(selector || '').trim();\n  if (!raw.includes('|>')) return raw;\n\n  const parts = raw\n    .split('|>')\n    .map((p) => p.trim())\n    .filter(Boolean);\n  return parts.length > 0 ? parts[parts.length - 1] : raw;\n}\n\n/**\n * Resolve ElementTarget to a CSS selector string\n *\n * Resolution order:\n * 1. Try to locate element using selectorLocator\n * 2. If ref found, resolve it to CSS selector via content script\n * 3. Fall back to first CSS/attr candidate if no ref\n */\nasync function resolveTargetSelector(\n  tabId: number,\n  target: ElementTarget,\n  vars: VariableStore,\n  contextFrameId: number | undefined,\n): Promise<{ ok: true; value: ResolvedTarget } | { ok: false; error: string }> {\n  const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);\n\n  // Locate element using shared selector locator\n  const located = await selectorLocator.locate(tabId, selectorTarget, {\n    frameId: contextFrameId,\n    preferRef: false,\n  });\n\n  const frameId = located?.frameId ?? contextFrameId;\n  const refToUse = located?.ref ?? selectorTarget.ref;\n\n  // Must have either ref or CSS/attr candidate\n  if (!refToUse && !firstCssOrAttr) {\n    return { ok: false, error: 'Could not locate target element' };\n  }\n\n  let selector: string | undefined;\n\n  // Try to resolve ref to CSS selector\n  if (refToUse) {\n    const resolved = await sendMessageToTab<ResolveRefResponse>(\n      tabId,\n      { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },\n      frameId,\n    );\n\n    if (resolved.ok && resolved.value?.success !== false && resolved.value?.selector) {\n      const sel = resolved.value.selector.trim();\n      if (sel) selector = sel;\n    }\n  }\n\n  // Fall back to CSS/attr candidate\n  if (!selector && firstCssOrAttr) {\n    const stripped = stripCompositePrefix(firstCssOrAttr);\n    if (stripped) selector = stripped;\n  }\n\n  if (!selector) {\n    return { ok: false, error: 'Could not resolve a CSS selector for the target element' };\n  }\n\n  return {\n    ok: true,\n    value: {\n      selector,\n      frameId,\n      firstCandidateType,\n      // Only mark as 'ref' if locator actually resolved via ref\n      resolvedBy: located?.resolvedBy || (located?.ref ? 'ref' : undefined),\n    },\n  };\n}\n\n/**\n * Log selector fallback if a different selector type was used\n */\nfunction maybeLogFallback(\n  ctx: Parameters<typeof logSelectorFallback>[0],\n  actionId: string,\n  resolved: ResolvedTarget,\n): void {\n  const { resolvedBy, firstCandidateType } = resolved;\n\n  const fallbackUsed =\n    resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;\n\n  if (fallbackUsed) {\n    logSelectorFallback(ctx, actionId, String(firstCandidateType), String(resolvedBy));\n  }\n}\n\n// ================================\n// triggerEvent Handler\n// ================================\n\nexport const triggerEventHandler: ActionHandler<'triggerEvent'> = {\n  type: 'triggerEvent',\n\n  validate: (action) => {\n    if (!hasValidTarget(action.params.target)) {\n      return invalid('triggerEvent requires a target ref or selector candidates');\n    }\n\n    const event = action.params.event;\n    if (event === undefined || event === null) {\n      return invalid('Missing event parameter');\n    }\n    if (typeof event === 'string' && event.trim().length === 0) {\n      return invalid('event must be a non-empty string');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const ev = typeof action.params.event === 'string' ? action.params.event : '(dynamic)';\n    const display = ev.length > 30 ? ev.slice(0, 30) + '...' : ev;\n    return `Trigger event \"${display}\"`;\n  },\n\n  run: async (ctx, action): Promise<ActionExecutionResult<'triggerEvent'>> => {\n    const { tabId, vars, frameId } = ctx;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for triggerEvent action');\n    }\n\n    // Resolve event type\n    const eventResolved = resolveString(action.params.event, vars);\n    if (!eventResolved.ok) {\n      return failed('VALIDATION_ERROR', eventResolved.error);\n    }\n\n    const eventType = eventResolved.value.trim();\n    if (!eventType) {\n      return failed('VALIDATION_ERROR', 'Event type is empty');\n    }\n\n    // Event options\n    const bubbles = action.params.bubbles !== false;\n    const cancelable = action.params.cancelable === true;\n\n    // Ensure page is read for element location\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });\n\n    // Resolve target selector\n    const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);\n    if (!targetResolved.ok) {\n      return failed('TARGET_NOT_FOUND', targetResolved.error);\n    }\n\n    const { selector, frameId: resolvedFrameId } = targetResolved.value;\n    const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;\n\n    // Execute event dispatch in page context\n    try {\n      const injected = await chrome.scripting.executeScript({\n        target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n        world: 'MAIN',\n        func: (\n          sel: string,\n          type: string,\n          bubbles: boolean,\n          cancelable: boolean,\n        ): DomScriptResult => {\n          try {\n            const el = document.querySelector(sel);\n            if (!el) {\n              // Use special error code to distinguish from script execution errors\n              return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };\n            }\n\n            const event = new Event(type, { bubbles, cancelable });\n            el.dispatchEvent(event);\n            return { success: true };\n          } catch (e) {\n            return { success: false, error: e instanceof Error ? e.message : String(e) };\n          }\n        },\n        args: [selector, eventType, bubbles, cancelable],\n      });\n\n      const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n      if (!result || typeof result !== 'object') {\n        return failed('SCRIPT_FAILED', 'triggerEvent script returned invalid result');\n      }\n\n      const typed = result as DomScriptResult;\n      if (!typed.success) {\n        // Parse error code from message if present (e.g., \"[TARGET_NOT_FOUND] ...\")\n        const errorMsg = typed.error || `Failed to dispatch \"${eventType}\"`;\n        const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')\n          ? 'TARGET_NOT_FOUND'\n          : 'SCRIPT_FAILED';\n        return failed(code, errorMsg.replace(/^\\[TARGET_NOT_FOUND\\]\\s*/, ''));\n      }\n    } catch (e) {\n      return failed(\n        'SCRIPT_FAILED',\n        `Failed to trigger event \"${eventType}\": ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n\n    maybeLogFallback(ctx, action.id, targetResolved.value);\n\n    return { status: 'success' };\n  },\n};\n\n// ================================\n// setAttribute Handler\n// ================================\n\nexport const setAttributeHandler: ActionHandler<'setAttribute'> = {\n  type: 'setAttribute',\n\n  validate: (action) => {\n    if (!hasValidTarget(action.params.target)) {\n      return invalid('setAttribute requires a target ref or selector candidates');\n    }\n\n    const name = action.params.name;\n    if (name === undefined || name === null) {\n      return invalid('Missing name parameter');\n    }\n    if (typeof name === 'string' && name.trim().length === 0) {\n      return invalid('name must be a non-empty string');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const name = typeof action.params.name === 'string' ? action.params.name : '(dynamic)';\n    const display = name.length > 30 ? name.slice(0, 30) + '...' : name;\n    return action.params.remove ? `Remove attribute \"${display}\"` : `Set attribute \"${display}\"`;\n  },\n\n  run: async (ctx, action): Promise<ActionExecutionResult<'setAttribute'>> => {\n    const { tabId, vars, frameId } = ctx;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for setAttribute action');\n    }\n\n    // Resolve attribute name\n    const nameResolved = resolveString(action.params.name, vars);\n    if (!nameResolved.ok) {\n      return failed('VALIDATION_ERROR', nameResolved.error);\n    }\n\n    const attrName = nameResolved.value.trim();\n    if (!attrName) {\n      return failed('VALIDATION_ERROR', 'Attribute name is empty');\n    }\n\n    const remove = action.params.remove === true;\n\n    // Resolve attribute value (only if not removing)\n    let attrValue: JsonValue = null;\n    if (!remove && action.params.value !== undefined) {\n      const valueResolved = tryResolveJson(action.params.value, vars);\n      if (!valueResolved.ok) {\n        return failed('VALIDATION_ERROR', valueResolved.error);\n      }\n\n      // Apply template interpolation for string values\n      attrValue =\n        typeof valueResolved.value === 'string'\n          ? interpolateBraces(valueResolved.value, vars)\n          : valueResolved.value;\n    }\n\n    // Ensure page is read for element location\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });\n\n    // Resolve target selector\n    const targetResolved = await resolveTargetSelector(tabId, action.params.target, vars, frameId);\n    if (!targetResolved.ok) {\n      return failed('TARGET_NOT_FOUND', targetResolved.error);\n    }\n\n    const { selector, frameId: resolvedFrameId } = targetResolved.value;\n    const frameIds = typeof resolvedFrameId === 'number' ? [resolvedFrameId] : undefined;\n\n    // Execute attribute modification in page context\n    try {\n      const injected = await chrome.scripting.executeScript({\n        target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n        world: 'MAIN',\n        func: (sel: string, name: string, value: JsonValue, remove: boolean): DomScriptResult => {\n          try {\n            const el = document.querySelector(sel);\n            if (!el) {\n              // Use special error code to distinguish from script execution errors\n              return { success: false, error: `[TARGET_NOT_FOUND] Element not found: ${sel}` };\n            }\n\n            if (remove) {\n              el.removeAttribute(name);\n            } else {\n              // Convert value to string for setAttribute\n              const strValue =\n                value === null || value === undefined\n                  ? ''\n                  : typeof value === 'string'\n                    ? value\n                    : String(value);\n              el.setAttribute(name, strValue);\n            }\n\n            return { success: true };\n          } catch (e) {\n            return { success: false, error: e instanceof Error ? e.message : String(e) };\n          }\n        },\n        args: [selector, attrName, attrValue, remove],\n      });\n\n      const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n      if (!result || typeof result !== 'object') {\n        return failed('SCRIPT_FAILED', 'setAttribute script returned invalid result');\n      }\n\n      const typed = result as DomScriptResult;\n      if (!typed.success) {\n        const actionDesc = remove ? 'remove' : 'set';\n        // Parse error code from message if present (e.g., \"[TARGET_NOT_FOUND] ...\")\n        const errorMsg = typed.error || `Failed to ${actionDesc} attribute \"${attrName}\"`;\n        const code = errorMsg.startsWith('[TARGET_NOT_FOUND]')\n          ? 'TARGET_NOT_FOUND'\n          : 'SCRIPT_FAILED';\n        return failed(code, errorMsg.replace(/^\\[TARGET_NOT_FOUND\\]\\s*/, ''));\n      }\n    } catch (e) {\n      const actionDesc = remove ? 'remove' : 'set';\n      return failed(\n        'SCRIPT_FAILED',\n        `Failed to ${actionDesc} attribute \"${attrName}\": ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n\n    maybeLogFallback(ctx, action.id, targetResolved.value);\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/drag.ts",
    "content": "/**\n * Drag Action Handler\n *\n * Performs a left-click drag from a start target to an end target.\n *\n * Features:\n * - Locates start/end via shared SelectorLocator (ref + candidates)\n * - Executes via chrome_computer with action=\"left_click_drag\" (CDP-based)\n * - Uses optional `path` endpoints as a fallback for coordinates\n * - Validates element visibility before drag\n */\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok } from '../registry';\nimport type { ActionHandler, ElementTarget, Point, VariableStore } from '../types';\nimport {\n  ensureElementVisible,\n  logSelectorFallback,\n  selectorLocator,\n  toSelectorTarget,\n} from './common';\n\ninterface Coordinates {\n  x: number;\n  y: number;\n}\n\n/** Check if target has valid selector specification */\nfunction hasTargetSpec(target: unknown): boolean {\n  if (!target || typeof target !== 'object') return false;\n  const t = target as { ref?: unknown; candidates?: unknown };\n  const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;\n  const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;\n  return hasRef || hasCandidates;\n}\n\n/** Check if value is a finite number */\nfunction isFiniteNumber(v: unknown): v is number {\n  return typeof v === 'number' && Number.isFinite(v);\n}\n\n/** Extract start/end coordinates from path array */\nfunction getPathEndpoints(\n  path: ReadonlyArray<Point> | undefined,\n): { startCoordinates: Coordinates; endCoordinates: Coordinates } | null {\n  if (!Array.isArray(path) || path.length < 2) return null;\n\n  const first = path[0];\n  const last = path[path.length - 1];\n\n  if (!first || !last) return null;\n  if (!isFiniteNumber(first.x) || !isFiniteNumber(first.y)) return null;\n  if (!isFiniteNumber(last.x) || !isFiniteNumber(last.y)) return null;\n\n  return {\n    startCoordinates: { x: first.x, y: first.y },\n    endCoordinates: { x: last.x, y: last.y },\n  };\n}\n\n/** Extract error text from tool result */\nfunction extractToolError(result: unknown, fallback: string): string {\n  const content = (result as { content?: Array<{ text?: string }> })?.content;\n  return content?.find((c) => typeof c?.text === 'string')?.text || fallback;\n}\n\n/** Locate target and verify visibility */\nasync function locateTarget(\n  tabId: number,\n  frameId: number | undefined,\n  target: ElementTarget | undefined,\n  vars: VariableStore,\n  role: 'start' | 'end',\n): Promise<\n  | { ok: true; ref?: string; firstCandidateType?: string; resolvedBy?: string }\n  | { ok: false; error: string; code: 'TARGET_NOT_FOUND' | 'ELEMENT_NOT_VISIBLE' }\n> {\n  if (!target || !hasTargetSpec(target)) {\n    return { ok: true };\n  }\n\n  const { selectorTarget, firstCandidateType } = toSelectorTarget(target, vars);\n\n  const located = await selectorLocator.locate(tabId, selectorTarget, {\n    frameId,\n    preferRef: false,\n  });\n\n  const locatedFrameId = located?.frameId ?? frameId;\n  const ref = located?.ref ?? selectorTarget.ref;\n  const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n\n  // Verify visibility for freshly located refs\n  if (located?.ref) {\n    const visible = await ensureElementVisible(tabId, located.ref, locatedFrameId);\n    if (!visible) {\n      return {\n        ok: false,\n        error: `Drag ${role} element is not visible`,\n        code: 'ELEMENT_NOT_VISIBLE',\n      };\n    }\n  }\n\n  return { ok: true, ref, firstCandidateType, resolvedBy };\n}\n\nexport const dragHandler: ActionHandler<'drag'> = {\n  type: 'drag',\n\n  validate: (action) => {\n    const pathEndpoints = getPathEndpoints(action.params.path);\n\n    // If path is present, it must be well-formed\n    if (action.params.path !== undefined && action.params.path.length > 0 && !pathEndpoints) {\n      return invalid('path must contain at least two points with finite x/y coordinates');\n    }\n\n    const hasStart = hasTargetSpec(action.params.start);\n    const hasEnd = hasTargetSpec(action.params.end);\n    const hasPath = !!pathEndpoints;\n\n    // Must have either target spec or path coordinates\n    if (!hasStart && !hasPath) {\n      return invalid('Drag start must include a non-empty ref or selector candidates');\n    }\n    if (!hasEnd && !hasPath) {\n      return invalid('Drag end must include a non-empty ref or selector candidates');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const startRef = (action.params.start as { ref?: unknown })?.ref;\n    const endRef = (action.params.end as { ref?: unknown })?.ref;\n\n    const s = typeof startRef === 'string' && startRef.trim() ? startRef.trim() : '';\n    const e = typeof endRef === 'string' && endRef.trim() ? endRef.trim() : '';\n\n    if (s && e) {\n      const truncS = s.length > 15 ? s.slice(0, 15) + '...' : s;\n      const truncE = e.length > 15 ? e.slice(0, 15) + '...' : e;\n      return `Drag ${truncS} → ${truncE}`;\n    }\n    if (s) return `Drag from ${s.length > 20 ? s.slice(0, 20) + '...' : s}`;\n    if (e) return `Drag to ${e.length > 20 ? e.slice(0, 20) + '...' : e}`;\n\n    const pathEndpoints = getPathEndpoints(action.params.path);\n    if (pathEndpoints) {\n      const { startCoordinates, endCoordinates } = pathEndpoints;\n      return `Drag (${startCoordinates.x},${startCoordinates.y}) → (${endCoordinates.x},${endCoordinates.y})`;\n    }\n\n    return 'Drag';\n  },\n\n  run: async (ctx, action) => {\n    const tabId = ctx.tabId;\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for drag action');\n    }\n\n    // Ensure element refs are fresh before locating\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });\n\n    // Get path coordinates as fallback\n    const pathEndpoints = getPathEndpoints(action.params.path);\n    const startCoordinates = pathEndpoints?.startCoordinates;\n    const endCoordinates = pathEndpoints?.endCoordinates;\n\n    // Locate start target\n    const startResult = await locateTarget(\n      tabId,\n      ctx.frameId,\n      action.params.start,\n      ctx.vars,\n      'start',\n    );\n    if (!startResult.ok) {\n      return failed(startResult.code, startResult.error);\n    }\n\n    // Locate end target\n    const endResult = await locateTarget(tabId, ctx.frameId, action.params.end, ctx.vars, 'end');\n    if (!endResult.ok) {\n      return failed(endResult.code, endResult.error);\n    }\n\n    // Validate we have at least one way to identify start and end\n    if (!startResult.ref && !startCoordinates) {\n      return failed('TARGET_NOT_FOUND', 'Could not resolve drag start (ref or path coordinates)');\n    }\n    if (!endResult.ref && !endCoordinates) {\n      return failed('TARGET_NOT_FOUND', 'Could not resolve drag end (ref or path coordinates)');\n    }\n\n    // Execute drag via chrome_computer tool\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.COMPUTER,\n      args: {\n        action: 'left_click_drag',\n        tabId,\n        startRef: startResult.ref,\n        ref: endResult.ref,\n        startCoordinates,\n        coordinates: endCoordinates,\n      },\n    });\n\n    if ((res as { isError?: boolean })?.isError) {\n      return failed('UNKNOWN', extractToolError(res, 'Drag action failed'));\n    }\n\n    // Log selector fallback after successful execution\n    const startFallbackUsed =\n      startResult.resolvedBy &&\n      startResult.firstCandidateType &&\n      startResult.resolvedBy !== 'ref' &&\n      startResult.resolvedBy !== startResult.firstCandidateType;\n\n    if (startFallbackUsed) {\n      logSelectorFallback(\n        ctx,\n        action.id,\n        `start:${String(startResult.firstCandidateType)}`,\n        `start:${String(startResult.resolvedBy)}`,\n      );\n    }\n\n    const endFallbackUsed =\n      endResult.resolvedBy &&\n      endResult.firstCandidateType &&\n      endResult.resolvedBy !== 'ref' &&\n      endResult.resolvedBy !== endResult.firstCandidateType;\n\n    if (endFallbackUsed) {\n      logSelectorFallback(\n        ctx,\n        action.id,\n        `end:${String(endResult.firstCandidateType)}`,\n        `end:${String(endResult.resolvedBy)}`,\n      );\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/extract.ts",
    "content": "/**\n * Extract Action Handler\n *\n * Extracts data from the page and stores in variables:\n * - selector mode: Extract text/attribute from elements\n * - js mode: Execute JavaScript and capture return value\n */\n\nimport { failed, invalid, ok, tryResolveString } from '../registry';\nimport type { ActionHandler, BrowserWorld, JsonValue, VariableStore } from '../types';\n\n/** Default attribute to extract */\nconst DEFAULT_EXTRACT_ATTR = 'textContent';\n\n/**\n * Execute extraction script in page context\n */\nasync function executeExtraction(\n  tabId: number,\n  frameId: number | undefined,\n  mode: 'selector' | 'js',\n  params: {\n    selector?: string;\n    attr?: string;\n    code?: string;\n    world?: BrowserWorld;\n  },\n): Promise<{ ok: true; value: JsonValue } | { ok: false; error: string }> {\n  const frameIds = typeof frameId === 'number' ? [frameId] : undefined;\n  const world = params.world === 'ISOLATED' ? 'ISOLATED' : 'MAIN';\n\n  try {\n    if (mode === 'selector') {\n      const injected = await chrome.scripting.executeScript({\n        target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n        world,\n        func: (selector: string, attr: string) => {\n          const el = document.querySelector(selector);\n          if (!el) {\n            return { success: false, error: `Element not found: ${selector}` };\n          }\n\n          let value: JsonValue;\n\n          // Handle special attribute names\n          if (attr === 'text' || attr === 'textContent') {\n            value = el.textContent?.trim() ?? '';\n          } else if (attr === 'innerText') {\n            value = (el as HTMLElement).innerText?.trim() ?? '';\n          } else if (attr === 'innerHTML') {\n            value = el.innerHTML;\n          } else if (attr === 'outerHTML') {\n            value = el.outerHTML;\n          } else if (attr === 'value') {\n            // For form elements\n            value = (el as HTMLInputElement).value ?? '';\n          } else if (attr === 'checked') {\n            value = (el as HTMLInputElement).checked ?? false;\n          } else if (attr === 'href') {\n            value = (el as HTMLAnchorElement).href ?? el.getAttribute('href') ?? '';\n          } else if (attr === 'src') {\n            value = (el as HTMLImageElement).src ?? el.getAttribute('src') ?? '';\n          } else {\n            // Generic attribute\n            const attrValue = el.getAttribute(attr);\n            value = attrValue ?? '';\n          }\n\n          return { success: true, value };\n        },\n        args: [params.selector!, params.attr!],\n      });\n\n      const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n      if (!result || typeof result !== 'object') {\n        return { ok: false, error: 'Extraction script returned invalid result' };\n      }\n\n      if (!result.success) {\n        return { ok: false, error: result.error || 'Extraction failed' };\n      }\n\n      return { ok: true, value: result.value as JsonValue };\n    }\n\n    // JS mode\n    const injected = await chrome.scripting.executeScript({\n      target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n      world,\n      func: (code: string) => {\n        try {\n          // Create function and execute\n          const fn = new Function(code);\n          const result = fn();\n\n          // Handle promises\n          if (result instanceof Promise) {\n            return result.then(\n              (value: unknown) => ({ success: true, value }),\n              (error: Error) => ({ success: false, error: error?.message || String(error) }),\n            );\n          }\n\n          return { success: true, value: result };\n        } catch (e) {\n          return { success: false, error: e instanceof Error ? e.message : String(e) };\n        }\n      },\n      args: [params.code!],\n    });\n\n    const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n\n    // Handle async result\n    if (result instanceof Promise) {\n      const asyncResult = await result;\n      if (!asyncResult || typeof asyncResult !== 'object') {\n        return { ok: false, error: 'Async extraction returned invalid result' };\n      }\n      if (!asyncResult.success) {\n        return { ok: false, error: asyncResult.error || 'Extraction failed' };\n      }\n      return { ok: true, value: asyncResult.value as JsonValue };\n    }\n\n    if (!result || typeof result !== 'object') {\n      return { ok: false, error: 'Extraction script returned invalid result' };\n    }\n\n    const typedResult = result as { success: boolean; value?: unknown; error?: string };\n    if (!typedResult.success) {\n      return { ok: false, error: typedResult.error || 'Extraction failed' };\n    }\n\n    return { ok: true, value: typedResult.value as JsonValue };\n  } catch (e) {\n    return {\n      ok: false,\n      error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,\n    };\n  }\n}\n\n/**\n * Resolve extraction parameters\n */\nfunction resolveExtractParams(\n  params: unknown,\n  vars: VariableStore,\n): { ok: true; mode: 'selector' | 'js'; resolved: ResolvedParams } | { ok: false; error: string } {\n  const p = params as {\n    mode: 'selector' | 'js';\n    selector?: unknown;\n    attr?: unknown;\n    code?: string;\n    world?: BrowserWorld;\n    saveAs: string;\n  };\n\n  if (p.mode === 'selector') {\n    const selectorResult = tryResolveString(p.selector as string, vars);\n    if (!selectorResult.ok) return selectorResult;\n    const selector = selectorResult.value.trim();\n    if (!selector) return { ok: false, error: 'Empty selector' };\n\n    let attr = DEFAULT_EXTRACT_ATTR;\n    if (p.attr !== undefined && p.attr !== null) {\n      const attrResult = tryResolveString(p.attr as string, vars);\n      if (!attrResult.ok) return attrResult;\n      attr = attrResult.value.trim() || DEFAULT_EXTRACT_ATTR;\n    }\n\n    return {\n      ok: true,\n      mode: 'selector',\n      resolved: { selector, attr, saveAs: p.saveAs },\n    };\n  }\n\n  if (p.mode === 'js') {\n    if (!p.code || typeof p.code !== 'string') {\n      return { ok: false, error: 'JS mode requires code string' };\n    }\n    return {\n      ok: true,\n      mode: 'js',\n      resolved: { code: p.code, world: p.world, saveAs: p.saveAs },\n    };\n  }\n\n  return { ok: false, error: `Unknown extract mode: ${String(p.mode)}` };\n}\n\ntype ResolvedParams =\n  | { selector: string; attr: string; saveAs: string }\n  | { code: string; world?: BrowserWorld; saveAs: string };\n\nexport const extractHandler: ActionHandler<'extract'> = {\n  type: 'extract',\n\n  validate: (action) => {\n    const params = action.params as {\n      mode: string;\n      selector?: unknown;\n      code?: string;\n      saveAs?: string;\n    };\n\n    if (params.mode !== 'selector' && params.mode !== 'js') {\n      return invalid(`Invalid extract mode: ${String(params.mode)}`);\n    }\n\n    if (!params.saveAs || typeof params.saveAs !== 'string' || params.saveAs.trim().length === 0) {\n      return invalid('Extract action requires a non-empty saveAs variable name');\n    }\n\n    if (params.mode === 'selector' && params.selector === undefined) {\n      return invalid('Selector mode requires a selector');\n    }\n\n    if (params.mode === 'js' && (!params.code || typeof params.code !== 'string')) {\n      return invalid('JS mode requires a code string');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const params = action.params as { mode: string; saveAs?: string };\n    const varName = params.saveAs || '?';\n    return params.mode === 'js' ? `Extract JS → ${varName}` : `Extract → ${varName}`;\n  },\n\n  run: async (ctx, action) => {\n    const tabId = ctx.tabId;\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for extract action');\n    }\n\n    const resolved = resolveExtractParams(action.params, ctx.vars);\n    if (!resolved.ok) {\n      return failed('VALIDATION_ERROR', resolved.error);\n    }\n\n    const extractParams =\n      resolved.mode === 'selector'\n        ? {\n            selector: (resolved.resolved as { selector: string }).selector,\n            attr: (resolved.resolved as { attr: string }).attr,\n          }\n        : {\n            code: (resolved.resolved as { code: string }).code,\n            world: (resolved.resolved as { world?: BrowserWorld }).world,\n          };\n\n    const result = await executeExtraction(tabId, ctx.frameId, resolved.mode, extractParams);\n\n    if (!result.ok) {\n      return failed('SCRIPT_FAILED', result.error);\n    }\n\n    // Store in variables\n    const saveAs = (resolved.resolved as { saveAs: string }).saveAs;\n    ctx.vars[saveAs] = result.value;\n\n    return {\n      status: 'success',\n      output: { value: result.value },\n    };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/fill.ts",
    "content": "/**\n * Fill Action Handler\n *\n * Handles form input actions:\n * - Text input\n * - File upload\n * - Auto-scroll and focus\n * - Selector fallback with logging\n */\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok } from '../registry';\nimport type { ActionHandler } from '../types';\nimport {\n  ensureElementVisible,\n  logSelectorFallback,\n  resolveString,\n  selectorLocator,\n  sendMessageToTab,\n  toSelectorTarget,\n} from './common';\n\nexport const fillHandler: ActionHandler<'fill'> = {\n  type: 'fill',\n\n  validate: (action) => {\n    const target = action.params.target as { ref?: string; candidates?: unknown[] };\n    const hasRef = typeof target?.ref === 'string' && target.ref.trim().length > 0;\n    const hasCandidates = Array.isArray(target?.candidates) && target.candidates.length > 0;\n    const hasValue = action.params.value !== undefined;\n\n    if (!hasValue) {\n      return invalid('Missing value parameter');\n    }\n    if (!hasRef && !hasCandidates) {\n      return invalid('Missing target selector or ref');\n    }\n    return ok();\n  },\n\n  describe: (action) => {\n    const value = typeof action.params.value === 'string' ? action.params.value : '(dynamic)';\n    const displayValue = value.length > 20 ? value.slice(0, 20) + '...' : value;\n    return `Fill \"${displayValue}\"`;\n  },\n\n  run: async (ctx, action) => {\n    const vars = ctx.vars;\n    const tabId = ctx.tabId;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found');\n    }\n\n    // Ensure page is read before locating element\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n\n    // Resolve fill value\n    const valueResolved = resolveString(action.params.value, vars);\n    if (!valueResolved.ok) {\n      return failed('VALIDATION_ERROR', valueResolved.error);\n    }\n    const value = valueResolved.value;\n\n    // Locate target element\n    const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(\n      action.params.target,\n      vars,\n    );\n\n    const located = await selectorLocator.locate(tabId, selectorTarget, {\n      frameId: ctx.frameId,\n      preferRef: false,\n    });\n\n    const frameId = located?.frameId ?? ctx.frameId;\n    const refToUse = located?.ref ?? selectorTarget.ref;\n    const cssSelector = !located?.ref ? firstCssOrAttr : undefined;\n\n    if (!refToUse && !cssSelector) {\n      return failed('TARGET_NOT_FOUND', 'Could not locate target element');\n    }\n\n    // Verify element visibility if we have a ref\n    if (located?.ref) {\n      const isVisible = await ensureElementVisible(tabId, located.ref, frameId);\n      if (!isVisible) {\n        return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');\n      }\n    }\n\n    // Check for file input and handle file upload\n    // Use firstCssOrAttr to check input type even when ref is available\n    const selectorForTypeCheck = firstCssOrAttr || cssSelector;\n    if (selectorForTypeCheck) {\n      const attrResult = await sendMessageToTab<{ value?: string }>(\n        tabId,\n        { action: 'getAttributeForSelector', selector: selectorForTypeCheck, name: 'type' },\n        frameId,\n      );\n      const inputType = (attrResult.ok ? (attrResult.value?.value ?? '') : '').toLowerCase();\n\n      if (inputType === 'file') {\n        const uploadResult = await handleCallTool({\n          name: TOOL_NAMES.BROWSER.FILE_UPLOAD,\n          args: { selector: selectorForTypeCheck, filePath: value, tabId },\n        });\n\n        if ((uploadResult as { isError?: boolean })?.isError) {\n          const errorContent = (uploadResult as { content?: Array<{ text?: string }> })?.content;\n          const errorMsg = errorContent?.[0]?.text || 'File upload failed';\n          return failed('UNKNOWN', errorMsg);\n        }\n\n        // Log fallback if used\n        const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n        const fallbackUsed =\n          resolvedBy &&\n          firstCandidateType &&\n          resolvedBy !== 'ref' &&\n          resolvedBy !== firstCandidateType;\n        if (fallbackUsed) {\n          logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));\n        }\n\n        return { status: 'success' };\n      }\n    }\n\n    // Scroll element into view (best-effort)\n    if (cssSelector) {\n      try {\n        await handleCallTool({\n          name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n          args: {\n            type: 'MAIN',\n            jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});}}catch(e){}`,\n            tabId,\n          },\n        });\n      } catch {\n        // Ignore scroll errors\n      }\n    }\n\n    // Focus element (best-effort, ignore errors)\n    if (located?.ref) {\n      await sendMessageToTab(tabId, { action: 'focusByRef', ref: located.ref }, frameId);\n    } else if (cssSelector) {\n      await handleCallTool({\n        name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n        args: {\n          type: 'MAIN',\n          jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,\n          tabId,\n        },\n      });\n    }\n\n    // Execute fill\n    const fillResult = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.FILL,\n      args: {\n        ref: refToUse,\n        selector: cssSelector,\n        value,\n        frameId,\n        tabId,\n      },\n    });\n\n    if ((fillResult as { isError?: boolean })?.isError) {\n      const errorContent = (fillResult as { content?: Array<{ text?: string }> })?.content;\n      const errorMsg = errorContent?.[0]?.text || 'Fill action failed';\n      return failed('UNKNOWN', errorMsg);\n    }\n\n    // Log fallback if used\n    const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n    const fallbackUsed =\n      resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;\n\n    if (fallbackUsed) {\n      logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/http.ts",
    "content": "/**\n * HTTP Action Handler\n *\n * Makes HTTP requests from the extension context.\n * Supports:\n * - All common HTTP methods (GET, POST, PUT, PATCH, DELETE)\n * - JSON and text body types\n * - Form data\n * - Custom headers\n * - Response validation\n * - Result capture to variables\n */\n\nimport { failed, invalid, ok, tryResolveString, tryResolveValue } from '../registry';\nimport type {\n  ActionHandler,\n  Assignments,\n  HttpBody,\n  HttpHeaders,\n  HttpFormData,\n  HttpMethod,\n  HttpOkStatus,\n  HttpResponse,\n  JsonValue,\n  Resolvable,\n  VariableStore,\n} from '../types';\n\n/** Default timeout for HTTP requests */\nconst DEFAULT_HTTP_TIMEOUT_MS = 30000;\n\n/** Maximum URL length */\nconst MAX_URL_LENGTH = 8192;\n\n/**\n * Resolve HTTP headers\n */\nasync function resolveHeaders(\n  headers: HttpHeaders | undefined,\n  vars: VariableStore,\n): Promise<{ ok: true; resolved: Record<string, string> } | { ok: false; error: string }> {\n  if (!headers) return { ok: true, resolved: {} };\n\n  const resolved: Record<string, string> = {};\n  for (const [key, resolvable] of Object.entries(headers)) {\n    const result = tryResolveString(resolvable, vars);\n    if (!result.ok) {\n      return { ok: false, error: `Failed to resolve header \"${key}\": ${result.error}` };\n    }\n    resolved[key] = result.value;\n  }\n\n  return { ok: true, resolved };\n}\n\n/**\n * Resolve form data\n */\nasync function resolveFormData(\n  formData: HttpFormData | undefined,\n  vars: VariableStore,\n): Promise<{ ok: true; resolved: Record<string, string> } | { ok: false; error: string }> {\n  if (!formData) return { ok: true, resolved: {} };\n\n  const resolved: Record<string, string> = {};\n  for (const [key, resolvable] of Object.entries(formData)) {\n    const result = tryResolveString(resolvable, vars);\n    if (!result.ok) {\n      return { ok: false, error: `Failed to resolve form field \"${key}\": ${result.error}` };\n    }\n    resolved[key] = result.value;\n  }\n\n  return { ok: true, resolved };\n}\n\n/**\n * Resolve HTTP body\n */\nasync function resolveBody(\n  body: HttpBody | undefined,\n  vars: VariableStore,\n): Promise<\n  | { ok: true; contentType: string | undefined; data: string | undefined }\n  | { ok: false; error: string }\n> {\n  if (!body || body.kind === 'none') {\n    return { ok: true, contentType: undefined, data: undefined };\n  }\n\n  if (body.kind === 'text') {\n    const textResult = tryResolveString(body.text, vars);\n    if (!textResult.ok) {\n      return { ok: false, error: `Failed to resolve body text: ${textResult.error}` };\n    }\n\n    let contentType = 'text/plain';\n    if (body.contentType) {\n      const ctResult = tryResolveString(body.contentType, vars);\n      if (!ctResult.ok) {\n        return { ok: false, error: `Failed to resolve content type: ${ctResult.error}` };\n      }\n      contentType = ctResult.value;\n    }\n\n    return { ok: true, contentType, data: textResult.value };\n  }\n\n  if (body.kind === 'json') {\n    const jsonResult = tryResolveValue(body.json, vars);\n    if (!jsonResult.ok) {\n      return { ok: false, error: `Failed to resolve JSON body: ${jsonResult.error}` };\n    }\n\n    return {\n      ok: true,\n      contentType: 'application/json',\n      data: JSON.stringify(jsonResult.value),\n    };\n  }\n\n  return { ok: false, error: `Unknown body kind: ${(body as { kind: string }).kind}` };\n}\n\n/**\n * Check if status code is considered successful\n */\nfunction isStatusOk(status: number, okStatus: HttpOkStatus | undefined): boolean {\n  if (!okStatus) {\n    // Default: 2xx is OK\n    return status >= 200 && status < 300;\n  }\n\n  if (okStatus.kind === 'range') {\n    return status >= okStatus.min && status <= okStatus.max;\n  }\n\n  if (okStatus.kind === 'list') {\n    return okStatus.statuses.includes(status);\n  }\n\n  return false;\n}\n\n/**\n * Get value from result using dot/bracket path notation\n */\nfunction getValueByPath(obj: unknown, path: string): JsonValue | undefined {\n  if (!path || typeof obj !== 'object' || obj === null) {\n    return obj as JsonValue;\n  }\n\n  const segments: Array<string | number> = [];\n  const pathRegex = /([^.[\\]]+)|\\[(\\d+)\\]/g;\n  let match: RegExpExecArray | null;\n\n  while ((match = pathRegex.exec(path)) !== null) {\n    if (match[1]) {\n      segments.push(match[1]);\n    } else if (match[2]) {\n      segments.push(parseInt(match[2], 10));\n    }\n  }\n\n  let current: unknown = obj;\n  for (const segment of segments) {\n    if (current === null || current === undefined) return undefined;\n    if (typeof current !== 'object') return undefined;\n    current = (current as Record<string | number, unknown>)[segment];\n  }\n\n  return current as JsonValue;\n}\n\n/**\n * Apply assignments from response to variables\n */\nfunction applyAssignments(\n  response: HttpResponse,\n  assignments: Assignments,\n  vars: VariableStore,\n): void {\n  for (const [varName, path] of Object.entries(assignments)) {\n    const value = getValueByPath(response, path);\n    if (value !== undefined) {\n      vars[varName] = value;\n    }\n  }\n}\n\nexport const httpHandler: ActionHandler<'http'> = {\n  type: 'http',\n\n  validate: (action) => {\n    const params = action.params;\n\n    if (params.url === undefined) {\n      return invalid('HTTP action requires a URL');\n    }\n\n    if (params.method !== undefined) {\n      const validMethods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];\n      if (!validMethods.includes(params.method)) {\n        return invalid(`Invalid HTTP method: ${String(params.method)}`);\n      }\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const method = action.params.method || 'GET';\n    const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';\n    const displayUrl = url.length > 40 ? url.slice(0, 40) + '...' : url;\n    return `${method} ${displayUrl}`;\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n    const method: HttpMethod = params.method || 'GET';\n\n    // Resolve URL\n    const urlResult = tryResolveString(params.url, ctx.vars);\n    if (!urlResult.ok) {\n      return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);\n    }\n\n    const url = urlResult.value.trim();\n    if (!url) {\n      return failed('VALIDATION_ERROR', 'URL is empty');\n    }\n\n    if (url.length > MAX_URL_LENGTH) {\n      return failed('VALIDATION_ERROR', `URL exceeds maximum length of ${MAX_URL_LENGTH}`);\n    }\n\n    // Validate URL format\n    try {\n      new URL(url);\n    } catch {\n      return failed('VALIDATION_ERROR', `Invalid URL format: ${url}`);\n    }\n\n    // Resolve headers\n    const headersResult = await resolveHeaders(params.headers, ctx.vars);\n    if (!headersResult.ok) {\n      return failed('VALIDATION_ERROR', headersResult.error);\n    }\n\n    // Resolve body\n    const bodyResult = await resolveBody(params.body, ctx.vars);\n    if (!bodyResult.ok) {\n      return failed('VALIDATION_ERROR', bodyResult.error);\n    }\n\n    // Resolve form data (alternative to body)\n    const formDataResult = await resolveFormData(params.formData, ctx.vars);\n    if (!formDataResult.ok) {\n      return failed('VALIDATION_ERROR', formDataResult.error);\n    }\n\n    // Build request\n    const headers: Record<string, string> = { ...headersResult.resolved };\n    let requestBody: string | FormData | undefined;\n\n    if (Object.keys(formDataResult.resolved).length > 0) {\n      // Use form data\n      const formData = new FormData();\n      for (const [key, value] of Object.entries(formDataResult.resolved)) {\n        formData.append(key, value);\n      }\n      requestBody = formData as unknown as string; // FormData handled by fetch\n    } else if (bodyResult.data !== undefined) {\n      // Use body\n      requestBody = bodyResult.data;\n      if (bodyResult.contentType && !headers['Content-Type']) {\n        headers['Content-Type'] = bodyResult.contentType;\n      }\n    }\n\n    // Execute request\n    const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_HTTP_TIMEOUT_MS;\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n\n    try {\n      const fetchOptions: RequestInit = {\n        method,\n        headers,\n        signal: controller.signal,\n      };\n\n      if (requestBody !== undefined && method !== 'GET' && method !== 'DELETE') {\n        fetchOptions.body = requestBody;\n      }\n\n      const response = await fetch(url, fetchOptions);\n      clearTimeout(timeoutId);\n\n      // Parse response\n      const responseHeaders: Record<string, string> = {};\n      response.headers.forEach((value, key) => {\n        responseHeaders[key] = value;\n      });\n\n      let responseBody: JsonValue | string | null = null;\n      const contentType = response.headers.get('content-type') || '';\n\n      try {\n        if (contentType.includes('application/json')) {\n          responseBody = (await response.json()) as JsonValue;\n        } else {\n          responseBody = await response.text();\n        }\n      } catch {\n        responseBody = null;\n      }\n\n      const httpResponse: HttpResponse = {\n        url: response.url,\n        status: response.status,\n        headers: responseHeaders,\n        body: responseBody,\n      };\n\n      // Check status\n      if (!isStatusOk(response.status, params.okStatus)) {\n        return failed(\n          'NETWORK_REQUEST_FAILED',\n          `HTTP ${response.status}: ${response.statusText || 'Request failed'}`,\n        );\n      }\n\n      // Store response if saveAs specified\n      if (params.saveAs) {\n        ctx.vars[params.saveAs] = httpResponse as unknown as JsonValue;\n      }\n\n      // Apply assignments\n      if (params.assign) {\n        applyAssignments(httpResponse, params.assign, ctx.vars);\n      }\n\n      return {\n        status: 'success',\n        output: { response: httpResponse },\n      };\n    } catch (e) {\n      clearTimeout(timeoutId);\n\n      if (e instanceof Error && e.name === 'AbortError') {\n        return failed('TIMEOUT', `HTTP request timed out after ${timeoutMs}ms`);\n      }\n\n      return failed(\n        'NETWORK_REQUEST_FAILED',\n        `HTTP request failed: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/index.ts",
    "content": "/**\n * Action Handlers Registry\n *\n * Central registration point for all action handlers.\n * Provides factory function to create a fully-configured ActionRegistry\n * with all replay handlers registered.\n */\n\nimport { ActionRegistry, createActionRegistry } from '../registry';\nimport { assertHandler } from './assert';\nimport { clickHandler, dblclickHandler } from './click';\nimport { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';\nimport { delayHandler } from './delay';\nimport { setAttributeHandler, triggerEventHandler } from './dom';\nimport { dragHandler } from './drag';\nimport { extractHandler } from './extract';\nimport { fillHandler } from './fill';\nimport { httpHandler } from './http';\nimport { keyHandler } from './key';\nimport { navigateHandler } from './navigate';\nimport { screenshotHandler } from './screenshot';\nimport { scriptHandler } from './script';\nimport { scrollHandler } from './scroll';\nimport { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';\nimport { waitHandler } from './wait';\n\n// Re-export individual handlers for direct access\nexport { assertHandler } from './assert';\nexport { clickHandler, dblclickHandler } from './click';\nexport { foreachHandler, ifHandler, switchFrameHandler, whileHandler } from './control-flow';\nexport { delayHandler } from './delay';\nexport { setAttributeHandler, triggerEventHandler } from './dom';\nexport { dragHandler } from './drag';\nexport { extractHandler } from './extract';\nexport { fillHandler } from './fill';\nexport { httpHandler } from './http';\nexport { keyHandler } from './key';\nexport { navigateHandler } from './navigate';\nexport { screenshotHandler } from './screenshot';\nexport { scriptHandler } from './script';\nexport { scrollHandler } from './scroll';\nexport { closeTabHandler, handleDownloadHandler, openTabHandler, switchTabHandler } from './tabs';\nexport { waitHandler } from './wait';\n\n// Re-export common utilities\nexport * from './common';\n\n/**\n * All available action handlers for replay\n *\n * Organized by category:\n * - Navigation: navigate\n * - Interaction: click, dblclick, fill, key, scroll, drag\n * - Timing: wait, delay\n * - Validation: assert\n * - Data: extract, script, http, screenshot\n * - DOM Tools: triggerEvent, setAttribute\n * - Tabs: openTab, switchTab, closeTab, handleDownload\n * - Control Flow: if, foreach, while, switchFrame\n *\n * TODO: Add remaining handlers:\n * - loopElements, executeFlow (advanced control flow)\n */\nconst ALL_HANDLERS = [\n  // Navigation\n  navigateHandler,\n  // Interaction\n  clickHandler,\n  dblclickHandler,\n  fillHandler,\n  keyHandler,\n  scrollHandler,\n  dragHandler,\n  // Timing\n  waitHandler,\n  delayHandler,\n  // Validation\n  assertHandler,\n  // Data\n  extractHandler,\n  scriptHandler,\n  httpHandler,\n  screenshotHandler,\n  // DOM Tools\n  triggerEventHandler,\n  setAttributeHandler,\n  // Tabs\n  openTabHandler,\n  switchTabHandler,\n  closeTabHandler,\n  handleDownloadHandler,\n  // Control Flow\n  ifHandler,\n  foreachHandler,\n  whileHandler,\n  switchFrameHandler,\n] as const;\n\n/**\n * Register all replay handlers to an ActionRegistry instance\n */\nexport function registerReplayHandlers(registry: ActionRegistry): void {\n  // Register each handler individually to satisfy TypeScript's type checker\n  registry.register(navigateHandler, { override: true });\n  registry.register(clickHandler, { override: true });\n  registry.register(dblclickHandler, { override: true });\n  registry.register(fillHandler, { override: true });\n  registry.register(keyHandler, { override: true });\n  registry.register(scrollHandler, { override: true });\n  registry.register(dragHandler, { override: true });\n  registry.register(waitHandler, { override: true });\n  registry.register(delayHandler, { override: true });\n  registry.register(assertHandler, { override: true });\n  registry.register(extractHandler, { override: true });\n  registry.register(scriptHandler, { override: true });\n  registry.register(httpHandler, { override: true });\n  registry.register(screenshotHandler, { override: true });\n  registry.register(triggerEventHandler, { override: true });\n  registry.register(setAttributeHandler, { override: true });\n  registry.register(openTabHandler, { override: true });\n  registry.register(switchTabHandler, { override: true });\n  registry.register(closeTabHandler, { override: true });\n  registry.register(handleDownloadHandler, { override: true });\n  registry.register(ifHandler, { override: true });\n  registry.register(foreachHandler, { override: true });\n  registry.register(whileHandler, { override: true });\n  registry.register(switchFrameHandler, { override: true });\n}\n\n/**\n * Create a new ActionRegistry with all replay handlers registered\n *\n * This is the primary entry point for creating an action execution context.\n *\n * @example\n * ```ts\n * const registry = createReplayActionRegistry();\n *\n * const result = await registry.execute(ctx, {\n *   id: 'action-1',\n *   type: 'click',\n *   params: { target: { candidates: [...] } },\n * });\n * ```\n */\nexport function createReplayActionRegistry(): ActionRegistry {\n  const registry = createActionRegistry();\n  registerReplayHandlers(registry);\n  return registry;\n}\n\n/**\n * Get list of supported action types\n */\nexport function getSupportedActionTypes(): ReadonlyArray<string> {\n  return ALL_HANDLERS.map((h) => h.type);\n}\n\n/**\n * Check if an action type is supported\n */\nexport function isActionTypeSupported(type: string): boolean {\n  return ALL_HANDLERS.some((h) => h.type === type);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/key.ts",
    "content": "/**\n * Key Action Handler\n *\n * Handles keyboard input:\n * - Resolves key sequences via variables/templates\n * - Optionally focuses a target element before sending keys\n * - Dispatches keyboard events via the keyboard tool\n */\n\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok } from '../registry';\nimport type { ActionHandler, ElementTarget } from '../types';\nimport {\n  ensureElementVisible,\n  logSelectorFallback,\n  resolveString,\n  selectorLocator,\n  sendMessageToTab,\n  toSelectorTarget,\n} from './common';\n\n/** Extract error text from tool result */\nfunction extractToolError(result: unknown, fallback: string): string {\n  const content = (result as { content?: Array<{ text?: string }> })?.content;\n  return content?.find((c) => typeof c?.text === 'string')?.text || fallback;\n}\n\n/** Check if target has valid selector specification */\nfunction hasTargetSpec(target: unknown): boolean {\n  if (!target || typeof target !== 'object') return false;\n  const t = target as { ref?: unknown; candidates?: unknown };\n  const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;\n  const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;\n  return hasRef || hasCandidates;\n}\n\n/** Strip frame prefix from composite selector */\nfunction stripCompositeSelector(selector: string): string {\n  const raw = String(selector || '').trim();\n  if (!raw || !raw.includes('|>')) return raw;\n  const parts = raw\n    .split('|>')\n    .map((p) => p.trim())\n    .filter(Boolean);\n  return parts.length > 0 ? parts[parts.length - 1] : raw;\n}\n\nexport const keyHandler: ActionHandler<'key'> = {\n  type: 'key',\n\n  validate: (action) => {\n    if (action.params.keys === undefined) {\n      return invalid('Missing keys parameter');\n    }\n\n    if (action.params.target !== undefined && !hasTargetSpec(action.params.target)) {\n      return invalid('Target must include a non-empty ref or selector candidates');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const keys = typeof action.params.keys === 'string' ? action.params.keys : '(dynamic)';\n    const display = keys.length > 30 ? keys.slice(0, 30) + '...' : keys;\n    return `Keys \"${display}\"`;\n  },\n\n  run: async (ctx, action) => {\n    const vars = ctx.vars;\n    const tabId = ctx.tabId;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for key action');\n    }\n\n    // Resolve keys string\n    const keysResolved = resolveString(action.params.keys, vars);\n    if (!keysResolved.ok) {\n      return failed('VALIDATION_ERROR', keysResolved.error);\n    }\n\n    const keys = keysResolved.value.trim();\n    if (!keys) {\n      return failed('VALIDATION_ERROR', 'Keys string is empty');\n    }\n\n    let frameId = ctx.frameId;\n    let selectorForTool: string | undefined;\n    let firstCandidateType: string | undefined;\n    let resolvedBy: string | undefined;\n\n    // Handle optional target focusing\n    const target = action.params.target as ElementTarget | undefined;\n    if (target) {\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });\n\n      const {\n        selectorTarget,\n        firstCandidateType: firstType,\n        firstCssOrAttr,\n      } = toSelectorTarget(target, vars);\n      firstCandidateType = firstType;\n\n      const located = await selectorLocator.locate(tabId, selectorTarget, {\n        frameId: ctx.frameId,\n        preferRef: false,\n      });\n\n      frameId = located?.frameId ?? ctx.frameId;\n      const refToUse = located?.ref ?? selectorTarget.ref;\n\n      if (!refToUse && !firstCssOrAttr) {\n        return failed('TARGET_NOT_FOUND', 'Could not locate target element for key action');\n      }\n\n      resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n\n      // Only verify visibility for freshly located refs (not stale refs from payload)\n      if (located?.ref) {\n        const visible = await ensureElementVisible(tabId, located.ref, frameId);\n        if (!visible) {\n          return failed('ELEMENT_NOT_VISIBLE', 'Target element is not visible');\n        }\n\n        const focusResult = await sendMessageToTab<{ success?: boolean; error?: string }>(\n          tabId,\n          { action: 'focusByRef', ref: located.ref },\n          frameId,\n        );\n\n        if (!focusResult.ok || focusResult.value?.success !== true) {\n          const focusErr = focusResult.ok ? focusResult.value?.error : focusResult.error;\n\n          if (!firstCssOrAttr) {\n            return failed(\n              'TARGET_NOT_FOUND',\n              `Failed to focus target element: ${focusErr || 'ref may be stale'}`,\n            );\n          }\n\n          ctx.log(`focusByRef failed; falling back to selector: ${focusErr}`, 'warn');\n        }\n\n        // Try to resolve ref to CSS selector for tool\n        const resolved = await sendMessageToTab<{\n          success?: boolean;\n          selector?: string;\n          error?: string;\n        }>(tabId, { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: located.ref }, frameId);\n\n        if (\n          resolved.ok &&\n          resolved.value?.success !== false &&\n          typeof resolved.value?.selector === 'string'\n        ) {\n          const sel = resolved.value.selector.trim();\n          if (sel) selectorForTool = sel;\n        }\n      }\n\n      // Fallback to CSS/attr selector\n      if (!selectorForTool && firstCssOrAttr) {\n        const stripped = stripCompositeSelector(firstCssOrAttr);\n        if (stripped) selectorForTool = stripped;\n      }\n    }\n\n    // Execute keyboard input\n    const keyboardResult = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.KEYBOARD,\n      args: {\n        keys,\n        selector: selectorForTool,\n        selectorType: selectorForTool ? 'css' : undefined,\n        tabId,\n        frameId,\n      },\n    });\n\n    if ((keyboardResult as { isError?: boolean })?.isError) {\n      return failed('UNKNOWN', extractToolError(keyboardResult, 'Keyboard input failed'));\n    }\n\n    // Log fallback after successful execution\n    const fallbackUsed =\n      resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;\n    if (fallbackUsed) {\n      logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/navigate.ts",
    "content": "/**\n * Navigate Action Handler\n *\n * Handles page navigation actions:\n * - Navigate to URL\n * - Page refresh\n * - Wait for navigation completion\n */\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ENGINE_CONSTANTS } from '../../engine/constants';\nimport { ensureReadPageIfWeb, waitForNavigationDone } from '../../engine/policies/wait';\nimport { failed, invalid, ok } from '../registry';\nimport type { ActionHandler } from '../types';\nimport { clampInt, readTabUrl, resolveString } from './common';\n\nexport const navigateHandler: ActionHandler<'navigate'> = {\n  type: 'navigate',\n\n  validate: (action) => {\n    const hasRefresh = action.params.refresh === true;\n    const hasUrl = action.params.url !== undefined;\n    return hasRefresh || hasUrl ? ok() : invalid('Missing url or refresh parameter');\n  },\n\n  describe: (action) => {\n    if (action.params.refresh) return 'Refresh page';\n    const url = typeof action.params.url === 'string' ? action.params.url : '(dynamic)';\n    return `Navigate to ${url}`;\n  },\n\n  run: async (ctx, action) => {\n    const vars = ctx.vars;\n    const tabId = ctx.tabId;\n    // Check if StepRunner owns nav-wait (skip internal nav-wait logic)\n    const skipNavWait = ctx.execution?.skipNavWait === true;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found');\n    }\n\n    // Only read beforeUrl and calculate waitMs if we need to do nav-wait\n    const beforeUrl = skipNavWait ? '' : await readTabUrl(tabId);\n    const waitMs = skipNavWait\n      ? 0\n      : clampInt(\n          action.policy?.timeout?.ms ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,\n          0,\n          ENGINE_CONSTANTS.MAX_WAIT_MS,\n        );\n\n    // Handle page refresh\n    if (action.params.refresh) {\n      const result = await handleCallTool({\n        name: TOOL_NAMES.BROWSER.NAVIGATE,\n        args: { refresh: true, tabId },\n      });\n\n      if ((result as { isError?: boolean })?.isError) {\n        const errorContent = (result as { content?: Array<{ text?: string }> })?.content;\n        const errorMsg = errorContent?.[0]?.text || 'Page refresh failed';\n        return failed('NAVIGATION_FAILED', errorMsg);\n      }\n\n      // Skip nav-wait if StepRunner handles it\n      if (!skipNavWait) {\n        await waitForNavigationDone(beforeUrl, waitMs);\n        await ensureReadPageIfWeb();\n      }\n      return { status: 'success' };\n    }\n\n    // Handle URL navigation\n    const urlResolved = resolveString(action.params.url, vars);\n    if (!urlResolved.ok) {\n      return failed('VALIDATION_ERROR', urlResolved.error);\n    }\n\n    const url = urlResolved.value.trim();\n    if (!url) {\n      return failed('VALIDATION_ERROR', 'URL is empty');\n    }\n\n    const result = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.NAVIGATE,\n      args: { url, tabId },\n    });\n\n    if ((result as { isError?: boolean })?.isError) {\n      const errorContent = (result as { content?: Array<{ text?: string }> })?.content;\n      const errorMsg = errorContent?.[0]?.text || `Navigation to ${url} failed`;\n      return failed('NAVIGATION_FAILED', errorMsg);\n    }\n\n    // Skip nav-wait if StepRunner handles it\n    if (!skipNavWait) {\n      await waitForNavigationDone(beforeUrl, waitMs);\n      await ensureReadPageIfWeb();\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/screenshot.ts",
    "content": "/**\n * Screenshot Action Handler\n *\n * Captures screenshots and optionally stores base64 data in variables.\n * Supports full page, selector-based, and viewport screenshots.\n */\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok } from '../registry';\nimport type { ActionHandler } from '../types';\nimport { resolveString } from './common';\n\n/** Extract text content from tool result */\nfunction extractToolText(result: unknown): string | undefined {\n  const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;\n  const text = content?.find((c) => c?.type === 'text' && typeof c.text === 'string')?.text;\n  return typeof text === 'string' && text.trim() ? text : undefined;\n}\n\nexport const screenshotHandler: ActionHandler<'screenshot'> = {\n  type: 'screenshot',\n\n  validate: (action) => {\n    const saveAs = action.params.saveAs;\n    if (saveAs !== undefined && (!saveAs || String(saveAs).trim().length === 0)) {\n      return invalid('saveAs must be a non-empty variable name when provided');\n    }\n    return ok();\n  },\n\n  describe: (action) => {\n    if (action.params.fullPage) return 'Screenshot (full page)';\n    if (typeof action.params.selector === 'string') {\n      const sel =\n        action.params.selector.length > 30\n          ? action.params.selector.slice(0, 30) + '...'\n          : action.params.selector;\n      return `Screenshot: ${sel}`;\n    }\n    if (action.params.selector) return 'Screenshot (dynamic selector)';\n    return 'Screenshot';\n  },\n\n  run: async (ctx, action) => {\n    const tabId = ctx.tabId;\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for screenshot action');\n    }\n\n    // Resolve optional selector\n    let selector: string | undefined;\n    if (action.params.selector !== undefined) {\n      const resolved = resolveString(action.params.selector, ctx.vars);\n      if (!resolved.ok) return failed('VALIDATION_ERROR', resolved.error);\n      const s = resolved.value.trim();\n      if (s) selector = s;\n    }\n\n    // Call screenshot tool\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.SCREENSHOT,\n      args: {\n        name: 'workflow',\n        storeBase64: true,\n        fullPage: action.params.fullPage === true,\n        selector,\n        tabId,\n      },\n    });\n\n    if ((res as { isError?: boolean })?.isError) {\n      return failed('UNKNOWN', extractToolText(res) || 'Screenshot failed');\n    }\n\n    // Parse response\n    const text = extractToolText(res);\n    if (!text) {\n      return failed('UNKNOWN', 'Screenshot tool returned an empty response');\n    }\n\n    let payload: unknown;\n    try {\n      payload = JSON.parse(text);\n    } catch {\n      return failed('UNKNOWN', 'Screenshot tool returned invalid JSON');\n    }\n\n    const base64Data = (payload as { base64Data?: unknown })?.base64Data;\n    if (typeof base64Data !== 'string' || base64Data.length === 0) {\n      return failed('UNKNOWN', 'Screenshot tool returned empty base64Data');\n    }\n\n    // Store in variables if saveAs specified\n    if (action.params.saveAs) {\n      ctx.vars[action.params.saveAs] = base64Data;\n    }\n\n    return { status: 'success', output: { base64Data } };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/script.ts",
    "content": "/**\n * Script Action Handler\n *\n * Executes custom JavaScript in the page context.\n * Supports:\n * - MAIN or ISOLATED world execution\n * - Argument passing with variable resolution\n * - Result capture to variables\n * - Assignment mapping from result paths\n */\n\nimport { failed, invalid, ok, tryResolveValue } from '../registry';\nimport type {\n  ActionHandler,\n  Assignments,\n  BrowserWorld,\n  JsonValue,\n  Resolvable,\n  VariableStore,\n} from '../types';\n\n/** Maximum code length to prevent abuse */\nconst MAX_CODE_LENGTH = 100000;\n\n/**\n * Resolve script arguments\n */\nfunction resolveArgs(\n  args: Record<string, Resolvable<JsonValue>> | undefined,\n  vars: VariableStore,\n): { ok: true; resolved: Record<string, JsonValue> } | { ok: false; error: string } {\n  if (!args) return { ok: true, resolved: {} };\n\n  const resolved: Record<string, JsonValue> = {};\n  for (const [key, resolvable] of Object.entries(args)) {\n    const result = tryResolveValue(resolvable, vars);\n    if (!result.ok) {\n      return { ok: false, error: `Failed to resolve arg \"${key}\": ${result.error}` };\n    }\n    resolved[key] = result.value;\n  }\n\n  return { ok: true, resolved };\n}\n\n/**\n * Get value from result using dot/bracket path notation\n */\nfunction getValueByPath(obj: unknown, path: string): JsonValue | undefined {\n  if (!path || typeof obj !== 'object' || obj === null) {\n    return obj as JsonValue;\n  }\n\n  // Parse path: supports \"data.items[0].name\" style\n  const segments: Array<string | number> = [];\n  const pathRegex = /([^.[\\]]+)|\\[(\\d+)\\]/g;\n  let match: RegExpExecArray | null;\n\n  while ((match = pathRegex.exec(path)) !== null) {\n    if (match[1]) {\n      segments.push(match[1]);\n    } else if (match[2]) {\n      segments.push(parseInt(match[2], 10));\n    }\n  }\n\n  let current: unknown = obj;\n  for (const segment of segments) {\n    if (current === null || current === undefined) return undefined;\n    if (typeof current !== 'object') return undefined;\n    current = (current as Record<string | number, unknown>)[segment];\n  }\n\n  return current as JsonValue;\n}\n\n/**\n * Apply assignments from result to variables\n */\nfunction applyAssignments(result: JsonValue, assignments: Assignments, vars: VariableStore): void {\n  for (const [varName, path] of Object.entries(assignments)) {\n    const value = getValueByPath(result, path);\n    if (value !== undefined) {\n      vars[varName] = value;\n    }\n  }\n}\n\n/**\n * Execute script in page context\n */\nasync function executeScript(\n  tabId: number,\n  frameId: number | undefined,\n  code: string,\n  args: Record<string, JsonValue>,\n  world: BrowserWorld,\n): Promise<{ ok: true; result: JsonValue } | { ok: false; error: string }> {\n  const frameIds = typeof frameId === 'number' ? [frameId] : undefined;\n\n  try {\n    const injected = await chrome.scripting.executeScript({\n      target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n      world: world === 'ISOLATED' ? 'ISOLATED' : 'MAIN',\n      func: (scriptCode: string, scriptArgs: Record<string, JsonValue>) => {\n        try {\n          // Create function with args available\n          const argNames = Object.keys(scriptArgs);\n          const argValues = Object.values(scriptArgs);\n\n          // Wrap code to return result\n          const wrappedCode = `\n            return (function(${argNames.join(', ')}) {\n              ${scriptCode}\n            })(${argNames.map((_, i) => `arguments[${i}]`).join(', ')});\n          `;\n\n          const fn = new Function(...argNames, wrappedCode);\n          const result = fn(...argValues);\n\n          // Handle promises\n          if (result instanceof Promise) {\n            return result.then(\n              (value: unknown) => ({ success: true, result: value }),\n              (error: Error) => ({ success: false, error: error?.message || String(error) }),\n            );\n          }\n\n          return { success: true, result };\n        } catch (e) {\n          return { success: false, error: e instanceof Error ? e.message : String(e) };\n        }\n      },\n      args: [code, args],\n    });\n\n    const scriptResult = Array.isArray(injected) ? injected[0]?.result : undefined;\n\n    // Handle async result\n    if (scriptResult instanceof Promise) {\n      const asyncResult = await scriptResult;\n      if (!asyncResult || typeof asyncResult !== 'object') {\n        return { ok: false, error: 'Async script returned invalid result' };\n      }\n      if (!asyncResult.success) {\n        return { ok: false, error: asyncResult.error || 'Script failed' };\n      }\n      return { ok: true, result: asyncResult.result as JsonValue };\n    }\n\n    if (!scriptResult || typeof scriptResult !== 'object') {\n      return { ok: false, error: 'Script returned invalid result' };\n    }\n\n    const typedResult = scriptResult as { success: boolean; result?: unknown; error?: string };\n    if (!typedResult.success) {\n      return { ok: false, error: typedResult.error || 'Script failed' };\n    }\n\n    return { ok: true, result: typedResult.result as JsonValue };\n  } catch (e) {\n    return {\n      ok: false,\n      error: `Script execution failed: ${e instanceof Error ? e.message : String(e)}`,\n    };\n  }\n}\n\nexport const scriptHandler: ActionHandler<'script'> = {\n  type: 'script',\n\n  validate: (action) => {\n    const params = action.params;\n\n    if (!params.code || typeof params.code !== 'string') {\n      return invalid('Script action requires a code string');\n    }\n\n    if (params.code.length > MAX_CODE_LENGTH) {\n      return invalid(`Script code exceeds maximum length of ${MAX_CODE_LENGTH} characters`);\n    }\n\n    if (params.world !== undefined && params.world !== 'MAIN' && params.world !== 'ISOLATED') {\n      return invalid(`Invalid world: ${String(params.world)}`);\n    }\n\n    if (params.when !== undefined && params.when !== 'before' && params.when !== 'after') {\n      return invalid(`Invalid timing: ${String(params.when)}`);\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const world = action.params.world === 'ISOLATED' ? '[isolated]' : '';\n    const timing = action.params.when ? `(${action.params.when})` : '';\n    return `Script ${world}${timing}`.trim();\n  },\n\n  run: async (ctx, action) => {\n    const tabId = ctx.tabId;\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for script action');\n    }\n\n    const params = action.params;\n    const world: BrowserWorld = params.world || 'MAIN';\n\n    // Resolve arguments\n    const argsResult = resolveArgs(params.args, ctx.vars);\n    if (!argsResult.ok) {\n      return failed('VALIDATION_ERROR', argsResult.error);\n    }\n\n    // Execute script\n    const result = await executeScript(tabId, ctx.frameId, params.code, argsResult.resolved, world);\n\n    if (!result.ok) {\n      return failed('SCRIPT_FAILED', result.error);\n    }\n\n    // Store result if saveAs specified\n    if (params.saveAs) {\n      ctx.vars[params.saveAs] = result.result;\n    }\n\n    // Apply assignments if specified\n    if (params.assign) {\n      applyAssignments(result.result, params.assign, ctx.vars);\n    }\n\n    return {\n      status: 'success',\n      output: { result: result.result },\n    };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/scroll.ts",
    "content": "/**\n * Scroll Action Handler\n *\n * Supports three scroll modes:\n * - offset: Scroll the window to absolute coordinates\n * - element: Scroll an element into view\n * - container: Scroll within a container element\n */\n\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { failed, invalid, ok, tryResolveNumber } from '../registry';\nimport type { ActionHandler, ElementTarget } from '../types';\nimport { logSelectorFallback, selectorLocator, sendMessageToTab, toSelectorTarget } from './common';\n\n/** Check if target has valid selector specification */\nfunction hasTargetSpec(target: unknown): boolean {\n  if (!target || typeof target !== 'object') return false;\n  const t = target as { ref?: unknown; candidates?: unknown };\n  const hasRef = typeof t.ref === 'string' && t.ref.trim().length > 0;\n  const hasCandidates = Array.isArray(t.candidates) && t.candidates.length > 0;\n  return hasRef || hasCandidates;\n}\n\n/** Strip frame prefix from composite selector */\nfunction stripCompositeSelector(selector: string): string {\n  const raw = String(selector || '').trim();\n  if (!raw || !raw.includes('|>')) return raw;\n  const parts = raw\n    .split('|>')\n    .map((p) => p.trim())\n    .filter(Boolean);\n  return parts.length > 0 ? parts[parts.length - 1] : raw;\n}\n\n/** Format offset value for description */\nfunction describeOffset(v: unknown): string {\n  return typeof v === 'number' && Number.isFinite(v) ? String(v) : '(dynamic)';\n}\n\nexport const scrollHandler: ActionHandler<'scroll'> = {\n  type: 'scroll',\n\n  validate: (action) => {\n    const mode = action.params.mode;\n    if (mode !== 'offset' && mode !== 'element' && mode !== 'container') {\n      return invalid(`Unsupported scroll mode: ${String(mode)}`);\n    }\n\n    if ((mode === 'element' || mode === 'container') && !hasTargetSpec(action.params.target)) {\n      return invalid(`Scroll mode \"${mode}\" requires a target ref or selector candidates`);\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    const mode = action.params.mode;\n    if (mode === 'offset') {\n      const x = describeOffset(action.params.offset?.x);\n      const y = describeOffset(action.params.offset?.y);\n      return `Scroll window to x=${x}, y=${y}`;\n    }\n    if (mode === 'container') return 'Scroll container';\n    return 'Scroll to element';\n  },\n\n  run: async (ctx, action) => {\n    const vars = ctx.vars;\n    const tabId = ctx.tabId;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found for scroll action');\n    }\n\n    const mode = action.params.mode;\n\n    // ----------------------------\n    // Offset mode: window scroll\n    // ----------------------------\n    if (mode === 'offset') {\n      let top: number | undefined;\n      let left: number | undefined;\n\n      if (action.params.offset?.y !== undefined) {\n        const yResolved = tryResolveNumber(action.params.offset.y, vars);\n        if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error);\n        top = yResolved.value;\n      }\n\n      if (action.params.offset?.x !== undefined) {\n        const xResolved = tryResolveNumber(action.params.offset.x, vars);\n        if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error);\n        left = xResolved.value;\n      }\n\n      const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;\n\n      try {\n        const injected = await chrome.scripting.executeScript({\n          target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n          world: 'MAIN',\n          func: (t: number | null, l: number | null) => {\n            try {\n              const hasTop = typeof t === 'number' && Number.isFinite(t);\n              const hasLeft = typeof l === 'number' && Number.isFinite(l);\n              if (!hasTop && !hasLeft) return true;\n\n              window.scrollTo({\n                top: hasTop ? t : window.scrollY,\n                left: hasLeft ? l : window.scrollX,\n                behavior: 'auto',\n              });\n              return true;\n            } catch {\n              return false;\n            }\n          },\n          args: [top ?? null, left ?? null],\n        });\n\n        const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n        if (result !== true) {\n          return failed('SCRIPT_FAILED', 'Window scroll script returned failure');\n        }\n      } catch (e) {\n        return failed(\n          'SCRIPT_FAILED',\n          `Failed to scroll window: ${e instanceof Error ? e.message : String(e)}`,\n        );\n      }\n\n      return { status: 'success' };\n    }\n\n    // ----------------------------\n    // Element/Container mode\n    // ----------------------------\n    const target = action.params.target as ElementTarget | undefined;\n    if (!target) {\n      return failed('VALIDATION_ERROR', `Scroll mode \"${mode}\" requires a target`);\n    }\n\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: { tabId } });\n\n    const { selectorTarget, firstCandidateType, firstCssOrAttr } = toSelectorTarget(target, vars);\n    const located = await selectorLocator.locate(tabId, selectorTarget, {\n      frameId: ctx.frameId,\n      preferRef: false,\n    });\n\n    const frameId = located?.frameId ?? ctx.frameId;\n    const refToUse = located?.ref ?? selectorTarget.ref;\n\n    // Resolve selector from ref or fallback\n    let selector: string | undefined;\n    if (refToUse) {\n      const resolved = await sendMessageToTab<{ success?: boolean; selector?: string }>(\n        tabId,\n        { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: refToUse },\n        frameId,\n      );\n      if (\n        resolved.ok &&\n        resolved.value?.success !== false &&\n        typeof resolved.value?.selector === 'string'\n      ) {\n        const sel = resolved.value.selector.trim();\n        if (sel) selector = sel;\n      }\n    }\n\n    if (!selector && firstCssOrAttr) {\n      const stripped = stripCompositeSelector(firstCssOrAttr);\n      if (stripped) selector = stripped;\n    }\n\n    if (!selector) {\n      return failed('TARGET_NOT_FOUND', 'Could not resolve a CSS selector for the scroll target');\n    }\n\n    // Resolve offset for container mode\n    let scrollTop: number | undefined;\n    let scrollLeft: number | undefined;\n    if (mode === 'container') {\n      if (action.params.offset?.y !== undefined) {\n        const yResolved = tryResolveNumber(action.params.offset.y, vars);\n        if (!yResolved.ok) return failed('VALIDATION_ERROR', yResolved.error);\n        scrollTop = yResolved.value;\n      }\n\n      if (action.params.offset?.x !== undefined) {\n        const xResolved = tryResolveNumber(action.params.offset.x, vars);\n        if (!xResolved.ok) return failed('VALIDATION_ERROR', xResolved.error);\n        scrollLeft = xResolved.value;\n      }\n    }\n\n    // Execute scroll script\n    try {\n      const frameIds = typeof frameId === 'number' ? [frameId] : undefined;\n      const injected = await chrome.scripting.executeScript({\n        target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n        world: 'MAIN',\n        func: (\n          sel: string,\n          scrollMode: 'element' | 'container',\n          top: number | null,\n          left: number | null,\n        ) => {\n          const el = document.querySelector(sel) as HTMLElement | null;\n          if (!el) return false;\n\n          if (scrollMode === 'element') {\n            el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });\n            return true;\n          }\n\n          // Container scroll\n          const hasTop = typeof top === 'number' && Number.isFinite(top);\n          const hasLeft = typeof left === 'number' && Number.isFinite(left);\n\n          if (typeof el.scrollTo === 'function') {\n            el.scrollTo({\n              top: hasTop ? top : el.scrollTop,\n              left: hasLeft ? left : el.scrollLeft,\n              behavior: 'instant',\n            });\n          } else {\n            if (hasTop) el.scrollTop = top;\n            if (hasLeft) el.scrollLeft = left;\n          }\n          return true;\n        },\n        args: [selector, mode, scrollTop ?? null, scrollLeft ?? null],\n      });\n\n      const result = Array.isArray(injected) ? injected[0]?.result : undefined;\n      if (result !== true) {\n        return failed('TARGET_NOT_FOUND', `Scroll target not found: ${selector}`);\n      }\n    } catch (e) {\n      return failed(\n        'SCRIPT_FAILED',\n        `Failed to execute scroll: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n\n    // Log fallback if used\n    const resolvedBy = located?.resolvedBy || (located?.ref ? 'ref' : '');\n    const fallbackUsed =\n      resolvedBy && firstCandidateType && resolvedBy !== 'ref' && resolvedBy !== firstCandidateType;\n    if (fallbackUsed) {\n      logSelectorFallback(ctx, action.id, String(firstCandidateType), String(resolvedBy));\n    }\n\n    return { status: 'success' };\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/tabs.ts",
    "content": "/**\n * Tab Management Action Handlers\n *\n * Handles browser tab operations:\n * - openTab: Open a new tab or window\n * - switchTab: Switch to a different tab\n * - closeTab: Close tab(s)\n * - handleDownload: Monitor and capture download information\n */\n\nimport { failed, invalid, ok, tryResolveString } from '../registry';\nimport type { ActionHandler, DownloadInfo, DownloadState, VariableStore } from '../types';\n\n/** Default timeout for tab operations */\nconst DEFAULT_TAB_TIMEOUT_MS = 10000;\n\n/** Default timeout for download operations */\nconst DEFAULT_DOWNLOAD_TIMEOUT_MS = 60000;\n\n// ================================\n// openTab Handler\n// ================================\n\nexport const openTabHandler: ActionHandler<'openTab'> = {\n  type: 'openTab',\n\n  validate: () => ok(),\n\n  describe: (action) => {\n    const url = typeof action.params.url === 'string' ? action.params.url : undefined;\n    const displayUrl = url ? (url.length > 30 ? url.slice(0, 30) + '...' : url) : 'blank';\n    return action.params.newWindow ? `Open window: ${displayUrl}` : `Open tab: ${displayUrl}`;\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    // Resolve URL if provided\n    let url: string | undefined;\n    if (params.url !== undefined) {\n      const urlResult = tryResolveString(params.url, ctx.vars);\n      if (!urlResult.ok) {\n        return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);\n      }\n      url = urlResult.value.trim() || undefined;\n    }\n\n    try {\n      let tabId: number;\n\n      if (params.newWindow) {\n        // Create new window\n        const window = await chrome.windows.create({\n          url: url || 'about:blank',\n          focused: true,\n        });\n\n        const tab = window?.tabs?.[0];\n        if (!tab?.id) {\n          return failed('TAB_NOT_FOUND', 'Failed to create new window');\n        }\n        tabId = tab.id;\n      } else {\n        // Create new tab in current window\n        const tab = await chrome.tabs.create({\n          url: url || 'about:blank',\n          active: true,\n        });\n\n        if (!tab.id) {\n          return failed('TAB_NOT_FOUND', 'Failed to create new tab');\n        }\n        tabId = tab.id;\n      }\n\n      // Wait for tab to be ready if URL was specified\n      if (url) {\n        await waitForTabComplete(tabId, DEFAULT_TAB_TIMEOUT_MS);\n      }\n\n      // Return newTabId for ctx.tabId sync\n      return { status: 'success', newTabId: tabId };\n    } catch (e) {\n      return failed('UNKNOWN', `Failed to open tab: ${e instanceof Error ? e.message : String(e)}`);\n    }\n  },\n};\n\n// ================================\n// switchTab Handler\n// ================================\n\nexport const switchTabHandler: ActionHandler<'switchTab'> = {\n  type: 'switchTab',\n\n  validate: (action) => {\n    const params = action.params;\n    const hasTabId = params.tabId !== undefined;\n    const hasUrlContains = params.urlContains !== undefined;\n    const hasTitleContains = params.titleContains !== undefined;\n\n    if (!hasTabId && !hasUrlContains && !hasTitleContains) {\n      return invalid('switchTab requires tabId, urlContains, or titleContains');\n    }\n\n    return ok();\n  },\n\n  describe: (action) => {\n    if (action.params.tabId !== undefined) {\n      return `Switch to tab #${action.params.tabId}`;\n    }\n    if (action.params.urlContains !== undefined) {\n      return `Switch tab (URL contains)`;\n    }\n    if (action.params.titleContains !== undefined) {\n      return `Switch tab (title contains)`;\n    }\n    return 'Switch tab';\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    try {\n      let targetTabId: number | undefined;\n\n      if (params.tabId !== undefined) {\n        targetTabId = params.tabId;\n      } else {\n        // Find tab by URL or title\n        const tabs = await chrome.tabs.query({});\n\n        if (params.urlContains !== undefined) {\n          const urlResult = tryResolveString(params.urlContains, ctx.vars);\n          if (!urlResult.ok) {\n            return failed('VALIDATION_ERROR', `Failed to resolve urlContains: ${urlResult.error}`);\n          }\n          const urlPattern = urlResult.value.trim().toLowerCase();\n\n          // Empty pattern is invalid\n          if (!urlPattern) {\n            return failed('VALIDATION_ERROR', 'urlContains pattern cannot be empty');\n          }\n\n          const matchingTab = tabs.find(\n            (tab) => tab.url && tab.url.toLowerCase().includes(urlPattern),\n          );\n          targetTabId = matchingTab?.id;\n        } else if (params.titleContains !== undefined) {\n          const titleResult = tryResolveString(params.titleContains, ctx.vars);\n          if (!titleResult.ok) {\n            return failed(\n              'VALIDATION_ERROR',\n              `Failed to resolve titleContains: ${titleResult.error}`,\n            );\n          }\n          const titlePattern = titleResult.value.trim().toLowerCase();\n\n          // Empty pattern is invalid\n          if (!titlePattern) {\n            return failed('VALIDATION_ERROR', 'titleContains pattern cannot be empty');\n          }\n\n          const matchingTab = tabs.find(\n            (tab) => tab.title && tab.title.toLowerCase().includes(titlePattern),\n          );\n          targetTabId = matchingTab?.id;\n        }\n      }\n\n      if (targetTabId === undefined) {\n        return failed('TAB_NOT_FOUND', 'No matching tab found');\n      }\n\n      // Activate the tab\n      await chrome.tabs.update(targetTabId, { active: true });\n\n      // Focus the window containing the tab\n      const tab = await chrome.tabs.get(targetTabId);\n      if (tab.windowId) {\n        await chrome.windows.update(tab.windowId, { focused: true });\n      }\n\n      // Return newTabId for ctx.tabId sync\n      return { status: 'success', newTabId: targetTabId };\n    } catch (e) {\n      return failed(\n        'UNKNOWN',\n        `Failed to switch tab: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n  },\n};\n\n// ================================\n// closeTab Handler\n// ================================\n\nexport const closeTabHandler: ActionHandler<'closeTab'> = {\n  type: 'closeTab',\n\n  validate: () => ok(),\n\n  describe: (action) => {\n    if (action.params.tabIds && action.params.tabIds.length > 0) {\n      return `Close ${action.params.tabIds.length} tab(s)`;\n    }\n    if (action.params.url !== undefined) {\n      return 'Close tab (by URL)';\n    }\n    return 'Close current tab';\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n\n    try {\n      let tabIds: number[] = [];\n\n      if (params.tabIds && params.tabIds.length > 0) {\n        // Close specific tabs\n        tabIds = [...params.tabIds];\n      } else if (params.url !== undefined) {\n        // Find and close tabs by URL\n        const urlResult = tryResolveString(params.url, ctx.vars);\n        if (!urlResult.ok) {\n          return failed('VALIDATION_ERROR', `Failed to resolve URL: ${urlResult.error}`);\n        }\n        const urlPattern = urlResult.value.trim().toLowerCase();\n\n        // Empty pattern is invalid\n        if (!urlPattern) {\n          return failed('VALIDATION_ERROR', 'URL pattern cannot be empty');\n        }\n\n        const tabs = await chrome.tabs.query({});\n        tabIds = tabs\n          .filter((tab) => tab.url && tab.url.toLowerCase().includes(urlPattern) && tab.id)\n          .map((tab) => tab.id!);\n      } else {\n        // Close current tab\n        if (typeof ctx.tabId === 'number') {\n          tabIds = [ctx.tabId];\n        }\n      }\n\n      if (tabIds.length === 0) {\n        return failed('TAB_NOT_FOUND', 'No tabs to close');\n      }\n\n      await chrome.tabs.remove(tabIds);\n      return { status: 'success' };\n    } catch (e) {\n      return failed(\n        'UNKNOWN',\n        `Failed to close tab: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n  },\n};\n\n// ================================\n// handleDownload Handler\n// ================================\n\nexport const handleDownloadHandler: ActionHandler<'handleDownload'> = {\n  type: 'handleDownload',\n\n  validate: () => ok(),\n\n  describe: (action) => {\n    if (action.params.filenameContains !== undefined) {\n      return 'Handle download (by filename)';\n    }\n    return 'Handle download';\n  },\n\n  run: async (ctx, action) => {\n    const params = action.params;\n    const timeoutMs = action.policy?.timeout?.ms ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;\n    const waitForComplete = params.waitForComplete !== false;\n\n    // Resolve filename pattern if provided\n    let filenamePattern: string | undefined;\n    if (params.filenameContains !== undefined) {\n      const result = tryResolveString(params.filenameContains, ctx.vars);\n      if (!result.ok) {\n        return failed('VALIDATION_ERROR', `Failed to resolve filenameContains: ${result.error}`);\n      }\n      filenamePattern = result.value.toLowerCase();\n    }\n\n    return new Promise((resolve) => {\n      const startTime = Date.now();\n      let downloadId: number | undefined;\n      let downloadInfo: DownloadInfo | undefined;\n      let resolved = false;\n\n      const cleanup = () => {\n        chrome.downloads.onCreated.removeListener(onCreated);\n        chrome.downloads.onChanged.removeListener(onChanged);\n      };\n\n      const finish = (result: Awaited<ReturnType<ActionHandler<'handleDownload'>['run']>>) => {\n        if (!resolved) {\n          resolved = true;\n          cleanup();\n          resolve(result);\n        }\n      };\n\n      const onCreated = (item: chrome.downloads.DownloadItem) => {\n        // Check if this download matches our criteria\n        if (filenamePattern) {\n          const filename = item.filename.toLowerCase();\n          if (!filename.includes(filenamePattern)) return;\n        }\n\n        downloadId = item.id;\n        downloadInfo = {\n          id: String(item.id),\n          filename: item.filename,\n          url: item.url,\n          state: item.state as DownloadState,\n          size: item.totalBytes > 0 ? item.totalBytes : undefined,\n        };\n\n        if (!waitForComplete || item.state === 'complete') {\n          storeAndFinish();\n        }\n      };\n\n      const onChanged = (delta: chrome.downloads.DownloadDelta) => {\n        if (delta.id !== downloadId) return;\n\n        if (delta.state) {\n          if (downloadInfo) {\n            downloadInfo.state = delta.state.current as DownloadState;\n          }\n\n          if (delta.state.current === 'complete') {\n            storeAndFinish();\n          } else if (delta.state.current === 'interrupted') {\n            finish(failed('DOWNLOAD_FAILED', 'Download was interrupted'));\n          }\n        }\n\n        if (delta.filename && downloadInfo) {\n          downloadInfo.filename = delta.filename.current || downloadInfo.filename;\n        }\n\n        if (delta.totalBytes && downloadInfo && delta.totalBytes.current) {\n          downloadInfo.size = delta.totalBytes.current;\n        }\n      };\n\n      const storeAndFinish = () => {\n        if (params.saveAs && downloadInfo) {\n          ctx.vars[params.saveAs] = downloadInfo as unknown as VariableStore[string];\n        }\n        finish({\n          status: 'success',\n          output: downloadInfo ? { download: downloadInfo } : undefined,\n        });\n      };\n\n      // Set up listeners\n      chrome.downloads.onCreated.addListener(onCreated);\n      chrome.downloads.onChanged.addListener(onChanged);\n\n      // Set up timeout\n      const checkTimeout = () => {\n        if (resolved) return;\n        if (Date.now() - startTime > timeoutMs) {\n          finish(failed('TIMEOUT', `Download timeout after ${timeoutMs}ms`));\n        } else {\n          setTimeout(checkTimeout, 500);\n        }\n      };\n      setTimeout(checkTimeout, 500);\n    });\n  },\n};\n\n// ================================\n// Helper Functions\n// ================================\n\n/**\n * Wait for a tab to complete loading\n */\nasync function waitForTabComplete(tabId: number, timeoutMs: number): Promise<void> {\n  const startTime = Date.now();\n\n  return new Promise((resolve, reject) => {\n    const checkStatus = async () => {\n      try {\n        const tab = await chrome.tabs.get(tabId);\n\n        if (tab.status === 'complete') {\n          resolve();\n          return;\n        }\n\n        if (Date.now() - startTime > timeoutMs) {\n          reject(new Error(`Tab load timeout after ${timeoutMs}ms`));\n          return;\n        }\n\n        setTimeout(checkStatus, 100);\n      } catch (e) {\n        reject(e);\n      }\n    };\n\n    checkStatus();\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/handlers/wait.ts",
    "content": "/**\n * Wait Action Handler\n *\n * Handles various wait conditions:\n * - Sleep (fixed delay)\n * - Network idle\n * - Navigation complete\n * - Text appears/disappears\n * - Selector visible/hidden\n */\n\nimport { ENGINE_CONSTANTS } from '../../engine/constants';\nimport { waitForNavigation, waitForNetworkIdle } from '../../rr-utils';\nimport { failed, invalid, ok, tryResolveNumber } from '../registry';\nimport type { ActionHandler } from '../types';\nimport { clampInt, resolveString, sendMessageToTab } from './common';\n\nexport const waitHandler: ActionHandler<'wait'> = {\n  type: 'wait',\n\n  validate: (action) => {\n    const condition = action.params.condition;\n    if (!condition || typeof condition !== 'object') {\n      return invalid('Missing condition parameter');\n    }\n    if (!('kind' in condition)) {\n      return invalid('Condition must have a kind property');\n    }\n    return ok();\n  },\n\n  describe: (action) => {\n    const condition = action.params.condition;\n    if (!condition) return 'Wait';\n\n    switch (condition.kind) {\n      case 'sleep': {\n        const ms = typeof condition.sleep === 'number' ? condition.sleep : '(dynamic)';\n        return `Wait ${ms}ms`;\n      }\n      case 'networkIdle':\n        return 'Wait for network idle';\n      case 'navigation':\n        return 'Wait for navigation';\n      case 'text': {\n        const appear = condition.appear !== false;\n        const text = typeof condition.text === 'string' ? condition.text : '(dynamic)';\n        const displayText = text.length > 20 ? text.slice(0, 20) + '...' : text;\n        return `Wait for text \"${displayText}\" to ${appear ? 'appear' : 'disappear'}`;\n      }\n      case 'selector': {\n        const visible = condition.visible !== false;\n        return `Wait for selector to be ${visible ? 'visible' : 'hidden'}`;\n      }\n      default:\n        return 'Wait';\n    }\n  },\n\n  run: async (ctx, action) => {\n    const vars = ctx.vars;\n    const tabId = ctx.tabId;\n\n    if (typeof tabId !== 'number') {\n      return failed('TAB_NOT_FOUND', 'No active tab found');\n    }\n\n    const timeoutMs = action.policy?.timeout?.ms;\n    const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;\n    const condition = action.params.condition;\n\n    // Handle sleep condition\n    if (condition.kind === 'sleep') {\n      const msResolved = tryResolveNumber(condition.sleep, vars);\n      if (!msResolved.ok) {\n        return failed('VALIDATION_ERROR', msResolved.error);\n      }\n      const ms = Math.max(0, Number(msResolved.value ?? 0));\n      await new Promise((resolve) => setTimeout(resolve, ms));\n      return { status: 'success' };\n    }\n\n    // Handle network idle condition\n    if (condition.kind === 'networkIdle') {\n      const totalMs = clampInt(timeoutMs ?? 5000, 1000, ENGINE_CONSTANTS.MAX_WAIT_MS);\n      let idleMs: number;\n\n      if (condition.idleMs !== undefined) {\n        const idleResolved = tryResolveNumber(condition.idleMs, vars);\n        idleMs = idleResolved.ok\n          ? clampInt(idleResolved.value, 200, 5000)\n          : Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));\n      } else {\n        idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));\n      }\n\n      await waitForNetworkIdle(totalMs, idleMs);\n      return { status: 'success' };\n    }\n\n    // Handle navigation condition\n    if (condition.kind === 'navigation') {\n      const timeout = timeoutMs === undefined ? undefined : Math.max(0, Number(timeoutMs));\n      await waitForNavigation(timeout);\n      return { status: 'success' };\n    }\n\n    // Handle text condition\n    if (condition.kind === 'text') {\n      const textResolved = resolveString(condition.text, vars);\n      if (!textResolved.ok) {\n        return failed('VALIDATION_ERROR', textResolved.error);\n      }\n\n      const appear = condition.appear !== false;\n      const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);\n\n      // Inject wait helper script\n      try {\n        await chrome.scripting.executeScript({\n          target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n          files: ['inject-scripts/wait-helper.js'],\n          world: 'ISOLATED',\n        });\n      } catch (e) {\n        return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);\n      }\n\n      // Execute wait for text\n      const response = await sendMessageToTab<{ success?: boolean }>(\n        tabId,\n        { action: 'waitForText', text: textResolved.value, appear, timeout },\n        ctx.frameId,\n      );\n\n      if (!response.ok) {\n        return failed('TIMEOUT', `Wait for text failed: ${response.error}`);\n      }\n      if (response.value?.success !== true) {\n        return failed(\n          'TIMEOUT',\n          `Text \"${textResolved.value}\" did not ${appear ? 'appear' : 'disappear'} within timeout`,\n        );\n      }\n\n      return { status: 'success' };\n    }\n\n    // Handle selector condition\n    if (condition.kind === 'selector') {\n      const selectorResolved = resolveString(condition.selector, vars);\n      if (!selectorResolved.ok) {\n        return failed('VALIDATION_ERROR', selectorResolved.error);\n      }\n\n      const visible = condition.visible !== false;\n      const timeout = clampInt(timeoutMs ?? 10000, 0, ENGINE_CONSTANTS.MAX_WAIT_MS);\n\n      // Inject wait helper script\n      try {\n        await chrome.scripting.executeScript({\n          target: { tabId, frameIds } as chrome.scripting.InjectionTarget,\n          files: ['inject-scripts/wait-helper.js'],\n          world: 'ISOLATED',\n        });\n      } catch (e) {\n        return failed('SCRIPT_FAILED', `Failed to inject wait helper: ${(e as Error).message}`);\n      }\n\n      // Execute wait for selector\n      const response = await sendMessageToTab<{ success?: boolean }>(\n        tabId,\n        { action: 'waitForSelector', selector: selectorResolved.value, visible, timeout },\n        ctx.frameId,\n      );\n\n      if (!response.ok) {\n        return failed('TIMEOUT', `Wait for selector failed: ${response.error}`);\n      }\n      if (response.value?.success !== true) {\n        return failed(\n          'TIMEOUT',\n          `Selector \"${selectorResolved.value}\" did not become ${visible ? 'visible' : 'hidden'} within timeout`,\n        );\n      }\n\n      return { status: 'success' };\n    }\n\n    return failed(\n      'VALIDATION_ERROR',\n      `Unsupported wait condition kind: ${(condition as { kind: string }).kind}`,\n    );\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/index.ts",
    "content": "/**\n * Action System - 导出模块\n */\n\n// 类型导出\nexport * from './types';\n\n// 注册表导出\nexport {\n  ActionRegistry,\n  createActionRegistry,\n  ok,\n  invalid,\n  failed,\n  tryResolveString,\n  tryResolveNumber,\n  tryResolveJson,\n  tryResolveValue,\n  type BeforeExecuteArgs,\n  type BeforeExecuteHook,\n  type AfterExecuteArgs,\n  type AfterExecuteHook,\n  type ActionRegistryHooks,\n} from './registry';\n\n// 适配器导出\nexport {\n  execCtxToActionCtx,\n  stepToAction,\n  actionResultToExecResult,\n  createStepExecutor,\n  isActionSupported,\n  getActionType,\n  type StepExecutionAttempt,\n} from './adapter';\n\n// Handler 工厂导出\nexport {\n  createReplayActionRegistry,\n  registerReplayHandlers,\n  getSupportedActionTypes,\n  isActionTypeSupported,\n} from './handlers';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/registry.ts",
    "content": "/**\n * Action Registry - Action 执行器注册表和执行管道\n *\n * 特性：\n * - 动态注册/注销 handler\n * - 中间件/钩子机制 (beforeExecute, afterExecute)\n * - 重试和超时策略\n * - 类型安全\n */\n\nimport type {\n  Action,\n  ActionError,\n  ActionErrorCode,\n  ActionExecutionContext,\n  ActionExecutionResult,\n  ActionHandler,\n  EdgeLabel,\n  ElementTarget,\n  ExecutableAction,\n  ExecutableActionType,\n  FrameTarget,\n  JsonValue,\n  NonEmptyArray,\n  Resolvable,\n  RetryPolicy,\n  SelectorCandidate,\n  TimeoutPolicy,\n  ValidationResult,\n  VariablePathSegment,\n  VariablePointer,\n  VariableStore,\n} from './types';\n\n// ================================\n// 类型定义\n// ================================\n\ntype AnyExecutableAction = {\n  [T in ExecutableActionType]: ExecutableAction<T>;\n}[ExecutableActionType];\ntype AnyExecutableHandler = { [T in ExecutableActionType]: ActionHandler<T> }[ExecutableActionType];\n\nexport interface BeforeExecuteArgs<T extends ExecutableActionType> {\n  ctx: ActionExecutionContext;\n  action: ExecutableAction<T>;\n  handler: ActionHandler<T>;\n  attempt: number;\n}\n\nexport type BeforeExecuteHook = <T extends ExecutableActionType>(\n  args: BeforeExecuteArgs<T>,\n) => void | ActionExecutionResult<T> | Promise<void | ActionExecutionResult<T>>;\n\nexport interface AfterExecuteArgs<T extends ExecutableActionType> {\n  ctx: ActionExecutionContext;\n  action: ExecutableAction<T>;\n  handler: ActionHandler<T>;\n  result: ActionExecutionResult<T>;\n  attempt: number;\n}\n\nexport type AfterExecuteHook = <T extends ExecutableActionType>(\n  args: AfterExecuteArgs<T>,\n) => void | ActionExecutionResult<T> | Promise<void | ActionExecutionResult<T>>;\n\nexport interface ActionRegistryHooks {\n  beforeExecute?: BeforeExecuteHook;\n  afterExecute?: AfterExecuteHook;\n}\n\n// ================================\n// 工具函数\n// ================================\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\nfunction toNonEmptyArray(value: string[], fallback: string): NonEmptyArray<string> {\n  return (value.length > 0 ? value : [fallback]) as NonEmptyArray<string>;\n}\n\nconst ACTION_ERROR_CODES: ReadonlyArray<ActionErrorCode> = [\n  'VALIDATION_ERROR',\n  'TIMEOUT',\n  'TAB_NOT_FOUND',\n  'FRAME_NOT_FOUND',\n  'TARGET_NOT_FOUND',\n  'ELEMENT_NOT_VISIBLE',\n  'NAVIGATION_FAILED',\n  'NETWORK_REQUEST_FAILED',\n  'DOWNLOAD_FAILED',\n  'ASSERTION_FAILED',\n  'SCRIPT_FAILED',\n  'UNKNOWN',\n] as const;\n\nfunction isActionErrorCode(value: unknown): value is ActionErrorCode {\n  return typeof value === 'string' && (ACTION_ERROR_CODES as ReadonlyArray<string>).includes(value);\n}\n\nfunction toErrorMessage(e: unknown): string {\n  if (e instanceof Error) return e.message;\n  if (typeof e === 'string') return e;\n  if (isRecord(e) && typeof e.message === 'string') return e.message;\n  return 'Unknown error';\n}\n\nfunction toActionError(e: unknown, fallbackCode: ActionErrorCode = 'UNKNOWN'): ActionError {\n  if (isRecord(e) && isActionErrorCode(e.code) && typeof e.message === 'string') {\n    return { code: e.code, message: e.message, data: undefined };\n  }\n  return { code: fallbackCode, message: toErrorMessage(e) };\n}\n\nexport function ok(): ValidationResult {\n  return { ok: true };\n}\n\nexport function invalid(...errors: string[]): ValidationResult {\n  return { ok: false, errors: toNonEmptyArray(errors.filter(Boolean), 'Validation failed') };\n}\n\nexport function failed<T extends ExecutableActionType>(\n  code: ActionErrorCode,\n  message: string,\n): ActionExecutionResult<T> {\n  return { status: 'failed', error: { code, message } };\n}\n\nfunction sleep(ms: number): Promise<void> {\n  const safe = Math.max(0, Math.floor(ms));\n  return new Promise((resolve) => setTimeout(resolve, safe));\n}\n\n// ================================\n// Resolvable 解析器\n// ================================\n\nfunction isVariablePointer(value: unknown): value is VariablePointer {\n  if (!isRecord(value)) return false;\n  if (typeof value.name !== 'string' || value.name.length === 0) return false;\n  if (value.path === undefined) return true;\n  if (!Array.isArray(value.path)) return false;\n  return value.path.every((s) => typeof s === 'string' || typeof s === 'number');\n}\n\nfunction isVarValue(\n  value: unknown,\n): value is { kind: 'var'; ref: VariablePointer; default?: unknown } {\n  if (!isRecord(value)) return false;\n  if (value.kind !== 'var') return false;\n  return isVariablePointer(value.ref);\n}\n\nfunction isExprValue(value: unknown): value is { kind: 'expr'; default?: unknown } {\n  if (!isRecord(value)) return false;\n  if (value.kind !== 'expr') return false;\n  return 'expr' in value;\n}\n\nfunction isStringTemplate(value: unknown): value is { kind: 'template'; parts: unknown[] } {\n  if (!isRecord(value)) return false;\n  if (value.kind !== 'template') return false;\n  return Array.isArray(value.parts) && value.parts.length > 0;\n}\n\nfunction readByPath(\n  value: JsonValue,\n  path?: ReadonlyArray<VariablePathSegment>,\n): JsonValue | undefined {\n  if (!path || path.length === 0) return value;\n  let cur: JsonValue | undefined = value;\n  for (const seg of path) {\n    if (cur === undefined || cur === null) return undefined;\n    if (typeof seg === 'number') {\n      if (!Array.isArray(cur)) return undefined;\n      cur = cur[seg] as JsonValue | undefined;\n      continue;\n    }\n    if (typeof seg === 'string') {\n      if (!isRecord(cur)) return undefined;\n      cur = (cur as Record<string, unknown>)[seg] as JsonValue | undefined;\n      continue;\n    }\n    return undefined;\n  }\n  return cur;\n}\n\nexport function tryResolveJson(\n  value: Resolvable<JsonValue>,\n  vars: VariableStore,\n): { ok: true; value: JsonValue } | { ok: false; error: string } {\n  if (isVarValue(value)) {\n    const ref = value.ref;\n    const root = vars[ref.name];\n    const resolved = root === undefined ? undefined : readByPath(root, ref.path);\n    if (resolved !== undefined) return { ok: true, value: resolved };\n    if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue };\n    return { ok: true, value: null };\n  }\n  if (isExprValue(value)) {\n    if ('default' in value) return { ok: true, value: (value.default ?? null) as JsonValue };\n    return { ok: false, error: 'Expression value is not supported by the default resolver' };\n  }\n  return { ok: true, value };\n}\n\nfunction formatInserted(value: JsonValue, format?: 'text' | 'json' | 'urlEncoded'): string {\n  if (format === 'json') return JSON.stringify(value);\n  const text = value === null ? '' : typeof value === 'string' ? value : String(value);\n  if (format === 'urlEncoded') return encodeURIComponent(text);\n  return text;\n}\n\nexport function tryResolveString(\n  value: Resolvable<string>,\n  vars: VariableStore,\n): { ok: true; value: string } | { ok: false; error: string } {\n  if (typeof value === 'string') return { ok: true, value };\n  if (isVarValue(value)) {\n    const ref = value.ref;\n    const root = vars[ref.name];\n    const resolved = root === undefined ? undefined : readByPath(root, ref.path);\n    if (resolved !== undefined && resolved !== null) return { ok: true, value: String(resolved) };\n    if ('default' in value && typeof value.default === 'string')\n      return { ok: true, value: value.default };\n    return { ok: true, value: '' };\n  }\n  if (isStringTemplate(value)) {\n    const parts = value.parts;\n    let out = '';\n    for (const p of parts) {\n      if (!isRecord(p) || typeof p.kind !== 'string')\n        return { ok: false, error: 'Invalid template part' };\n      if (p.kind === 'text') {\n        if (typeof p.value !== 'string') return { ok: false, error: 'Invalid template text part' };\n        out += p.value;\n        continue;\n      }\n      if (p.kind === 'insert') {\n        const resolved = tryResolveJson(p.value as Resolvable<JsonValue>, vars);\n        if (!resolved.ok) return { ok: false, error: resolved.error };\n        out += formatInserted(\n          resolved.value,\n          (p.format as 'text' | 'json' | 'urlEncoded' | undefined) ?? 'text',\n        );\n        continue;\n      }\n      return {\n        ok: false,\n        error: `Unknown template part kind: ${String((p as { kind: string }).kind)}`,\n      };\n    }\n    return { ok: true, value: out };\n  }\n  if (isExprValue(value)) {\n    if ('default' in value && typeof value.default === 'string')\n      return { ok: true, value: value.default };\n    return { ok: false, error: 'Expression value is not supported by the default resolver' };\n  }\n  return { ok: false, error: 'Unsupported resolvable string value' };\n}\n\nexport function tryResolveNumber(\n  value: Resolvable<number>,\n  vars: VariableStore,\n): { ok: true; value: number } | { ok: false; error: string } {\n  if (typeof value === 'number' && Number.isFinite(value)) return { ok: true, value };\n  if (isVarValue(value)) {\n    const ref = value.ref;\n    const root = vars[ref.name];\n    const resolved = root === undefined ? undefined : readByPath(root, ref.path);\n    if (typeof resolved === 'number' && Number.isFinite(resolved))\n      return { ok: true, value: resolved };\n    if (typeof resolved === 'string' && resolved.trim() !== '') {\n      const n = Number(resolved);\n      if (Number.isFinite(n)) return { ok: true, value: n };\n    }\n    if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default))\n      return { ok: true, value: value.default };\n    return { ok: false, error: `Variable \"${ref.name}\" is not a finite number` };\n  }\n  if (isExprValue(value)) {\n    if ('default' in value && typeof value.default === 'number' && Number.isFinite(value.default))\n      return { ok: true, value: value.default };\n    return { ok: false, error: 'Expression value is not supported by the default resolver' };\n  }\n  return { ok: false, error: 'Unsupported resolvable number value' };\n}\n\n/**\n * Resolve a generic JSON value (alias for tryResolveJson)\n * Useful for script/http handlers that work with arbitrary JSON\n */\nexport const tryResolveValue = tryResolveJson;\n\n// ================================\n// 重试和超时逻辑\n// ================================\n\nfunction shouldRetry(policy: RetryPolicy | undefined, error: ActionError | undefined): boolean {\n  if (!policy) return false;\n  if (policy.retries <= 0) return false;\n  if (!error) return false;\n  if (error.code === 'VALIDATION_ERROR') return false;\n  if (policy.retryOn && policy.retryOn.length > 0) return policy.retryOn.includes(error.code);\n  return true;\n}\n\nfunction computeRetryDelayMs(policy: RetryPolicy, retryIndex: number): number {\n  const base = Math.max(0, Math.floor(policy.intervalMs));\n  const backoff = policy.backoff ?? 'none';\n\n  let delay = base;\n  if (backoff === 'linear') delay = base * (retryIndex + 1);\n  if (backoff === 'exp') delay = base * Math.pow(2, retryIndex);\n\n  const capped =\n    policy.maxIntervalMs !== undefined ? Math.min(delay, Math.max(0, policy.maxIntervalMs)) : delay;\n  if ((policy.jitter ?? 'none') === 'full') return Math.floor(Math.random() * capped);\n  return capped;\n}\n\nasync function runWithTimeout<T>(\n  run: () => Promise<T>,\n  timeoutMs: number | undefined,\n): Promise<{ ok: true; value: T } | { ok: false; error: ActionError }> {\n  if (timeoutMs === undefined) {\n    try {\n      return { ok: true, value: await run() };\n    } catch (e) {\n      return { ok: false, error: toActionError(e) };\n    }\n  }\n\n  const ms = Math.max(0, Math.floor(timeoutMs));\n  if (ms === 0) return { ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } };\n\n  return await new Promise((resolve) => {\n    const timer: ReturnType<typeof setTimeout> = setTimeout(() => {\n      resolve({ ok: false, error: { code: 'TIMEOUT', message: 'Timeout reached' } });\n    }, ms);\n\n    run()\n      .then((value) => {\n        clearTimeout(timer);\n        resolve({ ok: true, value });\n      })\n      .catch((e) => {\n        clearTimeout(timer);\n        resolve({ ok: false, error: toActionError(e) });\n      });\n  });\n}\n\n// ================================\n// ActionRegistry 类\n// ================================\n\nexport class ActionRegistry {\n  private readonly handlers: { [T in ExecutableActionType]?: ActionHandler<T> } = {};\n  private readonly beforeHooks: BeforeExecuteHook[] = [];\n  private readonly afterHooks: AfterExecuteHook[] = [];\n\n  /**\n   * 注册 action handler\n   */\n  register<T extends ExecutableActionType>(\n    handler: ActionHandler<T>,\n    options?: { override?: boolean },\n  ): void {\n    const override = options?.override !== false;\n    const existing = this.handlers[handler.type];\n    if (existing && !override) {\n      throw new Error(`Handler already registered for type: ${handler.type}`);\n    }\n    // Type assertion needed due to TypeScript mapped type limitation\n\n    (this.handlers as Record<ExecutableActionType, ActionHandler<any>>)[handler.type] = handler;\n  }\n\n  /**\n   * 注销 action handler\n   */\n  unregister<T extends ExecutableActionType>(type: T): boolean {\n    const exists = this.handlers[type] !== undefined;\n    delete this.handlers[type];\n    return exists;\n  }\n\n  /**\n   * 获取 handler\n   */\n  get<T extends ExecutableActionType>(type: T): ActionHandler<T> | undefined {\n    return this.handlers[type];\n  }\n\n  /**\n   * 检查是否存在 handler\n   */\n  has(type: ExecutableActionType): boolean {\n    return this.handlers[type] !== undefined;\n  }\n\n  /**\n   * 列出所有已注册的 handler\n   */\n  list(): ReadonlyArray<AnyExecutableHandler> {\n    const arr = Object.values(this.handlers).filter(\n      (h): h is AnyExecutableHandler => h !== undefined,\n    );\n    return arr;\n  }\n\n  /**\n   * 注册 beforeExecute 钩子\n   */\n  onBeforeExecute(hook: BeforeExecuteHook): () => void {\n    this.beforeHooks.push(hook);\n    return () => {\n      const idx = this.beforeHooks.indexOf(hook);\n      if (idx >= 0) this.beforeHooks.splice(idx, 1);\n    };\n  }\n\n  /**\n   * 注册 afterExecute 钩子\n   */\n  onAfterExecute(hook: AfterExecuteHook): () => void {\n    this.afterHooks.push(hook);\n    return () => {\n      const idx = this.afterHooks.indexOf(hook);\n      if (idx >= 0) this.afterHooks.splice(idx, 1);\n    };\n  }\n\n  /**\n   * 批量注册钩子\n   */\n  use(hooks: ActionRegistryHooks): () => void {\n    const disposers: Array<() => void> = [];\n    if (hooks.beforeExecute) disposers.push(this.onBeforeExecute(hooks.beforeExecute));\n    if (hooks.afterExecute) disposers.push(this.onAfterExecute(hooks.afterExecute));\n    return () => {\n      for (const d of disposers) d();\n    };\n  }\n\n  /**\n   * 验证 action 配置\n   */\n  validate<T extends ExecutableActionType>(action: ExecutableAction<T>): ValidationResult {\n    const handler = this.get(action.type);\n    if (!handler) return invalid(`Unsupported action type: ${String(action.type)}`);\n    if (!handler.validate) return ok();\n    return handler.validate(action);\n  }\n\n  /**\n   * 执行 action\n   */\n  async execute<T extends ExecutableActionType>(\n    ctx: ActionExecutionContext,\n    action: ExecutableAction<T>,\n  ): Promise<ActionExecutionResult<T>> {\n    const startedAt = Date.now();\n\n    // 跳过禁用的 action\n    if (action.disabled) {\n      return { status: 'skipped', durationMs: Date.now() - startedAt };\n    }\n\n    // 获取 handler\n    const handler = this.get(action.type);\n    if (!handler) {\n      return {\n        status: 'failed',\n        error: {\n          code: 'VALIDATION_ERROR',\n          message: `Unsupported action type: ${String(action.type)}`,\n        },\n        durationMs: Date.now() - startedAt,\n      };\n    }\n\n    // 验证\n    const v = this.validate(action);\n    if (!v.ok) {\n      let result: ActionExecutionResult<T> = {\n        status: 'failed',\n        error: { code: 'VALIDATION_ERROR', message: v.errors.join(', ') },\n      };\n\n      // 调用 afterExecute 钩子\n      for (const hook of this.afterHooks) {\n        try {\n          const maybe = await hook({ ctx, action, handler, result, attempt: 0 });\n          if (maybe) result = maybe;\n        } catch (e) {\n          try {\n            ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn');\n          } catch {\n            // ignore\n          }\n        }\n      }\n\n      result.durationMs = Date.now() - startedAt;\n      return result;\n    }\n\n    // 计算重试和超时参数\n    const retryPolicy = action.policy?.retry;\n    const timeoutPolicy = action.policy?.timeout;\n    const maxAttempts = 1 + Math.max(0, Math.floor(retryPolicy?.retries ?? 0));\n\n    const actionDeadline =\n      timeoutPolicy && timeoutPolicy.ms > 0 && (timeoutPolicy.scope ?? 'attempt') === 'action'\n        ? startedAt + timeoutPolicy.ms\n        : undefined;\n\n    const remainingActionMs = () =>\n      actionDeadline === undefined ? undefined : Math.max(0, actionDeadline - Date.now());\n\n    let last: ActionExecutionResult<T> | undefined;\n\n    // 执行循环（支持重试）\n    for (let attempt = 0; attempt < maxAttempts; attempt++) {\n      const attemptTimeoutMs: number | undefined = (() => {\n        if (!timeoutPolicy || timeoutPolicy.ms <= 0) return undefined;\n        const scope = timeoutPolicy.scope ?? 'attempt';\n        if (scope === 'attempt') return timeoutPolicy.ms;\n        return remainingActionMs();\n      })();\n\n      if (attemptTimeoutMs !== undefined && attemptTimeoutMs <= 0) {\n        last = failed<T>('TIMEOUT', 'Timeout reached');\n        break;\n      }\n\n      // beforeExecute 钩子（可以短路）\n      let shortCircuited: ActionExecutionResult<T> | undefined;\n      for (const hook of this.beforeHooks) {\n        try {\n          const maybe = await hook({ ctx, action, handler, attempt });\n          if (maybe) {\n            shortCircuited = maybe;\n            break;\n          }\n        } catch (e) {\n          try {\n            ctx.log(`beforeExecute hook failed: ${toErrorMessage(e)}`, 'warn');\n          } catch {\n            // ignore\n          }\n        }\n      }\n\n      // 执行 handler\n      const runOutcome =\n        shortCircuited ??\n        (await (async () => {\n          const out = await runWithTimeout(() => handler.run(ctx, action), attemptTimeoutMs);\n          if (!out.ok) return failed<T>(out.error.code, out.error.message);\n\n          const result = out.value ?? ({} as ActionExecutionResult<T>);\n          if (result.status === 'failed' && !result.error) {\n            return { ...result, error: { code: 'UNKNOWN' as const, message: 'Action failed' } };\n          }\n          return result;\n        })());\n\n      let result: ActionExecutionResult<T> = runOutcome;\n\n      // afterExecute 钩子（可以替换结果）\n      for (const hook of this.afterHooks) {\n        try {\n          const maybe = await hook({ ctx, action, handler, result, attempt });\n          if (maybe) result = maybe;\n        } catch (e) {\n          try {\n            ctx.log(`afterExecute hook failed: ${toErrorMessage(e)}`, 'warn');\n          } catch {\n            // ignore\n          }\n        }\n      }\n\n      last = result;\n\n      // 成功则退出\n      if (result.status !== 'failed') break;\n\n      // 判断是否重试\n      const canRetry = attempt < maxAttempts - 1 && shouldRetry(retryPolicy, result.error);\n      if (!canRetry) break;\n\n      const delay = computeRetryDelayMs(retryPolicy!, attempt);\n      if (\n        actionDeadline !== undefined &&\n        remainingActionMs() !== undefined &&\n        (remainingActionMs() as number) < delay\n      ) {\n        break;\n      }\n\n      try {\n        ctx.log(`Retrying action \"${action.type}\" (attempt ${attempt + 1}/${maxAttempts})`, 'warn');\n      } catch {\n        // ignore\n      }\n\n      if (delay > 0) await sleep(delay);\n    }\n\n    const finalResult: ActionExecutionResult<T> =\n      last ??\n      ({\n        status: 'failed',\n        error: { code: 'UNKNOWN', message: 'Action execution produced no result' },\n      } as ActionExecutionResult<T>);\n\n    finalResult.durationMs = Date.now() - startedAt;\n    return finalResult;\n  }\n}\n\n// ================================\n// 导出工厂函数\n// ================================\n\n/**\n * 创建默认的 ActionRegistry 实例\n */\nexport function createActionRegistry(): ActionRegistry {\n  return new ActionRegistry();\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/actions/types.ts",
    "content": "/**\n * Action Type System for Record & Replay\n * 商业级录制回放的核心类型定义\n *\n * 设计原则：\n * - 类型安全，无 any\n * - 支持所有操作类型\n * - 支持重试、超时、错误处理策略\n * - 支持选择器候选列表和稳定性评分\n * - 支持变量系统\n * - 符合 SOLID 原则（接口可通过声明合并扩展）\n */\n\n// ================================\n// 基础类型\n// ================================\n\nexport type Milliseconds = number;\nexport type ISODateTimeString = string;\nexport type NonEmptyArray<T> = [T, ...T[]];\n\n// JSON 类型\nexport type JsonPrimitive = string | number | boolean | null;\nexport type JsonValue = JsonPrimitive | JsonObject | JsonArray;\nexport interface JsonObject {\n  [key: string]: JsonValue;\n}\nexport type JsonArray = JsonValue[];\n\n// ID 类型\nexport type FlowId = string;\nexport type ActionId = string;\nexport type SubflowId = string;\nexport type EdgeId = string;\nexport type VariableName = string;\n\n// ================================\n// Edge Labels\n// ================================\n\nexport const EDGE_LABELS = {\n  DEFAULT: 'default',\n  TRUE: 'true',\n  FALSE: 'false',\n  ON_ERROR: 'onError',\n} as const;\n\nexport type BuiltinEdgeLabel = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];\nexport type EdgeLabel = string;\n\n// ================================\n// 错误处理\n// ================================\n\nexport type ActionErrorCode =\n  | 'VALIDATION_ERROR'\n  | 'TIMEOUT'\n  | 'TAB_NOT_FOUND'\n  | 'FRAME_NOT_FOUND'\n  | 'TARGET_NOT_FOUND'\n  | 'ELEMENT_NOT_VISIBLE'\n  | 'NAVIGATION_FAILED'\n  | 'NETWORK_REQUEST_FAILED'\n  | 'DOWNLOAD_FAILED'\n  | 'ASSERTION_FAILED'\n  | 'SCRIPT_FAILED'\n  | 'UNKNOWN';\n\nexport interface ActionError {\n  code: ActionErrorCode;\n  message: string;\n  data?: JsonValue;\n}\n\n// ================================\n// 执行策略\n// ================================\n\nexport interface TimeoutPolicy {\n  ms: Milliseconds;\n  /** 'attempt' = 每次尝试独立计时, 'action' = 整个 action 总计时 */\n  scope?: 'attempt' | 'action';\n}\n\nexport type BackoffKind = 'none' | 'exp' | 'linear';\n\nexport interface RetryPolicy {\n  /** 重试次数（不含首次尝试） */\n  retries: number;\n  /** 重试间隔 */\n  intervalMs: Milliseconds;\n  /** 退避策略 */\n  backoff?: BackoffKind;\n  /** 最大间隔（用于 exp/linear） */\n  maxIntervalMs?: Milliseconds;\n  /** 抖动策略 */\n  jitter?: 'none' | 'full';\n  /** 仅在这些错误码时重试 */\n  retryOn?: ReadonlyArray<ActionErrorCode>;\n}\n\nexport type ErrorHandlingStrategy =\n  | { kind: 'stop' }\n  | { kind: 'continue'; level?: 'warning' | 'error' }\n  | { kind: 'goto'; label: EdgeLabel };\n\nexport interface ArtifactCapturePolicy {\n  screenshot?: 'never' | 'onFailure' | 'always';\n  saveScreenshotAs?: VariableName;\n  includeConsole?: boolean;\n  includeNetwork?: boolean;\n}\n\nexport interface ActionPolicy {\n  timeout?: TimeoutPolicy;\n  retry?: RetryPolicy;\n  onError?: ErrorHandlingStrategy;\n  artifacts?: ArtifactCapturePolicy;\n}\n\n// ================================\n// 变量系统\n// ================================\n\nexport interface VariableDefinitionBase {\n  name: VariableName;\n  label?: string;\n  description?: string;\n  sensitive?: boolean;\n  required?: boolean;\n}\n\nexport interface VariableStringRules {\n  pattern?: string;\n  minLength?: number;\n  maxLength?: number;\n}\n\nexport interface VariableNumberRules {\n  min?: number;\n  max?: number;\n  integer?: boolean;\n}\n\nexport type VariableDefinition =\n  | (VariableDefinitionBase & {\n      kind: 'string';\n      default?: string;\n      rules?: VariableStringRules;\n    })\n  | (VariableDefinitionBase & {\n      kind: 'number';\n      default?: number;\n      rules?: VariableNumberRules;\n    })\n  | (VariableDefinitionBase & {\n      kind: 'boolean';\n      default?: boolean;\n    })\n  | (VariableDefinitionBase & {\n      kind: 'enum';\n      options: NonEmptyArray<string>;\n      default?: string;\n    })\n  | (VariableDefinitionBase & {\n      kind: 'array';\n      item: 'string' | 'number' | 'boolean' | 'json';\n      default?: JsonValue[];\n    })\n  | (VariableDefinitionBase & {\n      kind: 'json';\n      default?: JsonValue;\n    });\n\nexport type VariableStore = Record<VariableName, JsonValue>;\n\nexport type VariableScope = 'flow' | 'run' | 'env' | 'secret';\nexport type VariablePathSegment = string | number;\n\nexport interface VariablePointer {\n  scope?: VariableScope;\n  name: VariableName;\n  path?: ReadonlyArray<VariablePathSegment>;\n}\n\n// ================================\n// 表达式和模板\n// ================================\n\nexport type ExpressionLanguage = 'js' | 'rr';\n\nexport interface Expression<_T = JsonValue> {\n  language: ExpressionLanguage;\n  code: string;\n}\n\nexport interface VariableValue<T> {\n  kind: 'var';\n  ref: VariablePointer;\n  default?: T;\n}\n\nexport interface ExpressionValue<T> {\n  kind: 'expr';\n  expr: Expression<T>;\n  default?: T;\n}\n\nexport type TemplateFormat = 'text' | 'json' | 'urlEncoded';\n\nexport type TemplatePart =\n  | { kind: 'text'; value: string }\n  | { kind: 'insert'; value: Resolvable<JsonValue>; format?: TemplateFormat };\n\nexport interface StringTemplate {\n  kind: 'template';\n  parts: NonEmptyArray<TemplatePart>;\n}\n\nexport type Resolvable<T> =\n  | T\n  | VariableValue<T>\n  | ExpressionValue<T>\n  | ([T] extends [string] ? StringTemplate : never);\n\nexport type DataPath = string; // dot/bracket path: e.g. \"data.items[0].id\"\nexport type Assignments = Record<VariableName, DataPath>;\n\n// ================================\n// 条件表达式\n// ================================\n\nexport type CompareOp =\n  | 'eq'\n  | 'eqi'\n  | 'neq'\n  | 'gt'\n  | 'gte'\n  | 'lt'\n  | 'lte'\n  | 'contains'\n  | 'containsI'\n  | 'notContains'\n  | 'notContainsI'\n  | 'startsWith'\n  | 'endsWith'\n  | 'regex';\n\nexport type Condition =\n  | { kind: 'expr'; expr: Expression<boolean> }\n  | {\n      kind: 'compare';\n      left: Resolvable<JsonValue>;\n      op: CompareOp;\n      right: Resolvable<JsonValue>;\n    }\n  | { kind: 'truthy'; value: Resolvable<JsonValue> }\n  | { kind: 'falsy'; value: Resolvable<JsonValue> }\n  | { kind: 'not'; condition: Condition }\n  | { kind: 'and'; conditions: NonEmptyArray<Condition> }\n  | { kind: 'or'; conditions: NonEmptyArray<Condition> };\n\n// ================================\n// 选择器系统\n// ================================\n\nexport type SelectorCandidateSource = 'recorded' | 'user' | 'generated';\n\nexport interface SelectorStability {\n  /** 稳定性评分 0-1 */\n  score: number;\n  signals?: {\n    usesId?: boolean;\n    usesAria?: boolean;\n    usesText?: boolean;\n    usesNthOfType?: boolean;\n    usesAttributes?: boolean;\n    usesClass?: boolean;\n  };\n  note?: string;\n}\n\nexport interface SelectorCandidateBase {\n  weight?: number;\n  stability?: SelectorStability;\n  source?: SelectorCandidateSource;\n}\n\nexport type SelectorCandidate =\n  | (SelectorCandidateBase & { type: 'css'; selector: Resolvable<string> })\n  | (SelectorCandidateBase & { type: 'xpath'; xpath: Resolvable<string> })\n  | (SelectorCandidateBase & { type: 'attr'; selector: Resolvable<string> })\n  | (SelectorCandidateBase & {\n      type: 'aria';\n      role?: Resolvable<string>;\n      name?: Resolvable<string>;\n    })\n  | (SelectorCandidateBase & {\n      type: 'text';\n      text: Resolvable<string>;\n      tagNameHint?: string;\n      match?: 'exact' | 'contains';\n    });\n\nexport type FrameTarget =\n  | { kind: 'top' }\n  | { kind: 'index'; index: Resolvable<number> }\n  | { kind: 'urlContains'; value: Resolvable<string> };\n\nexport interface TargetHint {\n  tagName?: string;\n  role?: string;\n  name?: string;\n  text?: string;\n}\n\nexport interface ElementTargetBase {\n  frame?: FrameTarget;\n  hint?: TargetHint;\n}\n\nexport type ElementTarget =\n  | (ElementTargetBase & {\n      /** 临时引用（快速路径） */\n      ref: string;\n      candidates?: ReadonlyArray<SelectorCandidate>;\n    })\n  | (ElementTargetBase & {\n      ref?: string;\n      candidates: NonEmptyArray<SelectorCandidate>;\n    });\n\n// ================================\n// Action 参数定义\n// ================================\n\nexport type BrowserWorld = 'MAIN' | 'ISOLATED';\n\n// --- 页面交互 ---\n\nexport interface ClickParams {\n  target: ElementTarget;\n  button?: 'left' | 'middle' | 'right';\n  before?: { scrollIntoView?: boolean; waitForSelector?: boolean };\n  after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean };\n}\n\nexport interface FillParams {\n  target: ElementTarget;\n  value: Resolvable<string>;\n  clearFirst?: boolean;\n  mode?: 'replace' | 'append';\n}\n\nexport interface KeyParams {\n  keys: Resolvable<string>; // e.g. \"Backspace Enter\" or \"cmd+a\"\n  target?: ElementTarget;\n}\n\nexport type ScrollMode = 'element' | 'offset' | 'container';\n\nexport interface ScrollOffset {\n  x?: Resolvable<number>;\n  y?: Resolvable<number>;\n}\n\nexport interface ScrollParams {\n  mode: ScrollMode;\n  target?: ElementTarget;\n  offset?: ScrollOffset;\n}\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport interface DragParams {\n  start: ElementTarget;\n  end: ElementTarget;\n  path?: ReadonlyArray<Point>;\n}\n\n// --- 导航 ---\n\nexport interface NavigateParams {\n  url: Resolvable<string>;\n  refresh?: boolean;\n}\n\n// --- 等待和断言 ---\n\nexport type WaitCondition =\n  | { kind: 'sleep'; sleep: Resolvable<Milliseconds> }\n  | { kind: 'navigation' }\n  | { kind: 'networkIdle'; idleMs?: Resolvable<Milliseconds> }\n  | { kind: 'text'; text: Resolvable<string>; appear?: boolean }\n  | { kind: 'selector'; selector: Resolvable<string>; visible?: boolean };\n\nexport interface WaitParams {\n  condition: WaitCondition;\n}\n\nexport type Assertion =\n  | { kind: 'exists'; selector: Resolvable<string> }\n  | { kind: 'visible'; selector: Resolvable<string> }\n  | { kind: 'textPresent'; text: Resolvable<string> }\n  | {\n      kind: 'attribute';\n      selector: Resolvable<string>;\n      name: Resolvable<string>;\n      equals?: Resolvable<string>;\n      matches?: Resolvable<string>;\n    };\n\nexport type AssertFailStrategy = 'stop' | 'warn' | 'retry';\n\nexport interface AssertParams {\n  assert: Assertion;\n  failStrategy?: AssertFailStrategy;\n}\n\n// --- 数据和脚本 ---\n\nexport type ExtractParams =\n  | {\n      mode: 'selector';\n      selector: Resolvable<string>;\n      attr?: Resolvable<string>; // \"text\" | \"textContent\" | attribute name\n      saveAs: VariableName;\n    }\n  | {\n      mode: 'js';\n      code: string;\n      world?: BrowserWorld;\n      saveAs: VariableName;\n    };\n\nexport type ScriptTiming = 'before' | 'after';\n\nexport interface ScriptParams {\n  world?: BrowserWorld;\n  code: string;\n  when?: ScriptTiming;\n  args?: Record<string, Resolvable<JsonValue>>;\n  saveAs?: VariableName;\n  assign?: Assignments;\n}\n\nexport interface ScreenshotParams {\n  selector?: Resolvable<string>;\n  fullPage?: boolean;\n  saveAs?: VariableName;\n}\n\n// --- HTTP ---\n\nexport type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\nexport type HttpHeaders = Record<string, Resolvable<string>>;\nexport type HttpFormData = Record<string, Resolvable<string>>;\n\nexport type HttpBody =\n  | { kind: 'none' }\n  | { kind: 'text'; text: Resolvable<string>; contentType?: Resolvable<string> }\n  | { kind: 'json'; json: Resolvable<JsonValue> };\n\nexport type HttpOkStatus =\n  | { kind: 'range'; min: number; max: number }\n  | { kind: 'list'; statuses: NonEmptyArray<number> };\n\nexport interface HttpParams {\n  method?: HttpMethod;\n  url: Resolvable<string>;\n  headers?: HttpHeaders;\n  body?: HttpBody;\n  formData?: HttpFormData;\n  okStatus?: HttpOkStatus;\n  saveAs?: VariableName;\n  assign?: Assignments;\n}\n\n// --- DOM 工具 ---\n\nexport interface TriggerEventParams {\n  target: ElementTarget;\n  event: Resolvable<string>;\n  bubbles?: boolean;\n  cancelable?: boolean;\n}\n\nexport interface SetAttributeParams {\n  target: ElementTarget;\n  name: Resolvable<string>;\n  value?: Resolvable<JsonValue>;\n  remove?: boolean;\n}\n\nexport interface SwitchFrameParams {\n  target: FrameTarget;\n}\n\nexport interface LoopElementsParams {\n  selector: Resolvable<string>;\n  saveAs?: VariableName;\n  itemVar?: VariableName;\n  subflowId: SubflowId;\n}\n\n// --- 标签页管理 ---\n\nexport interface OpenTabParams {\n  url?: Resolvable<string>;\n  newWindow?: boolean;\n}\n\nexport interface SwitchTabParams {\n  tabId?: number;\n  urlContains?: Resolvable<string>;\n  titleContains?: Resolvable<string>;\n}\n\nexport interface CloseTabParams {\n  tabIds?: ReadonlyArray<number>;\n  url?: Resolvable<string>;\n}\n\nexport interface HandleDownloadParams {\n  filenameContains?: Resolvable<string>;\n  waitForComplete?: boolean;\n  saveAs?: VariableName;\n}\n\n// --- 控制流 ---\n\nexport interface ExecuteFlowParams {\n  flowId: FlowId;\n  inline?: boolean;\n  args?: Record<string, Resolvable<JsonValue>>;\n}\n\nexport interface ForeachParams {\n  listVar: VariableName;\n  itemVar?: VariableName;\n  subflowId: SubflowId;\n  concurrency?: number;\n}\n\nexport interface WhileParams {\n  condition: Condition;\n  subflowId: SubflowId;\n  maxIterations?: number;\n}\n\nexport interface IfBranch {\n  id: string;\n  label: EdgeLabel;\n  condition: Condition;\n}\n\nexport type IfParams =\n  | {\n      mode: 'binary';\n      condition: Condition;\n      trueLabel?: EdgeLabel;\n      falseLabel?: EdgeLabel;\n    }\n  | {\n      mode: 'branches';\n      branches: NonEmptyArray<IfBranch>;\n      elseLabel?: EdgeLabel;\n    };\n\nexport interface DelayParams {\n  sleep: Resolvable<Milliseconds>;\n}\n\n// --- 触发器 ---\n\nexport type TriggerUrlRuleKind = 'url' | 'domain' | 'path';\n\nexport interface TriggerUrlRule {\n  kind: TriggerUrlRuleKind;\n  value: Resolvable<string>;\n}\n\nexport interface TriggerUrlConfig {\n  rules?: ReadonlyArray<TriggerUrlRule>;\n}\n\nexport interface TriggerModeConfig {\n  manual?: boolean;\n  url?: boolean;\n  contextMenu?: boolean;\n  command?: boolean;\n  dom?: boolean;\n  schedule?: boolean;\n}\n\nexport interface TriggerContextMenuConfig {\n  title?: Resolvable<string>;\n  enabled?: boolean;\n}\n\nexport interface TriggerCommandConfig {\n  commandKey?: Resolvable<string>;\n  enabled?: boolean;\n}\n\nexport interface TriggerDomConfig {\n  selector?: Resolvable<string>;\n  appear?: boolean;\n  once?: boolean;\n  debounceMs?: Milliseconds;\n  enabled?: boolean;\n}\n\nexport type TriggerScheduleType = 'once' | 'interval' | 'daily';\n\nexport interface TriggerSchedule {\n  id: string;\n  type: TriggerScheduleType;\n  when: Resolvable<string>; // ISO/cron-like string\n  enabled?: boolean;\n}\n\nexport interface TriggerParams {\n  enabled?: boolean;\n  description?: Resolvable<string>;\n  modes?: TriggerModeConfig;\n  url?: TriggerUrlConfig;\n  contextMenu?: TriggerContextMenuConfig;\n  command?: TriggerCommandConfig;\n  dom?: TriggerDomConfig;\n  schedules?: ReadonlyArray<TriggerSchedule>;\n}\n\n// ================================\n// Action 核心定义\n// ================================\n\n/**\n * ActionParamsByType 使用 interface 声明\n * 允许外部模块通过声明合并扩展 Action 类型（符合 OCP 原则）\n */\nexport interface ActionParamsByType {\n  // UI/构建时\n  trigger: TriggerParams;\n  delay: DelayParams;\n\n  // 页面交互\n  click: ClickParams;\n  dblclick: ClickParams;\n  fill: FillParams;\n  key: KeyParams;\n  scroll: ScrollParams;\n  drag: DragParams;\n\n  // 同步和验证\n  wait: WaitParams;\n  assert: AssertParams;\n\n  // 数据和脚本\n  extract: ExtractParams;\n  script: ScriptParams;\n  http: HttpParams;\n  screenshot: ScreenshotParams;\n\n  // DOM 工具\n  triggerEvent: TriggerEventParams;\n  setAttribute: SetAttributeParams;\n\n  // 帧和循环\n  switchFrame: SwitchFrameParams;\n  loopElements: LoopElementsParams;\n\n  // 控制流\n  if: IfParams;\n  foreach: ForeachParams;\n  while: WhileParams;\n  executeFlow: ExecuteFlowParams;\n\n  // 标签页\n  navigate: NavigateParams;\n  openTab: OpenTabParams;\n  switchTab: SwitchTabParams;\n  closeTab: CloseTabParams;\n  handleDownload: HandleDownloadParams;\n}\n\nexport type ActionType = keyof ActionParamsByType;\n\nexport interface ActionBase<T extends ActionType> {\n  id: ActionId;\n  type: T;\n  name?: string;\n  disabled?: boolean;\n  tags?: ReadonlyArray<string>;\n  policy?: ActionPolicy;\n  ui?: { x: number; y: number };\n}\n\nexport type Action<T extends ActionType = ActionType> = ActionBase<T> & {\n  params: ActionParamsByType[T];\n};\n\nexport type AnyAction = { [T in ActionType]: Action<T> }[ActionType];\n\nexport type ExecutableActionType = Exclude<ActionType, 'trigger'>;\nexport type ExecutableAction<T extends ExecutableActionType = ExecutableActionType> = Action<T>;\n\n// ================================\n// Action 输出\n// ================================\n\nexport interface HttpResponse {\n  url: string;\n  status: number;\n  headers?: Record<string, string>;\n  body?: JsonValue | string | null;\n}\n\nexport type DownloadState = 'in_progress' | 'complete' | 'interrupted' | 'canceled';\n\nexport interface DownloadInfo {\n  id: string;\n  filename: string;\n  url?: string;\n  state?: DownloadState;\n  size?: number;\n}\n\n/**\n * Action 输出类型映射（可通过声明合并扩展）\n */\nexport interface ActionOutputsByType {\n  screenshot: { base64Data: string };\n  extract: { value: JsonValue };\n  script: { result: JsonValue };\n  http: { response: HttpResponse };\n  handleDownload: { download: DownloadInfo };\n  loopElements: { elements: string[] };\n}\n\nexport type ActionOutput<T extends ActionType> = T extends keyof ActionOutputsByType\n  ? ActionOutputsByType[T]\n  : undefined;\n\n// ================================\n// 执行接口\n// ================================\n\nexport type ValidationResult = { ok: true } | { ok: false; errors: NonEmptyArray<string> };\n\n/**\n * Execution flags for coordinating with orchestrator policies.\n * Used to avoid duplicate retry/nav-wait when StepRunner owns these policies.\n */\nexport interface ExecutionFlags {\n  /**\n   * When true, navigation waiting should be handled by StepRunner.\n   * Action handlers (click, navigate) should skip their internal nav-wait logic.\n   */\n  skipNavWait?: boolean;\n}\n\nexport interface ActionExecutionContext {\n  vars: VariableStore;\n  tabId: number;\n  frameId?: number;\n  runId?: string;\n  /** 日志记录函数 */\n  log: (message: string, level?: 'info' | 'warn' | 'error') => void;\n  /** 截图函数 */\n  captureScreenshot?: () => Promise<string>;\n  /**\n   * Optional structured log sink for replay UIs (legacy RunLogger integration).\n   * Action handlers may emit richer entries (e.g. selector fallback) via this hook.\n   */\n  pushLog?: (entry: unknown) => void;\n  /**\n   * Execution flags provided by the orchestrator.\n   * Handlers should respect these flags to avoid duplicating StepRunner policies.\n   */\n  execution?: ExecutionFlags;\n}\n\nexport type ControlDirective =\n  | {\n      kind: 'foreach';\n      listVar: VariableName;\n      itemVar: VariableName;\n      subflowId: SubflowId;\n      concurrency?: number;\n    }\n  | {\n      kind: 'while';\n      condition: Condition;\n      subflowId: SubflowId;\n      maxIterations: number;\n    };\n\nexport interface ActionExecutionResult<T extends ActionType = ActionType> {\n  status: 'success' | 'failed' | 'skipped' | 'paused';\n  output?: ActionOutput<T>;\n  error?: ActionError;\n  /** 下一个边的 label（用于条件分支） */\n  nextLabel?: EdgeLabel;\n  /** 控制流指令（foreach/while） */\n  control?: ControlDirective;\n  /** 执行耗时 */\n  durationMs?: Milliseconds;\n  /**\n   * New tab ID after tab operations (openTab/switchTab).\n   * Used to update execution context for subsequent steps.\n   */\n  newTabId?: number;\n}\n\n/**\n * Action 执行器接口\n */\nexport interface ActionHandler<T extends ExecutableActionType = ExecutableActionType> {\n  type: T;\n  /** 验证 action 配置 */\n  validate?: (action: Action<T>) => ValidationResult;\n  /** 执行 action */\n  run: (ctx: ActionExecutionContext, action: Action<T>) => Promise<ActionExecutionResult<T>>;\n  /** 生成 action 描述（用于 UI 显示） */\n  describe?: (action: Action<T>) => string;\n}\n\n// ================================\n// Flow 图结构\n// ================================\n\nexport interface ActionEdge {\n  id: EdgeId;\n  from: ActionId;\n  to: ActionId;\n  label?: EdgeLabel;\n}\n\nexport interface FlowBinding {\n  type: 'domain' | 'path' | 'url';\n  value: string;\n}\n\nexport interface FlowMeta {\n  createdAt: ISODateTimeString;\n  updatedAt: ISODateTimeString;\n  domain?: string;\n  tags?: ReadonlyArray<string>;\n  bindings?: ReadonlyArray<FlowBinding>;\n  tool?: { category?: string; description?: string };\n  exposedOutputs?: ReadonlyArray<{ nodeId: ActionId; as: VariableName }>;\n}\n\nexport interface Flow {\n  id: FlowId;\n  name: string;\n  description?: string;\n  version: number;\n  meta: FlowMeta;\n  variables?: ReadonlyArray<VariableDefinition>;\n\n  /** DAG 节点 */\n  nodes: ReadonlyArray<AnyAction>;\n  /** DAG 边 */\n  edges: ReadonlyArray<ActionEdge>;\n  /** 子流程（用于 foreach/while/loopElements） */\n  subflows?: Record<\n    SubflowId,\n    { nodes: ReadonlyArray<AnyAction>; edges: ReadonlyArray<ActionEdge> }\n  >;\n}\n\n// ================================\n// Action 规格（用于 UI）\n// ================================\n\nexport type ActionCategory = 'Flow' | 'Actions' | 'Logic' | 'Tools' | 'Tabs' | 'Page';\n\nexport interface ActionSpecDisplay {\n  label: string;\n  description?: string;\n  category: ActionCategory;\n  icon?: string;\n  docUrl?: string;\n}\n\nexport interface ActionSpecPorts {\n  inputs: number | 'any';\n  outputs: Array<{ label?: EdgeLabel }> | 'any';\n  maxConnection?: number;\n  allowedInputs?: boolean;\n}\n\nexport interface ActionSpec<T extends ActionType = ActionType> {\n  type: T;\n  version: number;\n  display: ActionSpecDisplay;\n  ports: ActionSpecPorts;\n  defaults?: Partial<ActionParamsByType[T]>;\n  /** 需要进行模板替换的字段路径 */\n  refDataKeys?: ReadonlyArray<string>;\n}\n\n// ================================\n// 常量导出\n// ================================\n\nexport const ACTION_TYPES: ReadonlyArray<ActionType> = [\n  'trigger',\n  'delay',\n  'click',\n  'dblclick',\n  'fill',\n  'key',\n  'scroll',\n  'drag',\n  'wait',\n  'assert',\n  'extract',\n  'script',\n  'http',\n  'screenshot',\n  'triggerEvent',\n  'setAttribute',\n  'switchFrame',\n  'loopElements',\n  'if',\n  'foreach',\n  'while',\n  'executeFlow',\n  'navigate',\n  'openTab',\n  'switchTab',\n  'closeTab',\n  'handleDownload',\n] as const;\n\nexport const EXECUTABLE_ACTION_TYPES: ReadonlyArray<ExecutableActionType> = ACTION_TYPES.filter(\n  (t): t is ExecutableActionType => t !== 'trigger',\n);\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/constants.ts",
    "content": "// constants.ts — centralized engine constants and labels\nimport { EDGE_LABELS } from 'chrome-mcp-shared';\n\nexport const ENGINE_CONSTANTS = {\n  DEFAULT_WAIT_MS: 5000,\n  MAX_WAIT_MS: 120000,\n  NETWORK_IDLE_SAMPLE_MS: 1200,\n  MAX_ITERATIONS: 1000,\n  MAX_FOREACH_CONCURRENCY: 16,\n  EDGE_LABELS: EDGE_LABELS,\n} as const;\n\nexport type EdgeLabel =\n  (typeof ENGINE_CONSTANTS.EDGE_LABELS)[keyof typeof ENGINE_CONSTANTS.EDGE_LABELS];\n\n// Centralized stepId values used in run logs for non-step events\nexport const LOG_STEP_IDS = {\n  GLOBAL_TIMEOUT: 'global-timeout',\n  PLUGIN_RUN_START: 'plugin-runStart',\n  VARIABLE_COLLECT: 'variable-collect',\n  BINDING_CHECK: 'binding-check',\n  NETWORK_CAPTURE: 'network-capture',\n  DAG_REQUIRED: 'dag-required',\n  DAG_CYCLE: 'dag-cycle',\n  LOOP_GUARD: 'loop-guard',\n  PLUGIN_RUN_END: 'plugin-runEnd',\n  RUNSTATE_UPDATE: 'runState-update',\n  RUNSTATE_DELETE: 'runState-delete',\n} as const;\n\nexport type LogStepId = (typeof LOG_STEP_IDS)[keyof typeof LOG_STEP_IDS];\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/execution-mode.ts",
    "content": "/**\n * Execution Mode Configuration\n *\n * Controls whether step execution uses the legacy node system or the new ActionRegistry.\n * Provides a migration path from legacy to actions with hybrid mode for gradual rollout.\n *\n * Modes:\n * - 'legacy': Use the existing executeStep from nodes/index.ts (default, safest)\n * - 'actions': Use ActionRegistry exclusively (strict mode, throws on unsupported)\n * - 'hybrid': Try ActionRegistry first, fall back to legacy for unsupported types\n */\n\nimport type { Step } from '../types';\n\n/**\n * Execution mode determines how steps are executed\n */\nexport type ExecutionMode = 'legacy' | 'actions' | 'hybrid';\n\n/**\n * Configuration for execution mode\n */\nexport interface ExecutionModeConfig {\n  /**\n   * The execution mode to use\n   * @default 'legacy'\n   */\n  mode: ExecutionMode;\n\n  /**\n   * Step types that should always use legacy execution (denylist for actions)\n   * Only applies in hybrid mode\n   */\n  legacyOnlyTypes?: Set<string>;\n\n  /**\n   * Step types that should use actions execution (allowlist)\n   * Only applies in hybrid mode.\n   * - If undefined: uses MINIMAL_HYBRID_ACTION_TYPES (safest default)\n   * - If empty Set (size=0): falls back to MIGRATED_ACTION_TYPES policy\n   * - If non-empty Set: only these types use actions\n   */\n  actionsAllowlist?: Set<string>;\n\n  /**\n   * Whether to log when falling back from actions to legacy in hybrid mode\n   * @default true\n   */\n  logFallbacks?: boolean;\n\n  /**\n   * Skip ActionRegistry's built-in retry policy.\n   * When true, action.policy.retry is removed before execution.\n   * @default true - StepRunner already handles retry via withRetry()\n   *\n   * Note: ActionRegistry timeout is NOT disabled (provides per-action timeout safety).\n   */\n  skipActionsRetry?: boolean;\n\n  /**\n   * Skip ActionRegistry's navigation waiting when StepRunner handles it\n   * @default true - StepRunner already handles navigation waiting\n   */\n  skipActionsNavWait?: boolean;\n}\n\n/**\n * Default execution mode configuration\n * Starts with legacy mode for maximum safety during migration\n */\nexport const DEFAULT_EXECUTION_MODE_CONFIG: ExecutionModeConfig = {\n  mode: 'legacy',\n  logFallbacks: true,\n  skipActionsRetry: true,\n  skipActionsNavWait: true,\n};\n\n/**\n * Minimal allowlist for initial hybrid rollout.\n *\n * This keeps high-risk step types (navigation/click/tab management) on legacy\n * until policy (retry/timeout/nav-wait) and tab cursor semantics are unified.\n *\n * These types are chosen for their low risk:\n * - No navigation side effects\n * - No tab management\n * - No complex timing requirements\n * - Simple input/output semantics\n */\nexport const MINIMAL_HYBRID_ACTION_TYPES = new Set<string>([\n  'fill', // Form input - no navigation\n  'key', // Keyboard input - no navigation\n  'scroll', // Viewport manipulation - no navigation\n  'drag', // Drag and drop - local operation\n  'wait', // Condition waiting - no side effects\n  'delay', // Simple delay - no side effects\n  'screenshot', // Capture only - no side effects\n  'assert', // Validation only - no side effects\n]);\n\n/**\n * Step types that are fully migrated and tested with ActionRegistry\n * These are safe to run in actions mode\n *\n * NOTE: Start conservative and expand gradually as testing confirms equivalence.\n * Types NOT included here will fall back to legacy in hybrid mode.\n *\n * Criteria for inclusion:\n * 1. Handler implementation matches legacy behavior exactly\n * 2. Step data structure is compatible (no complex transformation needed)\n * 3. No timing-sensitive dependencies (like script when:'after' defer)\n */\nexport const MIGRATED_ACTION_TYPES = new Set<string>([\n  // Navigation - well tested, simple mapping\n  'navigate',\n  // Interaction - well tested, core functionality\n  'click',\n  'dblclick',\n  'fill',\n  'key',\n  'scroll',\n  'drag',\n  // Timing - simple logic, no complex state\n  'wait',\n  'delay',\n  // Screenshot - simple, no side effects\n  'screenshot',\n  // Assert - validation only, no state changes\n  'assert',\n]);\n\n/**\n * Step types that need more validation before migration\n * These are supported by ActionRegistry but may have behavior differences\n */\nexport const NEEDS_VALIDATION_TYPES = new Set<string>([\n  // Data extraction - need to verify selector/js mode equivalence\n  'extract',\n  // HTTP - body type handling may differ\n  'http',\n  // Script - when:'after' defer semantics differ from legacy\n  'script',\n  // Tabs - tabId tracking needs careful integration\n  'openTab',\n  'switchTab',\n  'closeTab',\n  'handleDownload',\n  // Control flow - condition evaluation may differ\n  'if',\n  'foreach',\n  'while',\n  'switchFrame',\n]);\n\n/**\n * Step types that must use legacy execution\n * These have complex integration requirements not yet supported by ActionRegistry\n */\nexport const LEGACY_ONLY_TYPES = new Set<string>([\n  // Complex legacy types not yet migrated\n  'triggerEvent',\n  'setAttribute',\n  'loopElements',\n  'executeFlow',\n]);\n\n/**\n * Determine whether a step should use actions execution based on config\n */\nexport function shouldUseActions(step: Step, config: ExecutionModeConfig): boolean {\n  if (config.mode === 'legacy') {\n    return false;\n  }\n\n  if (config.mode === 'actions') {\n    return true;\n  }\n\n  // Hybrid mode: check allowlist/denylist\n  const stepType = step.type;\n\n  // Denylist takes precedence\n  if (config.legacyOnlyTypes?.has(stepType)) {\n    return false;\n  }\n\n  // If allowlist is specified and non-empty, step must be in it\n  if (config.actionsAllowlist && config.actionsAllowlist.size > 0) {\n    return config.actionsAllowlist.has(stepType);\n  }\n\n  // Default to using actions for supported types\n  return MIGRATED_ACTION_TYPES.has(stepType);\n}\n\n/**\n * Create a hybrid execution mode config for gradual migration.\n *\n * By default uses MINIMAL_HYBRID_ACTION_TYPES as allowlist, which excludes\n * high-risk types (navigate/click/tab management) from actions execution.\n *\n * @param overrides - Optional overrides for the config\n * @param overrides.actionsAllowlist - Set of step types to execute via actions.\n *   If provided with size > 0, only these types use actions.\n *   If empty Set, falls back to MIGRATED_ACTION_TYPES.\n *   If undefined, uses MINIMAL_HYBRID_ACTION_TYPES (safest default).\n */\nexport function createHybridConfig(overrides?: Partial<ExecutionModeConfig>): ExecutionModeConfig {\n  return {\n    ...DEFAULT_EXECUTION_MODE_CONFIG,\n    mode: 'hybrid',\n    legacyOnlyTypes: new Set(LEGACY_ONLY_TYPES),\n    actionsAllowlist: new Set(MINIMAL_HYBRID_ACTION_TYPES),\n    ...overrides,\n  };\n}\n\n/**\n * Create a strict actions mode config for testing.\n * All steps must be handled by ActionRegistry or throw.\n *\n * Note: Even in actions mode, StepRunner remains the policy authority for\n * retry/nav-wait. This ensures consistent behavior across all execution modes\n * and avoids double-strategy issues.\n */\nexport function createActionsOnlyConfig(\n  overrides?: Partial<ExecutionModeConfig>,\n): ExecutionModeConfig {\n  return {\n    ...DEFAULT_EXECUTION_MODE_CONFIG,\n    mode: 'actions',\n    // Keep StepRunner as policy authority - skip ActionRegistry's internal policies\n    skipActionsRetry: true,\n    skipActionsNavWait: true,\n    ...overrides,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/logging/run-logger.ts",
    "content": "// engine/logging/run-logger.ts — run logs, overlay and persistence\nimport type { RunLogEntry, RunRecord, Flow } from '../../types';\nimport { appendRun } from '../../flow-store';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\n\nexport class RunLogger {\n  private logs: RunLogEntry[] = [];\n  constructor(private runId: string) {}\n\n  push(e: RunLogEntry) {\n    this.logs.push(e);\n  }\n\n  getLogs() {\n    return this.logs;\n  }\n\n  async overlayInit() {\n    try {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (tabs[0]?.id)\n        await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'init' } as any);\n    } catch {}\n  }\n\n  async overlayAppend(text: string) {\n    try {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (tabs[0]?.id)\n        await chrome.tabs.sendMessage(tabs[0].id, {\n          action: 'rr_overlay',\n          cmd: 'append',\n          text,\n        } as any);\n    } catch {}\n  }\n\n  async overlayDone() {\n    try {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (tabs[0]?.id)\n        await chrome.tabs.sendMessage(tabs[0].id, { action: 'rr_overlay', cmd: 'done' } as any);\n    } catch {}\n  }\n\n  async screenshotOnFailure() {\n    try {\n      const shot = await handleCallTool({\n        name: TOOL_NAMES.BROWSER.COMPUTER,\n        args: { action: 'screenshot' },\n      });\n      const img = (shot?.content?.find((c: any) => c.type === 'image') as any)?.data as string;\n      if (img) this.logs[this.logs.length - 1].screenshotBase64 = img;\n    } catch {}\n  }\n\n  async persist(flow: Flow, startedAt: number, success: boolean) {\n    const record: RunRecord = {\n      id: this.runId,\n      flowId: flow.id,\n      startedAt: new Date(startedAt).toISOString(),\n      finishedAt: new Date().toISOString(),\n      success,\n      entries: this.logs,\n    };\n    await appendRun(record);\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/plugins/breakpoint.ts",
    "content": "import type { RunPlugin, StepContext } from './types';\nimport { runState } from '../state-manager';\n\nexport function breakpointPlugin(): RunPlugin {\n  return {\n    name: 'breakpoint',\n    async onBeforeStep(ctx: StepContext) {\n      try {\n        const step: any = ctx.step as any;\n        const hasBreakpoint = step?.$breakpoint === true || step?.breakpoint === true;\n        if (!hasBreakpoint) return;\n        // mark run paused for external UI to resume\n        await runState.update(ctx.runId, { status: 'stopped', updatedAt: Date.now() } as any);\n        return { pause: true };\n      } catch {}\n      return;\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/plugins/manager.ts",
    "content": "import type {\n  RunPlugin,\n  HookControl,\n  RunContext,\n  StepContext,\n  StepAfterContext,\n  StepErrorContext,\n  StepRetryContext,\n  RunEndContext,\n  SubflowContext,\n} from './types';\n\nexport class PluginManager {\n  constructor(private plugins: RunPlugin[]) {}\n\n  async runStart(ctx: RunContext) {\n    for (const p of this.plugins) await safeCall(p, 'onRunStart', ctx);\n  }\n\n  async beforeStep(ctx: StepContext): Promise<HookControl | undefined> {\n    for (const p of this.plugins) {\n      const out = await safeCall(p, 'onBeforeStep', ctx);\n      if (out && (out.pause || out.nextLabel)) return out;\n    }\n    return undefined;\n  }\n\n  async afterStep(ctx: StepAfterContext) {\n    for (const p of this.plugins) await safeCall(p, 'onAfterStep', ctx);\n  }\n\n  async onError(ctx: StepErrorContext): Promise<HookControl | undefined> {\n    for (const p of this.plugins) {\n      const out = await safeCall(p, 'onStepError', ctx);\n      if (out && (out.pause || out.nextLabel)) return out;\n    }\n    return undefined;\n  }\n\n  async onRetry(ctx: StepRetryContext) {\n    for (const p of this.plugins) await safeCall(p, 'onRetry', ctx);\n  }\n\n  async onChooseNextLabel(ctx: StepContext & { suggested?: string }): Promise<string | undefined> {\n    for (const p of this.plugins) {\n      const out = await safeCall(p, 'onChooseNextLabel', ctx);\n      if (out && out.nextLabel) return String(out.nextLabel);\n    }\n    return undefined;\n  }\n\n  async subflowStart(ctx: SubflowContext) {\n    for (const p of this.plugins) await safeCall(p, 'onSubflowStart', ctx);\n  }\n\n  async subflowEnd(ctx: SubflowContext) {\n    for (const p of this.plugins) await safeCall(p, 'onSubflowEnd', ctx);\n  }\n\n  async runEnd(ctx: RunEndContext) {\n    for (const p of this.plugins) await safeCall(p, 'onRunEnd', ctx);\n  }\n}\n\nasync function safeCall<T extends keyof RunPlugin>(plugin: RunPlugin, key: T, arg: any) {\n  try {\n    const fn = plugin[key] as any;\n    if (typeof fn === 'function') return await fn.call(plugin, arg);\n  } catch (e) {\n    // swallow plugin errors to keep core stable\n    // console.warn(`[plugin:${plugin.name}] ${String(key)} error:`, e);\n  }\n  return undefined;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/plugins/types.ts",
    "content": "// Plugin system for record-replay engine\n// Inspired by webpack-like lifecycle hooks, to avoid touching core for extensibility\n\nimport type { Flow, Step } from '../../types';\nimport type { ExecResult } from '../../nodes';\n\nexport interface RunContext {\n  runId: string;\n  flow: Flow;\n  vars: Record<string, any>;\n}\n\nexport interface StepContext extends RunContext {\n  step: Step;\n}\n\nexport interface StepErrorContext extends StepContext {\n  error: any;\n}\n\nexport interface StepRetryContext extends StepErrorContext {\n  attempt: number;\n}\n\nexport interface StepAfterContext extends StepContext {\n  result?: ExecResult;\n}\n\nexport interface SubflowContext extends RunContext {\n  subflowId: string;\n}\n\nexport interface RunEndContext extends RunContext {\n  success: boolean;\n  failed: number;\n}\n\nexport interface HookControl {\n  pause?: boolean; // request scheduler to pause run (e.g., breakpoint)\n  nextLabel?: string; // override next edge label\n}\n\nexport interface RunPlugin {\n  name: string;\n  onRunStart?(ctx: RunContext): Promise<void> | void;\n  onBeforeStep?(ctx: StepContext): Promise<HookControl | void> | HookControl | void;\n  onAfterStep?(ctx: StepAfterContext): Promise<void> | void;\n  onStepError?(ctx: StepErrorContext): Promise<HookControl | void> | HookControl | void;\n  onRetry?(ctx: StepRetryContext): Promise<void> | void;\n  onChooseNextLabel?(\n    ctx: StepContext & { suggested?: string },\n  ): Promise<HookControl | void> | HookControl | void;\n  onSubflowStart?(ctx: SubflowContext): Promise<void> | void;\n  onSubflowEnd?(ctx: SubflowContext): Promise<void> | void;\n  onRunEnd?(ctx: RunEndContext): Promise<void> | void;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/policies/retry.ts",
    "content": "// engine/policies/retry.ts — unified retry/backoff policy\n\nexport type BackoffKind = 'none' | 'exp';\n\nexport interface RetryOptions {\n  count?: number; // max attempts beyond the first run\n  intervalMs?: number;\n  backoff?: BackoffKind;\n}\n\nexport async function withRetry<T>(\n  run: () => Promise<T>,\n  onRetry?: (attempt: number, err: any) => Promise<void> | void,\n  opts?: RetryOptions,\n): Promise<T> {\n  const max = Math.max(0, Number(opts?.count ?? 0));\n  const base = Math.max(0, Number(opts?.intervalMs ?? 0));\n  const backoff = (opts?.backoff || 'none') as BackoffKind;\n  let attempt = 0;\n  while (true) {\n    try {\n      return await run();\n    } catch (e) {\n      if (attempt >= max) throw e;\n      if (onRetry) await onRetry(attempt, e);\n      const delay = base > 0 ? (backoff === 'exp' ? base * Math.pow(2, attempt) : base) : 0;\n      if (delay > 0) await new Promise((r) => setTimeout(r, delay));\n      attempt += 1;\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/policies/wait.ts",
    "content": "// engine/policies/wait.ts — wrappers around rr-utils navigation/network waits\n// Keep logic centralized to avoid duplication in schedulers and nodes\n\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { waitForNavigation as rrWaitForNavigation, waitForNetworkIdle } from '../../rr-utils';\n\nexport async function waitForNavigationDone(prevUrl: string, timeoutMs?: number) {\n  await rrWaitForNavigation(timeoutMs, prevUrl);\n}\n\nexport async function ensureReadPageIfWeb() {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const url = tabs?.[0]?.url || '';\n    if (/^(https?:|file:)/i.test(url)) {\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    }\n  } catch {}\n}\n\nexport async function maybeQuickWaitForNav(prevUrl: string, timeoutMs?: number) {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') return;\n    const sniffMs = 350;\n    const startedAt = Date.now();\n    let seen = false;\n    await new Promise<void>((resolve) => {\n      let timer: any = null;\n      const cleanup = () => {\n        try {\n          chrome.webNavigation.onCommitted.removeListener(onCommitted);\n        } catch {}\n        try {\n          chrome.webNavigation.onCompleted.removeListener(onCompleted);\n        } catch {}\n        try {\n          (chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(\n            onHistoryStateUpdated,\n          );\n        } catch {}\n        try {\n          chrome.tabs.onUpdated.removeListener(onUpdated);\n        } catch {}\n        if (timer) {\n          try {\n            clearTimeout(timer);\n          } catch {}\n        }\n      };\n      const finish = async () => {\n        cleanup();\n        if (seen) {\n          try {\n            await rrWaitForNavigation(\n              prevUrl ? Math.min(timeoutMs || 15000, 30000) : undefined,\n              prevUrl,\n            );\n          } catch {}\n        }\n        resolve();\n      };\n      const mark = () => {\n        seen = true;\n      };\n      const onCommitted = (d: any) => {\n        if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();\n      };\n      const onCompleted = (d: any) => {\n        if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();\n      };\n      const onHistoryStateUpdated = (d: any) => {\n        if (d.tabId === tabId && d.frameId === 0 && d.timeStamp >= startedAt) mark();\n      };\n      const onUpdated = (updatedId: number, change: chrome.tabs.TabChangeInfo) => {\n        if (updatedId !== tabId) return;\n        if (change.status === 'loading') mark();\n        if (typeof change.url === 'string' && (!prevUrl || change.url !== prevUrl)) mark();\n      };\n\n      chrome.webNavigation.onCommitted.addListener(onCommitted);\n      chrome.webNavigation.onCompleted.addListener(onCompleted);\n      try {\n        (chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);\n      } catch {}\n      chrome.tabs.onUpdated.addListener(onUpdated);\n      timer = setTimeout(finish, sniffMs);\n    });\n  } catch {}\n}\n\nexport { waitForNetworkIdle };\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/runners/after-script-queue.ts",
    "content": "// after-script-queue.ts — queue + executor for deferred after-scripts\n// Notes:\n// - Executes user-provided code in the specified world (ISOLATED by default)\n// - Clears queue before execution to avoid leaks; re-queues remainder on failure\n// - Logs warnings instead of throwing to keep the main engine resilient\n\nimport type { StepScript } from '../../types';\nimport type { ExecCtx } from '../../nodes';\nimport { RunLogger } from '../logging/run-logger';\nimport { applyAssign } from '../../rr-utils';\n\nexport class AfterScriptQueue {\n  private queue: StepScript[] = [];\n\n  constructor(private logger: RunLogger) {}\n\n  enqueue(script: StepScript) {\n    this.queue.push(script);\n  }\n\n  size() {\n    return this.queue.length;\n  }\n\n  async flush(ctx: ExecCtx, vars: Record<string, any>) {\n    if (this.queue.length === 0) return;\n    const scriptsToFlush = this.queue.splice(0, this.queue.length);\n    for (let i = 0; i < scriptsToFlush.length; i++) {\n      const s = scriptsToFlush[i]!;\n      const tScript = Date.now();\n      const world = (s as any).world || 'ISOLATED';\n      const code = String((s as any).code || '');\n      if (!code.trim()) {\n        this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });\n        continue;\n      }\n      try {\n        // Warn on obviously dangerous constructs; not a sandbox, just visibility.\n        const dangerous =\n          /[;{}]|\\b(function|=>|while|for|class|globalThis|window|self|this|constructor|__proto__|prototype|eval|Function|import|require|XMLHttpRequest|fetch|chrome)\\b/;\n        if (dangerous.test(code)) {\n          this.logger.push({\n            stepId: s.id,\n            status: 'warning',\n            message: 'Script contains potentially unsafe tokens; executed in isolated world',\n          });\n        }\n        const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n        const tabId = tabs?.[0]?.id;\n        if (typeof tabId !== 'number') throw new Error('Active tab not found');\n        const [{ result }] = await chrome.scripting.executeScript({\n          target: { tabId },\n          func: (userCode: string) => {\n            try {\n              return (0, eval)(userCode);\n            } catch (e) {\n              return { __error: true, message: String(e) } as any;\n            }\n          },\n          args: [code],\n          world: world as any,\n        } as any);\n        if ((result as any)?.__error) {\n          this.logger.push({\n            stepId: s.id,\n            status: 'warning',\n            message: `After-script error: ${(result as any).message || 'unknown'}`,\n          });\n        }\n        const value = (result as any)?.__error ? null : result;\n        if ((s as any).saveAs) (vars as any)[(s as any).saveAs] = value;\n        if ((s as any).assign && typeof (s as any).assign === 'object')\n          applyAssign(vars, value, (s as any).assign);\n      } catch (e: any) {\n        // Re-queue remaining and stop flush cycle for now\n        const remaining = scriptsToFlush.slice(i + 1);\n        if (remaining.length) this.queue.unshift(...remaining);\n        this.logger.push({\n          stepId: s.id,\n          status: 'warning',\n          message: `After-script execution failed: ${e?.message || String(e)}`,\n        });\n        break;\n      }\n      this.logger.push({ stepId: s.id, status: 'success', tookMs: Date.now() - tScript });\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/runners/control-flow-runner.ts",
    "content": "// control-flow-runner.ts — foreach / while orchestration\n\nimport type { ExecCtx } from '../../nodes';\nimport { RunLogger } from '../logging/run-logger';\n\nexport interface ControlFlowEnv {\n  vars: Record<string, any>;\n  logger: RunLogger;\n  evalCondition: (cond: any) => boolean;\n  runSubflowById: (subflowId: string, ctx: ExecCtx) => Promise<void>;\n  isPaused: () => boolean;\n}\n\nexport class ControlFlowRunner {\n  constructor(private env: ControlFlowEnv) {}\n\n  async run(control: any, ctx: ExecCtx): Promise<'ok' | 'paused'> {\n    if (control?.kind === 'foreach') {\n      const list = Array.isArray(this.env.vars[control.listVar])\n        ? (this.env.vars[control.listVar] as any[])\n        : [];\n      const concurrency = Math.max(1, Math.min(16, Number(control.concurrency ?? 1)));\n      if (concurrency <= 1) {\n        for (const it of list) {\n          this.env.vars[control.itemVar] = it;\n          await this.env.runSubflowById(control.subflowId, ctx);\n          if (this.env.isPaused()) return 'paused';\n        }\n        return this.env.isPaused() ? 'paused' : 'ok';\n      }\n      // Parallel with shallow-cloned vars per task (no automatic merge)\n      let idx = 0;\n      const runOne = async () => {\n        while (idx < list.length) {\n          const cur = idx++;\n          const it = list[cur];\n          const childCtx: ExecCtx = { ...ctx, vars: { ...this.env.vars } };\n          childCtx.vars[control.itemVar] = it;\n          await this.env.runSubflowById(control.subflowId, childCtx);\n          if (this.env.isPaused()) return;\n        }\n      };\n      const workers = Array.from({ length: Math.min(concurrency, list.length) }, () => runOne());\n      await Promise.all(workers);\n      return this.env.isPaused() ? 'paused' : 'ok';\n    }\n    if (control?.kind === 'while') {\n      let i = 0;\n      while (i < control.maxIterations && this.env.evalCondition(control.condition)) {\n        await this.env.runSubflowById(control.subflowId, ctx);\n        if (this.env.isPaused()) return 'paused';\n        i++;\n      }\n      return this.env.isPaused() ? 'paused' : 'ok';\n    }\n    // Unknown control type → no-op\n    return 'ok';\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-executor.ts",
    "content": "/**\n * Step Executor Interface\n *\n * Provides a unified interface for step execution that supports multiple execution modes.\n * This abstraction allows seamless switching between legacy and actions execution.\n *\n * Architecture:\n * - StepExecutorInterface: Base interface for all executors\n * - LegacyStepExecutor: Uses the existing executeStep from nodes/\n * - ActionsStepExecutor: Uses ActionRegistry from actions/\n * - HybridStepExecutor: Tries actions first, falls back to legacy\n */\n\nimport type { Step } from '../../types';\nimport type { ExecCtx, ExecResult } from '../../nodes/types';\nimport { executeStep as legacyExecuteStep } from '../../nodes';\nimport type { ActionRegistry } from '../../actions/registry';\nimport {\n  createStepExecutor,\n  isActionSupported,\n  type StepExecutionAttempt,\n} from '../../actions/adapter';\nimport type { ExecutionModeConfig } from '../execution-mode';\nimport { shouldUseActions } from '../execution-mode';\n\n/**\n * Step execution result with additional metadata\n */\nexport interface StepExecutionResult {\n  /** The execution result from the step */\n  result: ExecResult;\n  /** Which executor was used */\n  executor: 'legacy' | 'actions';\n  /** Whether fallback was used (only in hybrid mode) */\n  fallback?: boolean;\n  /** Reason for fallback (only when fallback=true) */\n  fallbackReason?: string;\n}\n\n/**\n * Options for step execution\n */\nexport interface StepExecutionOptions {\n  /** Current tab ID */\n  tabId: number;\n  /** Run ID for logging/tracing */\n  runId?: string;\n  /** Logger for recording fallback information */\n  pushLog?: (entry: unknown) => void;\n  /** Remaining time budget from global deadline */\n  remainingBudgetMs?: number;\n}\n\n/**\n * Base interface for step executors\n */\nexport interface StepExecutorInterface {\n  /**\n   * Execute a single step\n   */\n  execute(ctx: ExecCtx, step: Step, options: StepExecutionOptions): Promise<StepExecutionResult>;\n\n  /**\n   * Check if executor supports a step type\n   */\n  supports(stepType: string): boolean;\n}\n\n/**\n * Legacy step executor using nodes/executeStep\n *\n * This executor delegates to the existing node execution system.\n * The options parameter is accepted but not used - retry/timeout/navigation\n * waiting are handled by StepRunner to maintain existing behavior.\n */\nexport class LegacyStepExecutor implements StepExecutorInterface {\n  async execute(\n    ctx: ExecCtx,\n    step: Step,\n    _options: StepExecutionOptions,\n  ): Promise<StepExecutionResult> {\n    // Note: tabId from options is not used here because legacy executeStep\n    // queries the active tab internally. In hybrid/actions mode, tabId is\n    // passed through to ActionRegistry handlers.\n    const result = await legacyExecuteStep(ctx, step);\n    return {\n      result: result || {},\n      executor: 'legacy',\n    };\n  }\n\n  supports(_stepType: string): boolean {\n    // Legacy executor supports all step types via its own registry\n    return true;\n  }\n}\n\n/**\n * Actions step executor using ActionRegistry\n *\n * In strict mode, any unsupported step type throws an error.\n * This executor does NOT fall back to legacy - use HybridStepExecutor for fallback behavior.\n *\n * Respects ExecutionModeConfig for:\n * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry)\n * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait)\n */\nexport class ActionsStepExecutor implements StepExecutorInterface {\n  private executor: ReturnType<typeof createStepExecutor>;\n\n  constructor(\n    private registry: ActionRegistry,\n    private config: ExecutionModeConfig,\n  ) {\n    this.executor = createStepExecutor(registry);\n  }\n\n  async execute(\n    ctx: ExecCtx,\n    step: Step,\n    options: StepExecutionOptions,\n  ): Promise<StepExecutionResult> {\n    // Use strict=true: throws on unsupported types instead of returning { supported: false }\n    // This ensures all steps must be handled by ActionRegistry in actions-only mode\n    const attempt = (await this.executor(ctx, step, options.tabId, {\n      runId: options.runId,\n      pushLog: options.pushLog,\n      strict: true,\n      // Pass policy skip flags from config (default to true = skip)\n      skipRetry: this.config.skipActionsRetry !== false,\n      skipNavWait: this.config.skipActionsNavWait !== false,\n    })) as StepExecutionAttempt;\n\n    // With strict=true, we should never get { supported: false } - it would throw instead\n    // This check exists for type safety and defensive programming\n    if (!attempt.supported) {\n      throw new Error(attempt.reason);\n    }\n\n    return {\n      result: attempt.result,\n      executor: 'actions',\n    };\n  }\n\n  supports(stepType: string): boolean {\n    // Use adapter's type guard to check if step type is supported\n    return isActionSupported(stepType);\n  }\n}\n\n/**\n * Hybrid step executor that tries actions first, falls back to legacy\n *\n * Respects ExecutionModeConfig for:\n * - actionsAllowlist/legacyOnlyTypes: Controls which steps use actions vs legacy\n * - skipActionsRetry: Disables ActionRegistry retry (StepRunner owns retry)\n * - skipActionsNavWait: Disables handler nav-wait (StepRunner owns nav-wait)\n * - logFallbacks: Whether to log when falling back to legacy\n */\nexport class HybridStepExecutor implements StepExecutorInterface {\n  private actionsExecutor: ReturnType<typeof createStepExecutor>;\n\n  constructor(\n    private registry: ActionRegistry,\n    private config: ExecutionModeConfig,\n  ) {\n    this.actionsExecutor = createStepExecutor(registry);\n  }\n\n  async execute(\n    ctx: ExecCtx,\n    step: Step,\n    options: StepExecutionOptions,\n  ): Promise<StepExecutionResult> {\n    // Check if step should use actions based on config\n    if (!shouldUseActions(step, this.config)) {\n      // Use legacy directly\n      const result = await legacyExecuteStep(ctx, step);\n      return {\n        result: result || {},\n        executor: 'legacy',\n      };\n    }\n\n    // Try actions first\n    const attempt = (await this.actionsExecutor(ctx, step, options.tabId, {\n      runId: options.runId,\n      pushLog: options.pushLog,\n      strict: false, // Don't throw on unsupported, return { supported: false }\n      // Pass policy skip flags from config (default to true = skip)\n      skipRetry: this.config.skipActionsRetry !== false,\n      skipNavWait: this.config.skipActionsNavWait !== false,\n    })) as StepExecutionAttempt;\n\n    if (attempt.supported) {\n      return {\n        result: attempt.result,\n        executor: 'actions',\n      };\n    }\n\n    // Fall back to legacy\n    if (this.config.logFallbacks) {\n      options.pushLog?.({\n        stepId: step.id,\n        status: 'warning',\n        message: `Falling back to legacy execution: ${attempt.reason}`,\n      });\n    }\n\n    const legacyResult = await legacyExecuteStep(ctx, step);\n    return {\n      result: legacyResult || {},\n      executor: 'legacy',\n      fallback: true,\n      fallbackReason: attempt.reason,\n    };\n  }\n\n  supports(stepType: string): boolean {\n    // Hybrid executor supports all types (via fallback)\n    return true;\n  }\n}\n\n/**\n * Factory function to create the appropriate executor based on config\n */\nexport function createExecutor(\n  config: ExecutionModeConfig,\n  registry?: ActionRegistry,\n): StepExecutorInterface {\n  switch (config.mode) {\n    case 'legacy':\n      return new LegacyStepExecutor();\n\n    case 'actions':\n      if (!registry) {\n        throw new Error('ActionRegistry required for actions execution mode');\n      }\n      return new ActionsStepExecutor(registry, config);\n\n    case 'hybrid':\n      if (!registry) {\n        throw new Error('ActionRegistry required for hybrid execution mode');\n      }\n      return new HybridStepExecutor(registry, config);\n\n    default: {\n      // TypeScript exhaustiveness check\n      const _exhaustive: never = config.mode;\n      throw new Error(`Unknown execution mode: ${_exhaustive}`);\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/runners/step-runner.ts",
    "content": "/**\n * step-runner.ts\n *\n * Encapsulates execution of a single step with policies (retry, navigation wait) and plugins.\n * Uses dependency-injected StepExecutorInterface for actual step execution, enabling\n * seamless switching between legacy and ActionRegistry execution modes.\n */\n\nimport type { Flow, Step, StepClick } from '../../types';\nimport { STEP_TYPES } from 'chrome-mcp-shared';\nimport type { ExecCtx, ExecResult } from '../../nodes';\nimport { RunLogger } from '../logging/run-logger';\nimport { withRetry } from '../policies/retry';\nimport {\n  waitForNavigationDone,\n  maybeQuickWaitForNav,\n  ensureReadPageIfWeb,\n  waitForNetworkIdle,\n} from '../policies/wait';\nimport { ENGINE_CONSTANTS } from '../constants';\nimport { AfterScriptQueue } from './after-script-queue';\nimport { PluginManager } from '../plugins/manager';\nimport type { HookControl } from '../plugins/types';\nimport type { StepExecutorInterface } from './step-executor';\n\n// Narrow error-like value used for overlay reporting\ninterface ErrorLike {\n  message?: string;\n}\n\nfunction errorMessage(e: unknown): string {\n  if (e instanceof Error) return e.message;\n  if (e && typeof e === 'object' && 'message' in e) return String((e as any).message);\n  return String(e);\n}\n\n/**\n * Environment dependencies for StepRunner.\n * Injected by Scheduler to allow flexible configuration and testing.\n */\nexport interface StepRunEnv {\n  /** Unique identifier for this run */\n  runId: string;\n  /** The flow being executed */\n  flow: Flow;\n  /** Runtime variables */\n  vars: Record<string, any>;\n  /** Run logger for recording execution events */\n  logger: RunLogger;\n  /** Plugin manager for hooks (beforeStep, afterStep, onRetry, onError) */\n  pluginManager: PluginManager;\n  /** Queue for deferred after-scripts */\n  afterScripts: AfterScriptQueue;\n  /** Returns remaining time budget from global deadline (ms), Infinity if no deadline */\n  getRemainingBudgetMs: () => number;\n  /**\n   * Step executor for actual step execution.\n   * Defaults to LegacyStepExecutor if not provided (for backwards compatibility).\n   * In future, Scheduler will inject ActionsStepExecutor or HybridStepExecutor.\n   */\n  stepExecutor: StepExecutorInterface;\n}\n\nexport class StepRunner {\n  constructor(private env: StepRunEnv) {}\n\n  private async getActiveTabInfo(): Promise<{ url: string; status: string | '' }> {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tab = tabs[0];\n    return { url: tab?.url || '', status: (tab?.status as string) || '' };\n  }\n\n  async run(\n    ctx: ExecCtx,\n    step: Step,\n    appendOverlayOk: (s: Step) => Promise<void> | void,\n    appendOverlayFail: (s: Step, e: ErrorLike) => Promise<void> | void,\n  ): Promise<{\n    status: 'success' | 'failed' | 'paused';\n    nextLabel?: string;\n    control?: ExecResult['control'];\n  }> {\n    const t0 = Date.now();\n    let stepNextLabel: string | undefined;\n    let controlOut: ExecResult['control'] | undefined = undefined;\n    let ctrlStart: HookControl | undefined;\n    try {\n      ctrlStart = await this.env.pluginManager.beforeStep({\n        runId: this.env.runId,\n        flow: this.env.flow,\n        vars: this.env.vars,\n        step,\n      });\n    } catch (e: unknown) {\n      this.env.logger.push({\n        stepId: step.id,\n        status: 'warning',\n        message: `plugin.beforeStep error: ${errorMessage(e)}`,\n      });\n    }\n    if (ctrlStart?.pause) return { status: 'paused' };\n\n    const beforeInfo = await this.getActiveTabInfo();\n    try {\n      await withRetry(\n        async () => {\n          // Execute step via injected executor (legacy, actions, or hybrid)\n          // tabId is expected to be set by Scheduler in ctx; fallback to active tab if missing\n          let tabId = ctx.tabId;\n          if (typeof tabId !== 'number') {\n            const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n            tabId = tabs?.[0]?.id;\n          }\n          if (typeof tabId !== 'number') {\n            throw new Error('No active tab found for step execution');\n          }\n\n          const execResult = await this.env.stepExecutor.execute(ctx, step, {\n            tabId,\n            runId: this.env.runId,\n            pushLog: (entry) => this.env.logger.push(entry as any),\n            remainingBudgetMs: this.env.getRemainingBudgetMs(),\n          });\n          const result = execResult.result;\n          const remainingBudget = this.env.getRemainingBudgetMs();\n          if (step.type === STEP_TYPES.CLICK || step.type === STEP_TYPES.DBLCLICK) {\n            const after = step.after ?? ({} as NonNullable<StepClick['after']>);\n            if (after.waitForNavigation)\n              await waitForNavigationDone(\n                beforeInfo.url,\n                Math.min(step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, remainingBudget),\n              );\n            else if (after.waitForNetworkIdle) {\n              const totalMs = Math.min(\n                step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,\n                remainingBudget,\n              );\n              const idleMs = Math.min(1500, Math.max(500, Math.floor(totalMs / 3)));\n              await waitForNetworkIdle(totalMs, idleMs);\n            } else\n              await maybeQuickWaitForNav(\n                beforeInfo.url,\n                Math.min(step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS, remainingBudget),\n              );\n          }\n          if (step.type === STEP_TYPES.NAVIGATE || step.type === STEP_TYPES.OPEN_TAB) {\n            await waitForNavigationDone(\n              beforeInfo.url,\n              Math.min(\n                step.timeoutMs ?? ENGINE_CONSTANTS.DEFAULT_WAIT_MS,\n                this.env.getRemainingBudgetMs(),\n              ),\n            );\n            await ensureReadPageIfWeb();\n          } else if (step.type === STEP_TYPES.SWITCH_TAB) {\n            await ensureReadPageIfWeb();\n          }\n          if (!result?.alreadyLogged)\n            this.env.logger.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });\n          try {\n            await this.env.pluginManager.afterStep({\n              runId: this.env.runId,\n              flow: this.env.flow,\n              vars: this.env.vars,\n              step,\n              result,\n            });\n          } catch (e: unknown) {\n            this.env.logger.push({\n              stepId: step.id,\n              status: 'warning',\n              message: `plugin.afterStep error: ${errorMessage(e)}`,\n            });\n          }\n          await appendOverlayOk(step);\n          if (result?.nextLabel) stepNextLabel = String(result.nextLabel);\n          if (result?.control) controlOut = result.control;\n          if (result?.deferAfterScript) this.env.afterScripts.enqueue(result.deferAfterScript);\n          await this.env.afterScripts.flush(ctx, this.env.vars);\n        },\n        async (attempt, e) => {\n          this.env.logger.push({\n            stepId: step.id,\n            status: 'retrying',\n            message: errorMessage(e),\n          });\n          try {\n            await this.env.pluginManager.onRetry({\n              runId: this.env.runId,\n              flow: this.env.flow,\n              vars: this.env.vars,\n              step,\n              error: e,\n              attempt,\n            });\n          } catch (pe: unknown) {\n            this.env.logger.push({\n              stepId: step.id,\n              status: 'warning',\n              message: `plugin.onRetry error: ${errorMessage(pe)}`,\n            });\n          }\n        },\n        {\n          count: Math.max(0, step.retry?.count ?? 0),\n          intervalMs: Math.max(0, step.retry?.intervalMs ?? 0),\n          backoff: step.retry?.backoff || 'none',\n        },\n      );\n    } catch (e: unknown) {\n      this.env.logger.push({\n        stepId: step.id,\n        status: 'failed',\n        message: errorMessage(e),\n        tookMs: Date.now() - t0,\n      });\n      await appendOverlayFail(step, e as ErrorLike);\n      try {\n        const hook = await this.env.pluginManager.onError({\n          runId: this.env.runId,\n          flow: this.env.flow,\n          vars: this.env.vars,\n          step,\n          error: e,\n        });\n        if (hook?.pause) return { status: 'paused' };\n      } catch (pe: unknown) {\n        this.env.logger.push({\n          stepId: step.id,\n          status: 'warning',\n          message: `plugin.onError error: ${errorMessage(pe)}`,\n        });\n      }\n      return { status: 'failed' };\n    }\n    return { status: 'success', nextLabel: stepNextLabel, control: controlOut };\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/runners/subflow-runner.ts",
    "content": "// subflow-runner.ts — execute a subflow (nodes/edges) using DAG traversal with branch support\n\nimport { STEP_TYPES } from 'chrome-mcp-shared';\nimport type { ExecCtx } from '../../nodes';\nimport { RunLogger } from '../logging/run-logger';\nimport { PluginManager } from '../plugins/manager';\nimport { mapDagNodeToStep } from '../../rr-utils';\nimport type { Edge, NodeBase, Step } from '../../types';\nimport { StepRunner } from './step-runner';\nimport { ENGINE_CONSTANTS } from '../constants';\n\nexport interface SubflowEnv {\n  runId: string;\n  flow: any;\n  vars: Record<string, any>;\n  logger: RunLogger;\n  pluginManager: PluginManager;\n  stepRunner: StepRunner;\n}\n\nexport class SubflowRunner {\n  constructor(private env: SubflowEnv) {}\n\n  async runSubflowById(subflowId: string, ctx: ExecCtx, pausedRef: () => boolean): Promise<void> {\n    const sub = (this.env.flow.subflows || {})[subflowId];\n    if (!sub || !Array.isArray(sub.nodes) || sub.nodes.length === 0) return;\n\n    try {\n      await this.env.pluginManager.subflowStart({\n        runId: this.env.runId,\n        flow: this.env.flow,\n        vars: this.env.vars,\n        subflowId,\n      });\n    } catch (e: any) {\n      this.env.logger.push({\n        stepId: `subflow:${subflowId}`,\n        status: 'warning',\n        message: `plugin.subflowStart error: ${e?.message || String(e)}`,\n      });\n    }\n\n    const sNodes: NodeBase[] = sub.nodes;\n    const sEdges: Edge[] = sub.edges || [];\n\n    // Build lookup maps\n    const id2node = new Map(sNodes.map((n) => [n.id, n] as const));\n    const outEdges = new Map<string, Edge[]>();\n    for (const e of sEdges) {\n      if (!outEdges.has(e.from)) outEdges.set(e.from, []);\n      outEdges.get(e.from)!.push(e);\n    }\n\n    // Calculate in-degrees to find root nodes\n    const indeg = new Map<string, number>(sNodes.map((n) => [n.id, 0] as const));\n    for (const e of sEdges) {\n      indeg.set(e.to, (indeg.get(e.to) || 0) + 1);\n    }\n\n    // Find start node: prefer non-trigger nodes with indeg=0\n    const findFirstExecutableRoot = (): string | undefined => {\n      const executableRoot = sNodes.find(\n        (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER,\n      );\n      if (executableRoot) return executableRoot.id;\n\n      // If all roots are triggers, follow default edge to first executable\n      const triggerRoot = sNodes.find((n) => (indeg.get(n.id) || 0) === 0);\n      if (triggerRoot) {\n        const defaultEdge = (outEdges.get(triggerRoot.id) || []).find(\n          (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,\n        );\n        if (defaultEdge) return defaultEdge.to;\n      }\n\n      return sNodes[0]?.id;\n    };\n\n    let currentId: string | undefined = findFirstExecutableRoot();\n    let guard = 0;\n    const maxIterations = ENGINE_CONSTANTS.MAX_ITERATIONS;\n\n    const ok = (s: Step) => this.env.logger.overlayAppend(`✔ ${s.type} (${s.id})`);\n    const fail = (s: Step, e: any) =>\n      this.env.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`);\n\n    while (currentId) {\n      if (pausedRef()) break;\n      if (guard++ >= maxIterations) {\n        this.env.logger.push({\n          stepId: `subflow:${subflowId}`,\n          status: 'warning',\n          message: `Subflow exceeded ${maxIterations} iterations - possible cycle`,\n        });\n        break;\n      }\n\n      const node = id2node.get(currentId);\n      if (!node) break;\n\n      // Skip trigger nodes\n      if (node.type === STEP_TYPES.TRIGGER) {\n        const defaultEdge = (outEdges.get(currentId) || []).find(\n          (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,\n        );\n        if (defaultEdge) {\n          currentId = defaultEdge.to;\n          continue;\n        }\n        break;\n      }\n\n      const step: Step = mapDagNodeToStep(node);\n      const r = await this.env.stepRunner.run(ctx, step, ok, fail);\n\n      if (r.status === 'paused' || pausedRef()) break;\n\n      if (r.status === 'failed') {\n        // Try to find on_error edge\n        const errEdge = (outEdges.get(currentId) || []).find(\n          (e) => e.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR,\n        );\n        if (errEdge) {\n          currentId = errEdge.to;\n          continue;\n        }\n        break;\n      }\n\n      // Determine next edge by label\n      const suggestedLabel = r.nextLabel\n        ? String(r.nextLabel)\n        : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT;\n      const oes = outEdges.get(currentId) || [];\n      const nextEdge =\n        oes.find((e) => (e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === suggestedLabel) ||\n        oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);\n\n      if (!nextEdge) {\n        // Log warning if we expected a labeled edge but couldn't find it\n        if (r.nextLabel && oes.length > 0) {\n          const availableLabels = oes.map((e) => e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);\n          this.env.logger.push({\n            stepId: step.id,\n            status: 'warning',\n            message: `No edge for label '${suggestedLabel}'. Available: [${availableLabels.join(', ')}]`,\n          });\n        }\n        break;\n      }\n      currentId = nextEdge.to;\n    }\n\n    try {\n      await this.env.pluginManager.subflowEnd({\n        runId: this.env.runId,\n        flow: this.env.flow,\n        vars: this.env.vars,\n        subflowId,\n      });\n    } catch (e: any) {\n      this.env.logger.push({\n        stepId: `subflow:${subflowId}`,\n        status: 'warning',\n        message: `plugin.subflowEnd error: ${e?.message || String(e)}`,\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/scheduler.ts",
    "content": "import { STEP_TYPES, TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { Edge, Flow, NodeBase, RunLogEntry, RunResult, Step } from '../types';\nimport {\n  mapDagNodeToStep,\n  topoOrder,\n  ensureTab,\n  expandTemplatesDeep,\n  defaultEdgesOnly,\n} from '../rr-utils';\nimport type { ExecCtx } from '../nodes';\nimport { RunLogger } from './logging/run-logger';\nimport { PluginManager } from './plugins/manager';\nimport type { RunPlugin } from './plugins/types';\nimport { breakpointPlugin } from './plugins/breakpoint';\nimport { evalExpression } from './utils/expression';\nimport { runState } from './state-manager';\nimport { AfterScriptQueue } from './runners/after-script-queue';\nimport { StepRunner } from './runners/step-runner';\nimport { ControlFlowRunner } from './runners/control-flow-runner';\nimport { SubflowRunner } from './runners/subflow-runner';\nimport { ENGINE_CONSTANTS, LOG_STEP_IDS } from './constants';\nimport {\n  DEFAULT_EXECUTION_MODE_CONFIG,\n  createActionsOnlyConfig,\n  createHybridConfig,\n  type ExecutionMode,\n  type ExecutionModeConfig,\n} from './execution-mode';\nimport { createExecutor, type StepExecutorInterface } from './runners/step-executor';\nimport { createReplayActionRegistry } from '../actions/handlers';\n\nexport interface RunOptions {\n  tabTarget?: 'current' | 'new';\n  refresh?: boolean;\n  captureNetwork?: boolean;\n  returnLogs?: boolean;\n  timeoutMs?: number;\n  startUrl?: string;\n  args?: Record<string, any>;\n  startNodeId?: string;\n  plugins?: RunPlugin[];\n\n  /**\n   * Step execution mode switch.\n   * - 'legacy': Use existing nodes/executeStep (default, safest)\n   * - 'hybrid': Try ActionRegistry first, fall back to legacy\n   * - 'actions': Use ActionRegistry exclusively (strict mode)\n   */\n  executionMode?: ExecutionMode;\n\n  /**\n   * Hybrid mode only: allowlist of step types executed via ActionRegistry.\n   * - undefined: use MINIMAL_HYBRID_ACTION_TYPES (safest default)\n   * - []: disable allowlist, fall back to MIGRATED_ACTION_TYPES policy\n   * - ['fill', 'key', ...]: only these types use actions\n   */\n  actionsAllowlist?: string[];\n\n  /**\n   * Hybrid mode only: denylist of step types forced to legacy.\n   * When omitted, createHybridConfig defaults to LEGACY_ONLY_TYPES.\n   */\n  legacyOnlyTypes?: string[];\n}\n\n/**\n * Type guard for ExecutionMode\n */\nfunction isExecutionMode(value: unknown): value is ExecutionMode {\n  return value === 'legacy' || value === 'hybrid' || value === 'actions';\n}\n\n/**\n * Convert array to Set<string>, filtering invalid values\n */\nfunction toStringSet(value: unknown): Set<string> {\n  const result = new Set<string>();\n  if (!Array.isArray(value)) return result;\n  for (const item of value) {\n    if (typeof item === 'string') {\n      const trimmed = item.trim();\n      if (trimmed) result.add(trimmed);\n    }\n  }\n  return result;\n}\n\n/**\n * Build ExecutionModeConfig from RunOptions.\n * Defaults to legacy mode if executionMode is not specified.\n *\n * Note: Only array inputs for actionsAllowlist/legacyOnlyTypes are accepted.\n * Non-array values are ignored to prevent accidental misconfiguration\n * (e.g., passing a string instead of array would unexpectedly widen the allowlist).\n */\nfunction buildExecutionModeConfig(options: RunOptions): ExecutionModeConfig {\n  const mode: ExecutionMode = isExecutionMode(options.executionMode)\n    ? options.executionMode\n    : DEFAULT_EXECUTION_MODE_CONFIG.mode;\n\n  if (mode === 'hybrid') {\n    const overrides: Partial<ExecutionModeConfig> = {};\n    // Only apply override if it's a valid array\n    // This prevents misconfiguration from widening the actions scope\n    if (Array.isArray(options.actionsAllowlist)) {\n      overrides.actionsAllowlist = toStringSet(options.actionsAllowlist);\n    }\n    if (Array.isArray(options.legacyOnlyTypes)) {\n      overrides.legacyOnlyTypes = toStringSet(options.legacyOnlyTypes);\n    }\n    return createHybridConfig(overrides);\n  }\n\n  if (mode === 'actions') {\n    return createActionsOnlyConfig();\n  }\n\n  // Default: legacy mode\n  return { ...DEFAULT_EXECUTION_MODE_CONFIG };\n}\n\n/**\n * ExecutionOrchestrator manages the lifecycle of a flow execution.\n *\n * Architecture:\n * - Creates StepExecutor based on ExecutionModeConfig (legacy by default)\n * - Injects StepExecutor into StepRunner for step execution\n * - Manages tabId and passes it through ExecCtx\n * - Handles DAG traversal, control flow, and cleanup\n */\nclass ExecutionOrchestrator {\n  private readonly runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n  private readonly startAt = Date.now();\n  private readonly logger = new RunLogger(this.runId);\n  private readonly pluginManager: PluginManager;\n  private readonly afterScripts = new AfterScriptQueue(this.logger);\n\n  // Execution mode configuration (defaults to legacy for safety)\n  private readonly executionModeConfig: ExecutionModeConfig;\n  private readonly stepExecutor: StepExecutorInterface;\n\n  // Runtime state\n  private vars: Record<string, any> = Object.create(null);\n  private tabId: number | null = null;\n  private deadline = 0;\n  private networkCaptureStarted = false;\n  private paused = false;\n  private failed = 0;\n  private executed = 0;\n  private steps: Step[] = [];\n  private prepareError: RunResult | null = null;\n\n  // Runners\n  private stepRunner: StepRunner;\n  private controlFlowRunner!: ControlFlowRunner;\n  private subflowRunner!: SubflowRunner;\n\n  constructor(\n    private flow: Flow,\n    private options: RunOptions = {},\n  ) {\n    // Initialize variables from flow defaults and args\n    for (const v of flow.variables || []) {\n      if (v.default !== undefined) this.vars[v.key] = v.default;\n    }\n    if (options.args) Object.assign(this.vars, options.args);\n\n    // Set up global deadline\n    const globalTimeout = Math.max(0, Number(options.timeoutMs || 0));\n    this.deadline = globalTimeout > 0 ? this.startAt + globalTimeout : 0;\n\n    // Initialize plugin manager\n    this.pluginManager = new PluginManager(\n      options.plugins && options.plugins.length ? options.plugins : [breakpointPlugin()],\n    );\n\n    // Create step executor based on execution mode configuration\n    // Default to legacy mode for maximum safety during migration\n    this.executionModeConfig = buildExecutionModeConfig(options);\n\n    // Only create ActionRegistry when needed (hybrid or actions mode)\n    // This avoids unnecessary initialization overhead in legacy mode\n    const registry =\n      this.executionModeConfig.mode === 'legacy' ? undefined : createReplayActionRegistry();\n    this.stepExecutor = createExecutor(this.executionModeConfig, registry);\n\n    // Initialize step runner with injected executor\n    this.stepRunner = new StepRunner({\n      runId: this.runId,\n      flow: this.flow,\n      vars: this.vars,\n      logger: this.logger,\n      pluginManager: this.pluginManager,\n      afterScripts: this.afterScripts,\n      getRemainingBudgetMs: () =>\n        this.deadline > 0 ? Math.max(0, this.deadline - Date.now()) : Number.POSITIVE_INFINITY,\n      stepExecutor: this.stepExecutor,\n    });\n  }\n\n  private ensureWithinDeadline() {\n    if (this.deadline > 0 && Date.now() > this.deadline) {\n      const err = new Error('Global timeout reached');\n      this.logger.push({\n        stepId: LOG_STEP_IDS.GLOBAL_TIMEOUT,\n        status: 'failed',\n        message: 'Global timeout reached',\n      });\n      throw err;\n    }\n  }\n\n  async run(): Promise<RunResult> {\n    try {\n      await this.prepareExecution();\n      if (this.prepareError) return this.prepareError;\n      return await this.traverseDag();\n    } finally {\n      await this.cleanup();\n    }\n  }\n\n  private async prepareExecution() {\n    // Derive default startUrl\n    let derivedStartUrl: string | undefined;\n    try {\n      const hasDag0 = Array.isArray(this.flow.nodes) && (this.flow.nodes?.length || 0) > 0;\n      const nodes0: NodeBase[] = hasDag0 ? this.flow.nodes || [] : [];\n      const edges0: Edge[] = hasDag0 ? this.flow.edges || [] : [];\n      const defaultEdges0 = hasDag0 ? defaultEdgesOnly(edges0) : [];\n      const order0 = hasDag0 ? topoOrder(nodes0, defaultEdges0) : [];\n      const steps0: Step[] = hasDag0 ? order0.map((n) => mapDagNodeToStep(n)) : [];\n      const nav = steps0.find((s) => s.type === STEP_TYPES.NAVIGATE);\n      if (nav && nav.type === STEP_TYPES.NAVIGATE)\n        derivedStartUrl = expandTemplatesDeep(nav.url, this.vars);\n    } catch {\n      // ignore: best-effort derive startUrl\n    }\n\n    const ensured = await ensureTab({\n      tabTarget: this.options.tabTarget,\n      startUrl: this.options.startUrl || derivedStartUrl,\n      refresh: this.options.refresh,\n    });\n    // Capture tabId for use in ExecCtx\n    this.tabId = ensured?.tabId ?? null;\n\n    // register run state\n    await runState.restore();\n    await runState.add(this.runId, {\n      id: this.runId,\n      flowId: this.flow.id,\n      name: this.flow.name,\n      status: 'running',\n      startedAt: this.startAt,\n      updatedAt: this.startAt,\n    });\n\n    try {\n      await this.pluginManager.runStart({ runId: this.runId, flow: this.flow, vars: this.vars });\n    } catch (e: any) {\n      this.logger.push({\n        stepId: LOG_STEP_IDS.PLUGIN_RUN_START,\n        status: 'warning',\n        message: e?.message || String(e),\n      });\n    }\n\n    // pre-load read_page when on web\n    try {\n      const u = ensured?.url || '';\n      if (/^(https?:|file:)/i.test(u))\n        await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    } catch {\n      // ignore: preloading read_page is best-effort\n    }\n\n    // overlay variable collection\n    try {\n      const needed = (this.flow.variables || []).filter(\n        (v) =>\n          (this.options.args?.[v.key] == null || this.options.args?.[v.key] === '') &&\n          (v.rules?.required || (v.default ?? '') === ''),\n      );\n      if (needed.length) {\n        const res = await handleCallTool({\n          name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,\n          args: {\n            eventName: TOOL_MESSAGE_TYPES.COLLECT_VARIABLES,\n            payload: JSON.stringify({ variables: needed, useOverlay: true }),\n          },\n        });\n        let values: Record<string, any> | null = null;\n        try {\n          const t = (res?.content || []).find((c: any) => c.type === 'text')?.text;\n          const j = t ? JSON.parse(t) : null;\n          if (j && j.success && j.values) values = j.values;\n        } catch {\n          // ignore: parse result from tool response\n        }\n        if (!values) {\n          const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n          const tabId = tabs?.[0]?.id;\n          if (typeof tabId === 'number') {\n            const res2 = await chrome.tabs.sendMessage(tabId, {\n              action: TOOL_MESSAGE_TYPES.COLLECT_VARIABLES,\n              variables: needed,\n              useOverlay: true,\n            });\n            if (res2 && res2.success && res2.values) values = res2.values;\n          }\n        }\n        if (values) Object.assign(this.vars, values);\n        else\n          this.logger.push({\n            stepId: LOG_STEP_IDS.VARIABLE_COLLECT,\n            status: 'warning',\n            message: 'Variable collection failed; using provided args/defaults',\n          });\n      }\n    } catch {\n      // ignore: variable collection is optional\n    }\n\n    await this.logger.overlayInit();\n\n    // binding enforcement\n    try {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const currentUrl = tabs?.[0]?.url || '';\n      const bindings = this.flow.meta?.bindings || [];\n      if (!this.options.startUrl && bindings.length > 0) {\n        const ok = bindings.some((b) => {\n          try {\n            if (b.type === 'domain') return new URL(currentUrl).hostname.includes(b.value);\n            if (b.type === 'path') return new URL(currentUrl).pathname.startsWith(b.value);\n            if (b.type === 'url') return currentUrl.startsWith(b.value);\n          } catch {\n            // ignore: URL parsing for binding check\n          }\n          return false;\n        });\n        if (!ok) {\n          this.prepareError = {\n            runId: this.runId,\n            success: false,\n            summary: { total: 0, success: 0, failed: 0, tookMs: 0 },\n            url: currentUrl,\n            outputs: null,\n            logs: [\n              {\n                stepId: LOG_STEP_IDS.BINDING_CHECK,\n                status: 'failed',\n                message:\n                  'Flow binding mismatch. Provide startUrl or open a page matching flow.meta.bindings.',\n              },\n            ],\n            screenshots: { onFailure: null },\n            paused: false,\n          };\n          return;\n        }\n      }\n    } catch {\n      // ignore: binding enforcement failures fall back to default behavior\n    }\n\n    // network capture start\n    if (this.options.captureNetwork) {\n      try {\n        const res = await handleCallTool({\n          name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,\n          args: { includeStatic: false, maxCaptureTime: 3 * 60_000, inactivityTimeout: 0 },\n        });\n        let started = false;\n        try {\n          const t = res?.content?.find?.((c: any) => c.type === 'text')?.text;\n          if (t) {\n            const j = JSON.parse(t);\n            started = !!j?.success;\n          }\n        } catch {\n          // ignore: parse network debugger start response\n        }\n        this.networkCaptureStarted = started;\n        if (!started) {\n          this.logger.push({\n            stepId: LOG_STEP_IDS.NETWORK_CAPTURE,\n            status: 'warning',\n            message: 'Failed to confirm network capture start',\n          });\n        }\n      } catch (e: any) {\n        this.logger.push({\n          stepId: LOG_STEP_IDS.NETWORK_CAPTURE,\n          status: 'warning',\n          message: e?.message || 'Network capture start errored',\n        });\n      }\n    }\n\n    // build DAG steps\n    const hasDag = Array.isArray(this.flow.nodes) && (this.flow.nodes?.length || 0) > 0;\n    if (!hasDag) {\n      this.prepareError = {\n        runId: this.runId,\n        success: false,\n        summary: { total: 0, success: 0, failed: 0, tookMs: 0 },\n        url: null,\n        outputs: null,\n        logs: [\n          {\n            stepId: LOG_STEP_IDS.DAG_REQUIRED,\n            status: 'failed',\n            message:\n              'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.',\n          },\n        ],\n        screenshots: { onFailure: null },\n        paused: false,\n      };\n      return;\n    }\n    const nodes: NodeBase[] = (this.flow.nodes || []) as NodeBase[];\n    const edges: Edge[] = (this.flow.edges || []) as Edge[];\n    // Validate DAG for potential cycles on full edge set\n    try {\n      if (this.hasCycle(nodes, edges)) {\n        this.prepareError = {\n          runId: this.runId,\n          success: false,\n          summary: { total: 0, success: 0, failed: 0, tookMs: 0 },\n          url: null,\n          outputs: null,\n          logs: [\n            {\n              stepId: LOG_STEP_IDS.DAG_CYCLE,\n              status: 'failed',\n              message:\n                'Flow DAG contains a cycle. Please break the cycle or add explicit labels/branches to avoid infinite loops.',\n            },\n          ],\n          screenshots: { onFailure: null },\n          paused: false,\n        };\n        return;\n      }\n    } catch {\n      // ignore: cycle detection guard\n    }\n    const defaultEdges = defaultEdgesOnly(edges);\n    const order = topoOrder(nodes, defaultEdges);\n    // Filter out trigger nodes - they are configuration nodes, not executable steps\n    this.steps = order.filter((n) => n.type !== STEP_TYPES.TRIGGER).map((n) => mapDagNodeToStep(n));\n    // initialize runners\n    this.subflowRunner = new SubflowRunner({\n      runId: this.runId,\n      flow: this.flow,\n      vars: this.vars,\n      logger: this.logger,\n      pluginManager: this.pluginManager,\n      stepRunner: this.stepRunner,\n    });\n    this.controlFlowRunner = new ControlFlowRunner({\n      vars: this.vars,\n      logger: this.logger,\n      evalCondition: (c) => this.evalCondition(c),\n      runSubflowById: (id, ctx) => this.subflowRunner.runSubflowById(id, ctx, () => this.paused),\n      isPaused: () => this.paused,\n    });\n  }\n\n  // Basic cycle detection using DFS coloring on the full edge set\n  private hasCycle(\n    nodes: Array<{ id: string }>,\n    edges: Array<{ from: string; to: string }>,\n  ): boolean {\n    const adj = new Map<string, string[]>();\n    for (const n of nodes) adj.set(n.id, []);\n    for (const e of edges) {\n      if (!adj.has(e.from)) adj.set(e.from, []);\n      adj.get(e.from)!.push(e.to);\n    }\n    const color = new Map<string, number>(); // 0=unvisited,1=visiting,2=done\n    const visit = (u: string): boolean => {\n      const c = color.get(u) || 0;\n      if (c === 1) return true; // back-edge\n      if (c === 2) return false;\n      color.set(u, 1);\n      for (const v of adj.get(u) || []) if (visit(v)) return true;\n      color.set(u, 2);\n      return false;\n    };\n    for (const n of nodes) if ((color.get(n.id) || 0) === 0 && visit(n.id)) return true;\n    return false;\n  }\n\n  private async traverseDag(): Promise<RunResult> {\n    if (!this.steps.length) {\n      await this.logger.overlayDone();\n      const tookMs0 = Date.now() - this.startAt;\n      return (\n        this.prepareError || {\n          runId: this.runId,\n          success: false,\n          summary: { total: 0, success: 0, failed: 0, tookMs: tookMs0 },\n          url: null,\n          outputs: null,\n          logs: this.options.returnLogs ? this.logger.getLogs() : undefined,\n          screenshots: { onFailure: null },\n          paused: false,\n        }\n      );\n    }\n    const nodes: NodeBase[] = this.flow.nodes || [];\n    const edges: Edge[] = this.flow.edges || [];\n    const id2node = new Map(nodes.map((n) => [n.id, n] as const));\n    const outEdges = new Map<string, Array<Edge>>();\n    for (const e of edges) {\n      if (!outEdges.has(e.from)) outEdges.set(e.from, []);\n      outEdges.get(e.from)!.push(e);\n    }\n    const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));\n    for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);\n    // Find start node: prefer non-trigger nodes with indeg=0\n    // Trigger nodes are configuration nodes and should be skipped\n    const findFirstExecutableRoot = (): string | undefined => {\n      // First try to find a non-trigger root node\n      const executableRoot = nodes.find(\n        (n) => (indeg.get(n.id) || 0) === 0 && n.type !== STEP_TYPES.TRIGGER,\n      );\n      if (executableRoot) return executableRoot.id;\n\n      // If all roots are triggers, find one and follow default edge to first executable\n      const triggerRoot = nodes.find((n) => (indeg.get(n.id) || 0) === 0);\n      if (triggerRoot) {\n        const defaultEdge = (outEdges.get(triggerRoot.id) || []).find(\n          (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,\n        );\n        if (defaultEdge) return defaultEdge.to;\n      }\n\n      // Fallback to first node\n      return nodes[0]?.id;\n    };\n\n    let currentId: string | undefined =\n      this.options.startNodeId && id2node.has(this.options.startNodeId)\n        ? this.options.startNodeId\n        : findFirstExecutableRoot();\n    let guard = 0;\n\n    // Create execution context with tabId from ensureTab\n    // tabId is managed by Scheduler and may be updated by openTab/switchTab actions\n    const ctx: ExecCtx = {\n      vars: this.vars,\n      tabId: this.tabId ?? undefined,\n      logger: (e: RunLogEntry) => this.logger.push(e),\n    };\n    if (currentId) {\n      try {\n        await this.logger.overlayAppend(\n          `▶ start at ${id2node.get(currentId)?.type || ''} (${currentId})`,\n        );\n      } catch {\n        // ignore: eval condition failure treated as false\n      }\n    }\n    while (currentId) {\n      this.ensureWithinDeadline();\n      if (guard++ >= ENGINE_CONSTANTS.MAX_ITERATIONS) {\n        this.logger.push({\n          stepId: LOG_STEP_IDS.LOOP_GUARD,\n          status: 'failed',\n          message: `Exceeded ${ENGINE_CONSTANTS.MAX_ITERATIONS} iterations - possible cycle in DAG`,\n        });\n        this.failed++;\n        break;\n      }\n      const node = id2node.get(currentId);\n      if (!node) break;\n\n      // Skip trigger nodes - they are configuration nodes, not executable steps\n      // Follow default edge to the next executable node\n      if (node.type === STEP_TYPES.TRIGGER) {\n        try {\n          await this.logger.overlayAppend(`⏭ skip trigger (${node.id})`);\n        } catch {}\n        const defaultEdge = (outEdges.get(currentId) || []).find(\n          (e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT,\n        );\n        if (defaultEdge) {\n          currentId = defaultEdge.to;\n          continue;\n        }\n        // No successor after trigger - end execution\n        this.logger.push({\n          stepId: node.id,\n          status: 'warning',\n          message: 'Trigger node has no successor - nothing to execute',\n        });\n        break;\n      }\n\n      const step: Step = mapDagNodeToStep(node);\n      // lightweight trace to aid debugging edge traversal\n      try {\n        await this.logger.overlayAppend(`→ ${step.type} (${step.id})`);\n      } catch {\n        // ignore: stopping network capture is best-effort\n      }\n      // Count this step as executed (regardless of success/failure)\n      this.executed++;\n\n      const r = await this.stepRunner.run(\n        ctx,\n        step,\n        (s) => this.logger.overlayAppend(`✔ ${s.type} (${s.id})`),\n        (s, e) => this.logger.overlayAppend(`✘ ${s.type} (${s.id}) -> ${e?.message || String(e)}`),\n      );\n      if (r.status === 'paused') {\n        this.paused = true;\n        break;\n      }\n      if (r.status === 'failed') {\n        this.failed++;\n        const oes = (outEdges.get(currentId) || []) as Edge[];\n        const errEdge = oes.find((edg) => edg.label === ENGINE_CONSTANTS.EDGE_LABELS.ON_ERROR);\n        if (errEdge) {\n          currentId = errEdge.to;\n          continue;\n        } else {\n          break;\n        }\n      }\n      if (r.control) {\n        const control = r.control;\n        const st = await this.controlFlowRunner.run(control, ctx);\n        if (st === 'paused') {\n          this.paused = true;\n          break;\n        }\n        const suggested = r.nextLabel ? String(r.nextLabel) : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT;\n        const next = await this.advanceToNext(currentId, step, suggested, id2node, outEdges);\n        if (!next) break;\n        currentId = next;\n        continue;\n      }\n      // choose next by label\n      {\n        const suggested = r.nextLabel ? String(r.nextLabel) : ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT;\n        const next = await this.advanceToNext(currentId, step, suggested, id2node, outEdges);\n        if (!next) break;\n        currentId = next;\n      }\n    }\n    const tookMs = Date.now() - this.startAt;\n    const sensitiveKeys = new Set(\n      (this.flow.variables || []).filter((v) => v.sensitive).map((v) => v.key),\n    );\n    const outputs: Record<string, any> = {};\n    for (const [k, v] of Object.entries(this.vars)) if (!sensitiveKeys.has(k)) outputs[k] = v;\n    return {\n      runId: this.runId,\n      success: !this.paused && this.failed === 0,\n      summary: {\n        total: this.executed,\n        success: this.executed - this.failed,\n        failed: this.failed,\n        tookMs,\n      },\n      url: null,\n      outputs,\n      logs: this.options.returnLogs ? this.logger.getLogs() : undefined,\n      screenshots: {\n        onFailure: this.logger.getLogs().find((l) => l.status === 'failed')?.screenshotBase64,\n      },\n      paused: this.paused,\n    };\n  }\n\n  // Advance to next node by suggested label, with overlay/logging and fallback to default edge.\n  private async advanceToNext(\n    currentId: string,\n    step: Step,\n    suggested: string,\n    id2node: Map<string, NodeBase>,\n    outEdges: Map<string, Array<Edge>>,\n  ): Promise<string | undefined> {\n    const nextLabel = await this.chooseNextLabel(step, suggested);\n    const nextId = this.findNextNodeId(currentId, outEdges, nextLabel);\n    if (nextId) {\n      try {\n        await this.logger.overlayAppend(\n          `↪ next(${nextLabel}) → ${id2node.get(nextId)?.type || ''} (${nextId})`,\n        );\n      } catch {}\n      return nextId;\n    }\n    const labels = (outEdges.get(currentId) || []).map((e) =>\n      String(e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT),\n    );\n    this.logger.push({\n      stepId: step.id,\n      status: 'warning',\n      message: `No next edge for label '${nextLabel}'. Outgoing labels: [${labels.join(', ')}]`,\n    });\n    return undefined;\n  }\n\n  // Decide next label, allowing plugins to override; logs plugin errors as warnings\n  private async chooseNextLabel(step: Step, suggested: string): Promise<string> {\n    try {\n      const override = await this.pluginManager.onChooseNextLabel({\n        runId: this.runId,\n        flow: this.flow,\n        vars: this.vars,\n        step,\n        suggested,\n      });\n      return override ? String(override) : suggested;\n    } catch (e: any) {\n      this.logger.push({\n        stepId: step.id,\n        status: 'warning',\n        message: `plugin.onChooseNextLabel error: ${e?.message || String(e)}`,\n      });\n      return suggested;\n    }\n  }\n\n  // From current node and label, pick next nodeId using outEdges; prefers labeled edge then default\n  private findNextNodeId(\n    currentId: string,\n    outEdges: Map<string, Array<Edge>>,\n    nextLabel: string,\n  ): string | undefined {\n    const oes = (outEdges.get(currentId) || []) as Edge[];\n    const edge =\n      oes.find((e) => String(e.label || ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT) === nextLabel) ||\n      oes.find((e) => !e.label || e.label === ENGINE_CONSTANTS.EDGE_LABELS.DEFAULT);\n    return edge ? edge.to : undefined;\n  }\n\n  private evalCondition(cond: any): boolean {\n    try {\n      if (cond && typeof cond.expression === 'string' && cond.expression.trim()) {\n        return !!evalExpression(String(cond.expression), { vars: this.vars });\n      }\n      if (cond && typeof cond.var === 'string') {\n        const v = this.vars[cond.var];\n        if ('equals' in cond) return String(v) === String(cond.equals);\n        return !!v;\n      }\n    } catch {\n      // ignore: cleanup guard\n    }\n    return false;\n  }\n\n  private async cleanup() {\n    if (this.networkCaptureStarted) {\n      try {\n        const stopRes = await handleCallTool({\n          name: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,\n          args: {},\n        });\n        const text = (stopRes?.content || []).find((c: any) => c.type === 'text')?.text;\n        if (text) {\n          try {\n            const data = JSON.parse(text);\n            const requests: any[] = Array.isArray(data?.requests) ? data.requests : [];\n            const snippets = requests\n              .filter((r) => ['XHR', 'Fetch'].includes(String(r.type)))\n              .slice(0, 10)\n              .map((r) => ({\n                method: String(r.method || 'GET'),\n                url: String(r.url || ''),\n                status: r.statusCode || r.status,\n                ms: Math.max(0, (r.responseTime || 0) - (r.requestTime || 0)),\n              }));\n            this.logger.push({\n              stepId: LOG_STEP_IDS.NETWORK_CAPTURE,\n              status: 'success',\n              message: `Captured ${Number(data?.requestCount || 0)} requests`,\n              networkSnippets: snippets,\n            });\n          } catch (e: any) {\n            this.logger.push({\n              stepId: LOG_STEP_IDS.NETWORK_CAPTURE,\n              status: 'warning',\n              message: `Failed parsing network capture result: ${e?.message || String(e)}`,\n            });\n          }\n        }\n      } catch {}\n    }\n    await this.logger.overlayDone();\n    try {\n      try {\n        await this.pluginManager.runEnd({\n          runId: this.runId,\n          flow: this.flow,\n          vars: this.vars,\n          success: this.failed === 0 && !this.paused,\n          failed: this.failed,\n        });\n      } catch (e: any) {\n        this.logger.push({\n          stepId: LOG_STEP_IDS.PLUGIN_RUN_END,\n          status: 'warning',\n          message: e?.message || String(e),\n        });\n      }\n      if (!this.paused) await this.logger.persist(this.flow, this.startAt, this.failed === 0);\n      try {\n        await runState.update(this.runId, {\n          status: this.paused ? 'stopped' : this.failed === 0 ? 'completed' : 'failed',\n          updatedAt: Date.now(),\n        });\n      } catch (e: any) {\n        this.logger.push({\n          stepId: LOG_STEP_IDS.RUNSTATE_UPDATE,\n          status: 'warning',\n          message: e?.message || String(e),\n        });\n      }\n      try {\n        if (!this.paused) await runState.delete(this.runId);\n      } catch (e: any) {\n        this.logger.push({\n          stepId: LOG_STEP_IDS.RUNSTATE_DELETE,\n          status: 'warning',\n          message: e?.message || String(e),\n        });\n      }\n    } catch {}\n  }\n}\n\nexport async function runFlow(flow: Flow, options: RunOptions = {}): Promise<RunResult> {\n  const orchestrator = new ExecutionOrchestrator(flow, options);\n  return await orchestrator.run();\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/state-manager.ts",
    "content": "// engine/state-manager.ts — lightweight run state store with events and persistence\n\ntype Listener<T> = (payload: T) => void;\n\nexport interface RunState {\n  id: string;\n  flowId: string;\n  name?: string;\n  status: 'running' | 'completed' | 'failed' | 'stopped';\n  startedAt: number;\n  updatedAt: number;\n}\n\nexport class StateManager<T extends { id: string }> {\n  private key: string;\n  private states = new Map<string, T>();\n  private listeners: Record<string, Listener<any>[]> = Object.create(null);\n\n  constructor(storageKey: string) {\n    this.key = storageKey;\n  }\n\n  on<E = any>(name: string, listener: Listener<E>) {\n    (this.listeners[name] = this.listeners[name] || []).push(listener);\n  }\n\n  off<E = any>(name: string, listener: Listener<E>) {\n    const arr = this.listeners[name];\n    if (!arr) return;\n    const i = arr.indexOf(listener as any);\n    if (i >= 0) arr.splice(i, 1);\n  }\n\n  private emit<E = any>(name: string, payload: E) {\n    const arr = this.listeners[name] || [];\n    for (const fn of arr)\n      try {\n        fn(payload);\n      } catch {}\n  }\n\n  getAll(): Map<string, T> {\n    return this.states;\n  }\n\n  get(id: string): T | undefined {\n    return this.states.get(id);\n  }\n\n  async add(id: string, data: T): Promise<void> {\n    this.states.set(id, data);\n    this.emit('add', { id, data });\n    await this.persist();\n  }\n\n  async update(id: string, patch: Partial<T>): Promise<void> {\n    const cur = this.states.get(id);\n    if (!cur) return;\n    const next = Object.assign({}, cur, patch);\n    this.states.set(id, next);\n    this.emit('update', { id, data: next });\n    await this.persist();\n  }\n\n  async delete(id: string): Promise<void> {\n    this.states.delete(id);\n    this.emit('delete', { id });\n    await this.persist();\n  }\n\n  private async persist(): Promise<void> {\n    try {\n      const obj = Object.fromEntries(this.states.entries());\n      await chrome.storage.local.set({ [this.key]: obj });\n    } catch {}\n  }\n\n  async restore(): Promise<void> {\n    try {\n      const res = await chrome.storage.local.get(this.key);\n      const obj = (res && res[this.key]) || {};\n      this.states = new Map(Object.entries(obj) as any);\n    } catch {}\n  }\n}\n\nexport const runState = new StateManager<RunState>('rr_run_states');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/engine/utils/expression.ts",
    "content": "// expression.ts — minimal safe boolean expression evaluator (no access to global scope)\n// Supported:\n// - Literals: numbers (123, 1.23), strings ('x' or \"x\"), booleans (true/false)\n// - Variables: vars.x, vars.a.b (only reads from provided vars object)\n// - Operators: !, &&, ||, ==, !=, >, >=, <, <=, +, -, *, /\n// - Parentheses: ( ... )\n\ntype Token = { type: string; value?: any };\n\nfunction tokenize(input: string): Token[] {\n  const s = input.trim();\n  const out: Token[] = [];\n  let i = 0;\n  const isAlpha = (c: string) => /[a-zA-Z_]/.test(c);\n  const isNum = (c: string) => /[0-9]/.test(c);\n  const isIdChar = (c: string) => /[a-zA-Z0-9_]/.test(c);\n  while (i < s.length) {\n    const c = s[i];\n    if (c === ' ' || c === '\\t' || c === '\\n' || c === '\\r') {\n      i++;\n      continue;\n    }\n    // operators\n    if (\n      s.startsWith('&&', i) ||\n      s.startsWith('||', i) ||\n      s.startsWith('==', i) ||\n      s.startsWith('!=', i) ||\n      s.startsWith('>=', i) ||\n      s.startsWith('<=', i)\n    ) {\n      out.push({ type: 'op', value: s.slice(i, i + 2) });\n      i += 2;\n      continue;\n    }\n    if ('!+-*/()<>'.includes(c)) {\n      out.push({ type: 'op', value: c });\n      i++;\n      continue;\n    }\n    // number\n    if (isNum(c) || (c === '.' && isNum(s[i + 1] || ''))) {\n      let j = i + 1;\n      while (j < s.length && (isNum(s[j]) || s[j] === '.')) j++;\n      out.push({ type: 'num', value: parseFloat(s.slice(i, j)) });\n      i = j;\n      continue;\n    }\n    // string\n    if (c === '\"' || c === \"'\") {\n      const quote = c;\n      let j = i + 1;\n      let str = '';\n      while (j < s.length) {\n        if (s[j] === '\\\\' && j + 1 < s.length) {\n          str += s[j + 1];\n          j += 2;\n        } else if (s[j] === quote) {\n          j++;\n          break;\n        } else {\n          str += s[j++];\n        }\n      }\n      out.push({ type: 'str', value: str });\n      i = j;\n      continue;\n    }\n    // identifier (vars or true/false)\n    if (isAlpha(c)) {\n      let j = i + 1;\n      while (j < s.length && isIdChar(s[j])) j++;\n      let id = s.slice(i, j);\n      // dotted path\n      while (s[j] === '.' && isAlpha(s[j + 1] || '')) {\n        let k = j + 1;\n        while (k < s.length && isIdChar(s[k])) k++;\n        id += s.slice(j, k);\n        j = k;\n      }\n      out.push({ type: 'id', value: id });\n      i = j;\n      continue;\n    }\n    // unknown token, skip to avoid crash\n    i++;\n  }\n  return out;\n}\n\n// Recursive descent parser\nexport function evalExpression(expr: string, scope: { vars: Record<string, any> }): any {\n  const tokens = tokenize(expr);\n  let i = 0;\n  const peek = () => tokens[i];\n  const consume = () => tokens[i++];\n\n  function parsePrimary(): any {\n    const t = peek();\n    if (!t) return undefined;\n    if (t.type === 'num') {\n      consume();\n      return t.value;\n    }\n    if (t.type === 'str') {\n      consume();\n      return t.value;\n    }\n    if (t.type === 'id') {\n      consume();\n      const id = String(t.value);\n      if (id === 'true') return true;\n      if (id === 'false') return false;\n      // Only allow vars.* lookups\n      if (!id.startsWith('vars')) return undefined;\n      try {\n        const parts = id.split('.').slice(1);\n        let cur: any = scope.vars;\n        for (const p of parts) {\n          if (cur == null) return undefined;\n          cur = cur[p];\n        }\n        return cur;\n      } catch {\n        return undefined;\n      }\n    }\n    if (t.type === 'op' && t.value === '(') {\n      consume();\n      const v = parseOr();\n      if (peek()?.type === 'op' && peek()?.value === ')') consume();\n      return v;\n    }\n    return undefined;\n  }\n\n  function parseUnary(): any {\n    const t = peek();\n    if (t && t.type === 'op' && (t.value === '!' || t.value === '-')) {\n      consume();\n      const v = parseUnary();\n      return t.value === '!' ? !truthy(v) : -Number(v || 0);\n    }\n    return parsePrimary();\n  }\n\n  function parseMulDiv(): any {\n    let v = parseUnary();\n    while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {\n      const op = consume().value;\n      const r = parseUnary();\n      v = op === '*' ? Number(v || 0) * Number(r || 0) : Number(v || 0) / Number(r || 0);\n    }\n    return v;\n  }\n\n  function parseAddSub(): any {\n    let v = parseMulDiv();\n    while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {\n      const op = consume().value;\n      const r = parseMulDiv();\n      v = op === '+' ? Number(v || 0) + Number(r || 0) : Number(v || 0) - Number(r || 0);\n    }\n    return v;\n  }\n\n  function parseRel(): any {\n    let v = parseAddSub();\n    while (peek() && peek().type === 'op' && ['>', '>=', '<', '<='].includes(peek().value)) {\n      const op = consume().value as string;\n      const r = parseAddSub();\n      const a = toComparable(v);\n      const b = toComparable(r);\n      if (op === '>') v = (a as any) > (b as any);\n      else if (op === '>=') v = (a as any) >= (b as any);\n      else if (op === '<') v = (a as any) < (b as any);\n      else v = (a as any) <= (b as any);\n    }\n    return v;\n  }\n\n  function parseEq(): any {\n    let v = parseRel();\n    while (peek() && peek().type === 'op' && (peek().value === '==' || peek().value === '!=')) {\n      const op = consume().value as string;\n      const r = parseRel();\n      const a = toComparable(v);\n      const b = toComparable(r);\n      v = op === '==' ? a === b : a !== b;\n    }\n    return v;\n  }\n\n  function parseAnd(): any {\n    let v = parseEq();\n    while (peek() && peek().type === 'op' && peek().value === '&&') {\n      consume();\n      const r = parseEq();\n      v = truthy(v) && truthy(r);\n    }\n    return v;\n  }\n\n  function parseOr(): any {\n    let v = parseAnd();\n    while (peek() && peek().type === 'op' && peek().value === '||') {\n      consume();\n      const r = parseAnd();\n      v = truthy(v) || truthy(r);\n    }\n    return v;\n  }\n\n  function truthy(v: any) {\n    return !!v;\n  }\n  function toComparable(v: any) {\n    return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : String(v);\n  }\n\n  try {\n    const res = parseOr();\n    return res;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/flow-runner.ts",
    "content": "// thin re-export for backward compatibility\nexport { runFlow } from './engine/scheduler';\nexport type { RunOptions } from './engine/scheduler';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/flow-store.ts",
    "content": "import type { Flow, RunRecord, NodeBase, Edge } from './types';\nimport { stepsToDAG, type RRNode, type RREdge } from 'chrome-mcp-shared';\nimport { NODE_TYPES } from '@/common/node-types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager';\n\n// Design note: IndexedDB-backed store for flows and run records.\n// Includes lazy migration from chrome.storage.local for backwards compatibility.\n\n// Validate if a type string is a valid NodeType\nconst VALID_NODE_TYPES = new Set<string>(Object.values(NODE_TYPES));\nfunction isValidNodeType(type: string): boolean {\n  return VALID_NODE_TYPES.has(type);\n}\n\n// Convert RRNode to NodeBase (ui coordinates are optional, not added here)\nfunction toNodeBase(node: RRNode): NodeBase {\n  return {\n    id: node.id,\n    type: isValidNodeType(node.type) ? (node.type as NodeBase['type']) : NODE_TYPES.SCRIPT,\n    config: node.config,\n  };\n}\n\n// Convert RREdge to Edge\nfunction toEdge(edge: RREdge): Edge {\n  return {\n    id: edge.id,\n    from: edge.from,\n    to: edge.to,\n    label: edge.label,\n  };\n}\n\n/**\n * Filter edges to only keep those whose from/to both exist in nodeIds.\n * Prevents topoOrder crash when edges reference non-existent nodes.\n */\nfunction filterValidEdges(edges: Edge[], nodeIds: Set<string>): Edge[] {\n  return edges.filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to));\n}\n\n// =============================================================================\n// UI Notification\n// =============================================================================\n\n/**\n * Timer handle for coalescing flow change notifications.\n * Prevents multiple rapid changes (e.g., during import) from flooding UI.\n */\nlet flowsChangedTimer: ReturnType<typeof setTimeout> | undefined;\n\n/**\n * Notify UI that flows have changed.\n * Uses a short debounce (50ms) to coalesce rapid changes.\n */\nfunction notifyFlowsChanged(): void {\n  // If timer is already scheduled, skip (will be handled by pending timer)\n  if (flowsChangedTimer !== undefined) return;\n\n  flowsChangedTimer = setTimeout(() => {\n    flowsChangedTimer = undefined;\n    try {\n      // Send message to all extension contexts (popup, sidepanel, etc.)\n      // Use void cast to avoid unhandled promise rejection\n      void chrome.runtime\n        .sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.RR_FLOWS_CHANGED,\n        })\n        .catch(() => {\n          // Ignore errors - no listeners is expected when UI is closed\n        });\n    } catch {\n      // Ignore errors (e.g., if chrome.runtime is not available)\n    }\n  }, 50);\n}\n\n/**\n * Strip deprecated steps field before persisting to IndexedDB.\n * This ensures new saves only contain the DAG model (nodes/edges).\n *\n * @param flow - Flow with or without steps\n * @returns Flow without steps field (omit entirely, not set to empty array)\n */\nfunction stripStepsForSave(flow: Flow): Flow {\n  if (!('steps' in flow)) {\n    return flow;\n  }\n\n  const { steps: _steps, ...rest } = flow;\n  return rest as Flow;\n}\n\n/**\n * Normalize flow before saving: ensure nodes/edges exist for scheduler compatibility.\n * Only generates DAG from steps if nodes are missing or empty.\n * Preserves existing nodes/edges to avoid overwriting user edits.\n *\n * Also validates edges: removes edges referencing non-existent nodes to prevent\n * runtime errors in scheduler's topoOrder calculation.\n */\nfunction normalizeFlowForSave(flow: Flow): Flow {\n  const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0;\n  if (hasNodes) {\n    // Validate edges even when nodes exist (e.g., imported flows may have invalid edges)\n    const nodeIds = new Set(flow.nodes!.map((n) => n.id));\n    if (Array.isArray(flow.edges) && flow.edges.length > 0) {\n      const validEdges = filterValidEdges(flow.edges, nodeIds);\n      if (validEdges.length !== flow.edges.length) {\n        // Some edges were invalid, return cleaned flow\n        return { ...flow, edges: validEdges };\n      }\n    }\n    return flow;\n  }\n\n  // No nodes - generate from steps\n  if (!Array.isArray(flow.steps) || flow.steps.length === 0) {\n    return flow;\n  }\n\n  const dag = stepsToDAG(flow.steps);\n  if (dag.nodes.length === 0) {\n    return flow;\n  }\n\n  const nodes: NodeBase[] = dag.nodes.map(toNodeBase);\n  const nodeIds = new Set(nodes.map((n) => n.id));\n\n  // Validate existing edges: only keep if from/to both exist in new nodes\n  // Otherwise fall back to generated chain edges\n  let edges: Edge[];\n  if (Array.isArray(flow.edges) && flow.edges.length > 0) {\n    const validEdges = filterValidEdges(flow.edges, nodeIds);\n    edges = validEdges.length > 0 ? validEdges : dag.edges.map(toEdge);\n  } else {\n    edges = dag.edges.map(toEdge);\n  }\n\n  return {\n    ...flow,\n    nodes,\n    edges,\n  };\n}\n\nexport interface PublishedFlowInfo {\n  id: string;\n  slug: string; // for tool name `flow.<slug>`\n  version: number;\n  name: string;\n  description?: string;\n}\n\n/**\n * Check if a flow needs normalization (missing nodes when steps exist).\n */\nfunction needsNormalization(flow: Flow): boolean {\n  const hasSteps = Array.isArray(flow.steps) && flow.steps.length > 0;\n  const hasNodes = Array.isArray(flow.nodes) && flow.nodes.length > 0;\n  return hasSteps && !hasNodes;\n}\n\n/**\n * Lazy normalize a flow if needed, and persist the normalized version.\n * This handles legacy flows that only have steps but no nodes.\n * After normalization, steps field is stripped before persist AND return.\n */\nasync function lazyNormalize(flow: Flow): Promise<Flow> {\n  if (!needsNormalization(flow)) {\n    return stripStepsForSave(flow);\n  }\n  // Normalize and save back to storage (strip steps before persist)\n  const normalized = normalizeFlowForSave(flow);\n  const cleanFlow = stripStepsForSave(normalized);\n  try {\n    await IndexedDbStorage.flows.save(cleanFlow);\n  } catch (e) {\n    console.warn('lazyNormalize: failed to save normalized flow', e);\n  }\n  // Return DAG-only flow (do not leak deprecated steps to callers)\n  return cleanFlow;\n}\n\nexport async function listFlows(): Promise<Flow[]> {\n  await ensureMigratedFromLocal();\n  const flows = await IndexedDbStorage.flows.list();\n  // Check if any flows need normalization\n  const needsNorm = flows.some(needsNormalization);\n  if (!needsNorm) {\n    // Strip steps from all flows before returning\n    return flows.map(stripStepsForSave);\n  }\n  // Normalize flows that need it (in parallel)\n  // lazyNormalize already returns DAG-only flow\n  const normalized = await Promise.all(\n    flows.map(async (flow) => {\n      if (needsNormalization(flow)) {\n        return lazyNormalize(flow);\n      }\n      return stripStepsForSave(flow);\n    }),\n  );\n  return normalized;\n}\n\nexport async function getFlow(flowId: string): Promise<Flow | undefined> {\n  await ensureMigratedFromLocal();\n  const flow = await IndexedDbStorage.flows.get(flowId);\n  if (!flow) return undefined;\n  // Lazy normalize if needed (lazyNormalize returns DAG-only)\n  if (needsNormalization(flow)) {\n    return lazyNormalize(flow);\n  }\n  // Strip steps before returning\n  return stripStepsForSave(flow);\n}\n\nexport async function saveFlow(flow: Flow, options?: { notify?: boolean }): Promise<void> {\n  await ensureMigratedFromLocal();\n  // 1. Normalize: generate nodes/edges from steps if missing\n  // 2. Strip: remove deprecated steps field before persist\n  const normalizedFlow = normalizeFlowForSave(flow);\n  const cleanFlow = stripStepsForSave(normalizedFlow);\n  await IndexedDbStorage.flows.save(cleanFlow);\n  // Notify UI by default, can be disabled for batch operations\n  if (options?.notify !== false) {\n    notifyFlowsChanged();\n  }\n}\n\nexport async function deleteFlow(flowId: string): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.flows.delete(flowId);\n  notifyFlowsChanged();\n}\n\nexport async function listRuns(): Promise<RunRecord[]> {\n  await ensureMigratedFromLocal();\n  return await IndexedDbStorage.runs.list();\n}\n\nexport async function appendRun(record: RunRecord): Promise<void> {\n  await ensureMigratedFromLocal();\n  const runs = await IndexedDbStorage.runs.list();\n  runs.push(record);\n  // Trim to keep last 10 runs per flowId to avoid unbounded growth\n  try {\n    const byFlow = new Map<string, RunRecord[]>();\n    for (const r of runs) {\n      const list = byFlow.get(r.flowId) || [];\n      list.push(r);\n      byFlow.set(r.flowId, list);\n    }\n    const merged: RunRecord[] = [];\n    for (const [, arr] of byFlow.entries()) {\n      arr.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());\n      const last = arr.slice(Math.max(0, arr.length - 10));\n      merged.push(...last);\n    }\n    await IndexedDbStorage.runs.replaceAll(merged);\n  } catch (e) {\n    console.warn('appendRun: trim failed, saving all', e);\n    await IndexedDbStorage.runs.replaceAll(runs);\n  }\n}\n\nexport async function listPublished(): Promise<PublishedFlowInfo[]> {\n  await ensureMigratedFromLocal();\n  return await IndexedDbStorage.published.list();\n}\n\nexport async function publishFlow(flow: Flow, slug?: string): Promise<PublishedFlowInfo> {\n  await ensureMigratedFromLocal();\n  const info: PublishedFlowInfo = {\n    id: flow.id,\n    slug: slug || toSlug(flow.name) || flow.id,\n    version: flow.version,\n    name: flow.name,\n    description: flow.description,\n  };\n  await IndexedDbStorage.published.save(info);\n  return info;\n}\n\nexport async function unpublishFlow(flowId: string): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.published.delete(flowId);\n}\n\nexport function toSlug(name: string): string {\n  return (name || '')\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/(^-|-$)+/g, '')\n    .slice(0, 64);\n}\n\nexport async function exportFlow(flowId: string): Promise<string> {\n  const flow = await getFlow(flowId);\n  if (!flow) throw new Error('flow not found');\n  return JSON.stringify(flow, null, 2);\n}\n\nexport async function exportAllFlows(): Promise<string> {\n  const flows = await listFlows();\n  return JSON.stringify({ flows }, null, 2);\n}\n\n/**\n * Import flows from JSON string.\n *\n * Supported formats:\n * 1. Array of flows: [...flows]\n * 2. Object with flows array: { flows: [...] }\n * 3. Single flow with steps: { id, steps: [...] }\n * 4. Single flow with nodes (new format): { id, nodes: [...], edges?: [...] }\n *\n * Flows are normalized on save (steps → nodes if needed).\n */\nexport async function importFlowFromJson(json: string): Promise<Flow[]> {\n  await ensureMigratedFromLocal();\n  const parsed = JSON.parse(json);\n\n  // Detect candidates from various formats\n  const candidates: unknown[] = Array.isArray(parsed)\n    ? parsed\n    : Array.isArray(parsed?.flows)\n      ? parsed.flows\n      : parsed?.id && (Array.isArray(parsed?.steps) || Array.isArray(parsed?.nodes))\n        ? [parsed]\n        : [];\n\n  if (!candidates.length) {\n    throw new Error('invalid flow json: no flows found');\n  }\n\n  const nowIso = new Date().toISOString();\n  const flowsToImport: Flow[] = [];\n\n  for (const raw of candidates) {\n    if (!raw || typeof raw !== 'object') {\n      throw new Error('invalid flow json: flow must be an object');\n    }\n\n    const f = raw as Record<string, unknown>;\n    const id = String(f.id || '').trim();\n    if (!id) {\n      throw new Error('invalid flow json: missing id');\n    }\n\n    // Normalize fields with sensible defaults\n    const name = typeof f.name === 'string' && f.name.trim() ? f.name : id;\n    const version = Number.isFinite(Number(f.version)) ? Number(f.version) : 1;\n\n    // Handle meta with proper timestamps\n    const existingMeta =\n      f.meta && typeof f.meta === 'object' ? (f.meta as Record<string, unknown>) : {};\n    const createdAt = typeof existingMeta.createdAt === 'string' ? existingMeta.createdAt : nowIso;\n\n    // Build flow object - preserve steps only if present (for normalize)\n    // saveFlow() will normalize (steps→nodes) then strip steps before persist\n    const flow: Flow = {\n      ...(f as object),\n      id,\n      name,\n      version,\n      meta: {\n        ...existingMeta,\n        createdAt,\n        updatedAt: nowIso,\n      },\n    } as Flow;\n\n    // Preserve steps for normalization if present in import data\n    if (Array.isArray(f.steps) && f.steps.length > 0) {\n      flow.steps = f.steps as Flow['steps'];\n    }\n\n    flowsToImport.push(flow);\n  }\n\n  // Save all flows (normalize on save)\n  // Disable individual notifications to avoid flooding UI during batch import\n  for (const f of flowsToImport) {\n    await saveFlow(f, { notify: false });\n  }\n\n  // Send single notification after all flows are imported\n  notifyFlowsChanged();\n\n  return flowsToImport;\n}\n\n// Scheduling support\nexport type ScheduleType = 'once' | 'interval' | 'daily';\nexport interface FlowSchedule {\n  id: string; // schedule id\n  flowId: string;\n  type: ScheduleType;\n  enabled: boolean;\n  // when: ISO string for 'once'; HH:mm for 'daily'; minutes for 'interval'\n  when: string;\n  // optional variables to pass when running\n  args?: Record<string, any>;\n}\n\nexport async function listSchedules(): Promise<FlowSchedule[]> {\n  await ensureMigratedFromLocal();\n  return await IndexedDbStorage.schedules.list();\n}\n\nexport async function saveSchedule(s: FlowSchedule): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.schedules.save(s);\n}\n\nexport async function removeSchedule(scheduleId: string): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.schedules.delete(scheduleId);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/index.ts",
    "content": "import { BACKGROUND_MESSAGE_TYPES, CONTENT_MESSAGE_TYPES } from '@/common/message-types';\nimport { Flow } from './types';\nimport {\n  listFlows,\n  saveFlow,\n  getFlow,\n  deleteFlow,\n  publishFlow,\n  unpublishFlow,\n  exportFlow,\n  exportAllFlows,\n  importFlowFromJson,\n  listSchedules,\n  saveSchedule,\n  removeSchedule,\n  type FlowSchedule,\n} from './flow-store';\nimport { listRuns } from './flow-store';\nimport { STORAGE_KEYS } from '@/common/constants';\nimport { listTriggers, saveTrigger, deleteTrigger, type FlowTrigger } from './trigger-store';\nimport { runFlow } from './flow-runner';\nimport { RecorderManager } from './recording/recorder-manager';\nimport { recordingSession } from './recording/session-manager';\n// Browser/content listeners are initialized via RecorderManager.init\n\n// design note: background listener for record & replay; delegates recording to dedicated modules\n\n// Alarm helpers for schedules\nasync function rescheduleAlarms() {\n  const schedules = await listSchedules();\n  // Clear existing rr_schedule_* alarms\n  const alarms = await chrome.alarms.getAll();\n  await Promise.all(\n    alarms\n      .filter((a) => a.name && a.name.startsWith('rr_schedule_'))\n      .map((a) => chrome.alarms.clear(a.name)),\n  );\n  for (const s of schedules) {\n    if (!s.enabled) continue;\n    const name = `rr_schedule_${s.id}`;\n    if (s.type === 'interval') {\n      const minutes = Math.max(1, Math.floor(Number(s.when) || 0));\n      await chrome.alarms.create(name, { periodInMinutes: minutes });\n    } else if (s.type === 'once') {\n      const whenMs = Date.parse(s.when);\n      if (Number.isFinite(whenMs)) await chrome.alarms.create(name, { when: whenMs });\n    } else if (s.type === 'daily') {\n      // daily HH:mm local time\n      const [hh, mm] = String(s.when || '00:00')\n        .split(':')\n        .map((x) => Number(x));\n      const now = new Date();\n      const next = new Date();\n      next.setHours(hh || 0, mm || 0, 0, 0);\n      if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);\n      await chrome.alarms.create(name, { when: next.getTime(), periodInMinutes: 24 * 60 });\n    }\n  }\n}\n\n// legacy injection helpers removed — use recording/content-injection when needed\n\nasync function startRecording(meta?: Partial<Flow>): Promise<{ success: boolean; error?: string }> {\n  return await RecorderManager.start(meta);\n}\n\nasync function stopRecording(): Promise<{ success: boolean; flow?: Flow; error?: string }> {\n  return await RecorderManager.stop();\n}\n\nexport function initRecordReplayListeners() {\n  // Storage state sync is handled within session manager and recorder manager\n  // On startup, re-schedule alarms\n  rescheduleAlarms().catch(() => {});\n  // Initialize trigger engine (contextMenus/commands/url/dom)\n  initTriggerEngine().catch(() => {});\n  // Initialize recorder manager (wires browser and content listeners)\n  RecorderManager.init().catch(() => {});\n\n  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n    try {\n      // rr_recorder_event 交由 ContentMessageHandler 处理\n      switch (message?.type) {\n        case BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING: {\n          startRecording(message.meta)\n            .then(sendResponse)\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING: {\n          stopRecording()\n            .then(sendResponse)\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_PAUSE_RECORDING: {\n          RecorderManager.pause()\n            .then(sendResponse)\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_RESUME_RECORDING: {\n          RecorderManager.resume()\n            .then(sendResponse)\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_GET_RECORDING_STATUS: {\n          const status = recordingSession.getStatus();\n          const session = recordingSession.getSession();\n          sendResponse({\n            success: true,\n            status,\n            sessionId: session.sessionId,\n            originTabId: session.originTabId,\n          });\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS: {\n          listFlows()\n            .then((flows) => sendResponse({ success: true, flows }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_GET_FLOW: {\n          getFlow(message.flowId)\n            .then((flow) => sendResponse({ success: !!flow, flow }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_DELETE_FLOW: {\n          deleteFlow(message.flowId)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_PUBLISH_FLOW: {\n          getFlow(message.flowId)\n            .then(async (flow) => {\n              if (!flow) return sendResponse({ success: false, error: 'flow not found' });\n              await publishFlow(flow, message.slug);\n              sendResponse({ success: true });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_UNPUBLISH_FLOW: {\n          unpublishFlow(message.flowId)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW: {\n          getFlow(message.flowId)\n            .then(async (flow) => {\n              if (!flow) return sendResponse({ success: false, error: 'flow not found' });\n              const result = await runFlow(flow, message.options || {});\n              sendResponse({ success: true, result });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_SAVE_FLOW: {\n          const flow = message.flow as Flow;\n          if (!flow || !flow.id) {\n            sendResponse({ success: false, error: 'invalid flow' });\n            return true;\n          }\n          saveFlow(flow)\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_EXPORT_FLOW: {\n          exportFlow(message.flowId)\n            .then((json) => sendResponse({ success: true, json }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_EXPORT_ALL: {\n          exportAllFlows()\n            .then((json) => sendResponse({ success: true, json }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_IMPORT_FLOW: {\n          importFlowFromJson(message.json)\n            .then((flows) => sendResponse({ success: true, imported: flows.length, flows }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_LIST_RUNS: {\n          listRuns()\n            .then((runs) => sendResponse({ success: true, runs }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_LIST_TRIGGERS: {\n          listTriggers()\n            .then((triggers) => sendResponse({ success: true, triggers }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER: {\n          const t = message.trigger as FlowTrigger;\n          if (!t || !t.id || !t.type || !t.flowId) {\n            sendResponse({ success: false, error: 'invalid trigger' });\n            return true;\n          }\n          saveTrigger(t)\n            .then(async () => {\n              await refreshTriggers();\n              sendResponse({ success: true });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {\n          const id = String(message.id || '');\n          if (!id) {\n            sendResponse({ success: false, error: 'invalid id' });\n            return true;\n          }\n          deleteTrigger(id)\n            .then(async () => {\n              await refreshTriggers();\n              sendResponse({ success: true });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS: {\n          refreshTriggers()\n            .then(() => sendResponse({ success: true }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_LIST_SCHEDULES: {\n          listSchedules()\n            .then((s) => sendResponse({ success: true, schedules: s }))\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_SCHEDULE_FLOW: {\n          const s = message.schedule as FlowSchedule;\n          if (!s || !s.id || !s.flowId) {\n            sendResponse({ success: false, error: 'invalid schedule' });\n            return true;\n          }\n          saveSchedule(s)\n            .then(async () => {\n              await rescheduleAlarms();\n              sendResponse({ success: true });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n        case BACKGROUND_MESSAGE_TYPES.RR_UNSCHEDULE_FLOW: {\n          const scheduleId = String(message.scheduleId || '');\n          if (!scheduleId) {\n            sendResponse({ success: false, error: 'invalid scheduleId' });\n            return true;\n          }\n          removeSchedule(scheduleId)\n            .then(async () => {\n              await rescheduleAlarms();\n              sendResponse({ success: true });\n            })\n            .catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));\n          return true;\n        }\n      }\n    } catch (err) {\n      sendResponse({ success: false, error: (err as any)?.message || String(err) });\n    }\n    return false;\n  });\n\n  // Trigger engine: contextMenus/commands/url/dom\n  if ((chrome as any).contextMenus?.onClicked?.addListener) {\n    chrome.contextMenus.onClicked.addListener(async (info) => {\n      try {\n        const triggers = await listTriggers();\n        const t = triggers.find(\n          (x) => x.type === 'contextMenu' && (x as any).menuId === info.menuItemId,\n        );\n        if (!t || t.enabled === false) return;\n        const flow = await getFlow(t.flowId);\n        if (!flow) return;\n        await runFlow(flow, { args: t.args || {}, returnLogs: false });\n      } catch {}\n    });\n  }\n  chrome.commands.onCommand.addListener(async (command) => {\n    try {\n      const triggers = await listTriggers();\n      const t = triggers.find((x) => x.type === 'command' && (x as any).commandKey === command);\n      if (!t || t.enabled === false) return;\n      const flow = await getFlow(t.flowId);\n      if (!flow) return;\n      await runFlow(flow, { args: t.args || {}, returnLogs: false });\n    } catch {}\n  });\n  chrome.webNavigation.onCommitted.addListener(async (details) => {\n    try {\n      if (details.frameId !== 0) return;\n      const url = details.url || '';\n      // Ensure core content scripts are injected for this tab (pre-heat for replay)\n      await ensureCoreInjected(details.tabId);\n      // Ensure DOM observer is active on this tab (if triggers exist)\n      try {\n        const { [STORAGE_KEYS.RR_TRIGGERS]: stored } =\n          (await chrome.storage.local.get(STORAGE_KEYS.RR_TRIGGERS)) || {};\n        const triggers: any[] = Array.isArray(stored) ? stored : [];\n        const domTriggers = triggers\n          .filter((x) => x.type === 'dom' && x.enabled !== false)\n          .map((x: any) => ({\n            id: x.id,\n            selector: x.selector,\n            appear: x.appear !== false,\n            once: x.once !== false,\n            debounceMs: x.debounceMs ?? 800,\n          }));\n        if (typeof details.tabId === 'number') {\n          try {\n            await chrome.scripting.executeScript({\n              target: { tabId: details.tabId, allFrames: true },\n              files: ['inject-scripts/dom-observer.js'],\n              world: 'ISOLATED',\n            } as any);\n            await chrome.tabs.sendMessage(details.tabId, {\n              action: 'set_dom_triggers',\n              triggers: domTriggers,\n            } as any);\n          } catch {}\n        }\n      } catch {}\n      const triggers = await listTriggers();\n      const list = triggers.filter((x) => x.type === 'url' && x.enabled !== false) as any[];\n      for (const t of list) {\n        if (matchUrl(url, (t as any).match || [])) {\n          const flow = await getFlow(t.flowId);\n          if (!flow) continue;\n          await runFlow(flow, { args: t.args || {}, returnLogs: false });\n        }\n      }\n    } catch {}\n  });\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    try {\n      if (message && message.action === 'dom_trigger_fired') {\n        const id = message.triggerId;\n        listTriggers().then(async (arr) => {\n          const t = arr.find((x) => x.id === id && x.type === 'dom');\n          if (!t || t.enabled === false) return;\n          const flow = await getFlow(t.flowId);\n          if (!flow) return;\n          await runFlow(flow, { args: t.args || {}, returnLogs: false });\n        });\n        sendResponse({ ok: true });\n        return true;\n      }\n    } catch {}\n    return false;\n  });\n}\n\nfunction matchUrl(\n  u: string,\n  rules: Array<{ kind: 'url' | 'domain' | 'path'; value: string }>,\n): boolean {\n  try {\n    const url = new URL(u);\n    for (const r of rules || []) {\n      const v = String(r.value || '');\n      if (r.kind === 'url' && u.startsWith(v)) return true;\n      if (r.kind === 'domain' && url.hostname.includes(v)) return true;\n      if (r.kind === 'path' && url.pathname.startsWith(v)) return true;\n    }\n  } catch {}\n  return false;\n}\n\n// Track context menu IDs created by record-replay to avoid removing other menus\nconst rrContextMenuIds = new Set<string>();\n\nasync function refreshContextMenus(triggers: FlowTrigger[]) {\n  if (!(chrome as any).contextMenus?.create) return;\n\n  // Remove only our own menu items\n  await removeRecordReplayMenus();\n\n  // Create menus for enabled context menu triggers\n  for (const t of triggers) {\n    if (t.type !== 'contextMenu' || t.enabled === false) continue;\n    const id = `rr_menu_${t.id}`;\n    (t as any).menuId = id;\n\n    try {\n      await chrome.contextMenus.create({\n        id,\n        title: (t as any).title || '运行工作流',\n        contexts: (t as any).contexts || ['all'],\n      });\n      rrContextMenuIds.add(id);\n    } catch (err) {\n      console.warn('[RecordReplay] Failed to create context menu:', err);\n    }\n  }\n}\n\nasync function removeRecordReplayMenus() {\n  if (!(chrome as any).contextMenus?.remove) {\n    rrContextMenuIds.clear();\n    return;\n  }\n\n  const pending = Array.from(rrContextMenuIds.values()).map((id) =>\n    chrome.contextMenus.remove(id).catch(() => {}),\n  );\n\n  if (pending.length) await Promise.all(pending);\n  rrContextMenuIds.clear();\n}\n\nasync function refreshTriggers() {\n  try {\n    const triggers = await listTriggers();\n    await refreshContextMenus(triggers);\n    await chrome.storage.local.set({ [STORAGE_KEYS.RR_TRIGGERS]: triggers });\n    const domTriggers = triggers\n      .filter((x) => x.type === 'dom' && x.enabled !== false)\n      .map((x: any) => ({\n        id: x.id,\n        selector: x.selector,\n        appear: x.appear !== false,\n        once: x.once !== false,\n        debounceMs: x.debounceMs ?? 800,\n      }));\n    const tabs = await chrome.tabs.query({});\n    for (const t of tabs) {\n      if (!t.id) continue;\n      try {\n        await chrome.scripting.executeScript({\n          target: { tabId: t.id, allFrames: true },\n          files: ['inject-scripts/dom-observer.js'],\n          world: 'ISOLATED',\n        } as any);\n        await chrome.tabs.sendMessage(t.id, {\n          action: 'set_dom_triggers',\n          triggers: domTriggers,\n        } as any);\n      } catch {}\n    }\n  } catch {}\n}\n\n// Backward-compatible init function; initialize all trigger-related hooks/state\nasync function initTriggerEngine() {\n  await refreshTriggers();\n}\n\n// Ensure core content scripts are present for a tab after navigation\nasync function ensureCoreInjected(tabId?: number) {\n  try {\n    if (typeof tabId !== 'number') return;\n    // Ping accessibility helper\n    const ok = await pingTab(tabId, CONTENT_MESSAGE_TYPES.ACCESSIBILITY_TREE_HELPER_PING);\n    if (!ok) {\n      await chrome.scripting.executeScript({\n        target: { tabId, allFrames: true },\n        files: ['inject-scripts/inject-bridge.js', 'inject-scripts/accessibility-tree-helper.js'],\n        world: 'ISOLATED',\n      } as any);\n    }\n  } catch {}\n}\n\nasync function pingTab(tabId: number, action: string): Promise<boolean> {\n  try {\n    const resp: any = await chrome.tabs.sendMessage(tabId, { action } as any);\n    if (!resp) return false;\n    // Helpers generally respond { status: 'pong' } or { ok: true }\n    return resp.status === 'pong' || resp.ok === true;\n  } catch {\n    return false;\n  }\n}\n\n// Alarm listener executes scheduled flows\nchrome.alarms.onAlarm.addListener(async (alarm) => {\n  try {\n    if (!alarm?.name || !alarm.name.startsWith('rr_schedule_')) return;\n    const id = alarm.name.slice('rr_schedule_'.length);\n    const schedules = await listSchedules();\n    const s = schedules.find((x) => x.id === id && x.enabled);\n    if (!s) return;\n    const flow = await getFlow(s.flowId);\n    if (!flow) return;\n    await runFlow(flow, { args: s.args || {}, returnLogs: false });\n  } catch (e) {\n    // swallow to not spam logs\n  }\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/legacy-types.ts",
    "content": "/**\n * Legacy Step Types for Record & Replay\n *\n * This file contains the legacy Step type system that is being phased out\n * in favor of the DAG-based execution model (nodes/edges).\n *\n * These types are kept for:\n * 1. Backward compatibility with existing flows that use steps array\n * 2. Recording pipeline that still produces Step[] output\n * 3. Legacy node handlers in nodes/ directory\n *\n * New code should use the Action type system from ./actions/types.ts instead.\n *\n * Migration status: P4 phase 1 - types extracted, re-exported from types.ts\n */\n\nimport { STEP_TYPES } from '@/common/step-types';\n\n// =============================================================================\n// Legacy Selector Types\n// =============================================================================\n\nexport type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';\n\nexport interface SelectorCandidate {\n  type: SelectorType;\n  value: string; // literal selector or text/aria expression\n  weight?: number; // user-adjustable priority; higher first\n}\n\nexport interface TargetLocator {\n  ref?: string; // ephemeral ref from read_page\n  candidates: SelectorCandidate[]; // ordered by priority\n}\n\n// =============================================================================\n// Legacy Step Types\n// =============================================================================\n\nexport type StepType = (typeof STEP_TYPES)[keyof typeof STEP_TYPES];\n\nexport interface StepBase {\n  id: string;\n  type: StepType;\n  timeoutMs?: number; // default 10000\n  retry?: { count: number; intervalMs: number; backoff?: 'none' | 'exp' };\n  screenshotOnFail?: boolean; // default true\n}\n\nexport interface StepClick extends StepBase {\n  type: 'click' | 'dblclick';\n  target: TargetLocator;\n  before?: { scrollIntoView?: boolean; waitForSelector?: boolean };\n  after?: { waitForNavigation?: boolean; waitForNetworkIdle?: boolean };\n}\n\nexport interface StepFill extends StepBase {\n  type: 'fill';\n  target: TargetLocator;\n  value: string; // may contain {var}\n}\n\nexport interface StepTriggerEvent extends StepBase {\n  type: 'triggerEvent';\n  target: TargetLocator;\n  event: string; // e.g. 'input', 'change', 'mouseover'\n  bubbles?: boolean;\n  cancelable?: boolean;\n}\n\nexport interface StepSetAttribute extends StepBase {\n  type: 'setAttribute';\n  target: TargetLocator;\n  name: string;\n  value?: string; // when omitted and remove=true, remove attribute\n  remove?: boolean;\n}\n\nexport interface StepScreenshot extends StepBase {\n  type: 'screenshot';\n  selector?: string;\n  fullPage?: boolean;\n  saveAs?: string; // variable name to store base64\n}\n\nexport interface StepSwitchFrame extends StepBase {\n  type: 'switchFrame';\n  frame?: { index?: number; urlContains?: string };\n}\n\nexport interface StepLoopElements extends StepBase {\n  type: 'loopElements';\n  selector: string;\n  saveAs?: string; // list var name\n  itemVar?: string; // default 'item'\n  subflowId: string;\n}\n\nexport interface StepKey extends StepBase {\n  type: 'key';\n  keys: string; // e.g. \"Backspace Enter\" or \"cmd+a\"\n  target?: TargetLocator; // optional focus target\n}\n\nexport interface StepScroll extends StepBase {\n  type: 'scroll';\n  mode: 'element' | 'offset' | 'container';\n  target?: TargetLocator; // when mode = element / container\n  offset?: { x?: number; y?: number };\n}\n\nexport interface StepDrag extends StepBase {\n  type: 'drag';\n  start: TargetLocator;\n  end: TargetLocator;\n  path?: Array<{ x: number; y: number }>; // sampled trajectory\n}\n\nexport interface StepWait extends StepBase {\n  type: 'wait';\n  condition:\n    | { selector: string; visible?: boolean }\n    | { text: string; appear?: boolean }\n    | { navigation: true }\n    | { networkIdle: true }\n    | { sleep: number };\n}\n\nexport interface StepAssert extends StepBase {\n  type: 'assert';\n  assert:\n    | { exists: string }\n    | { visible: string }\n    | { textPresent: string }\n    | { attribute: { selector: string; name: string; equals?: string; matches?: string } };\n  // 失败策略：stop=失败即停（默认）、warn=仅告警并继续、retry=触发重试机制\n  failStrategy?: 'stop' | 'warn' | 'retry';\n}\n\nexport interface StepScript extends StepBase {\n  type: 'script';\n  world?: 'MAIN' | 'ISOLATED';\n  code: string; // user script string\n  when?: 'before' | 'after';\n}\n\nexport interface StepIf extends StepBase {\n  type: 'if';\n  // condition supports: { var: string; equals?: any } | { expression: string }\n  condition: any;\n}\n\nexport interface StepForeach extends StepBase {\n  type: 'foreach';\n  listVar: string;\n  itemVar?: string;\n  subflowId: string;\n}\n\nexport interface StepWhile extends StepBase {\n  type: 'while';\n  condition: any;\n  subflowId: string;\n  maxIterations?: number;\n}\n\nexport interface StepHttp extends StepBase {\n  type: 'http';\n  method?: string;\n  url: string;\n  headers?: Record<string, string>;\n  body?: any;\n  formData?: any;\n  saveAs?: string;\n  assign?: Record<string, string>;\n}\n\nexport interface StepExtract extends StepBase {\n  type: 'extract';\n  selector?: string;\n  attr?: string; // 'text'|'textContent' to read text\n  js?: string; // custom JS that returns value\n  saveAs: string;\n}\n\nexport interface StepOpenTab extends StepBase {\n  type: 'openTab';\n  url?: string;\n  newWindow?: boolean;\n}\n\nexport interface StepSwitchTab extends StepBase {\n  type: 'switchTab';\n  tabId?: number;\n  urlContains?: string;\n  titleContains?: string;\n}\n\nexport interface StepCloseTab extends StepBase {\n  type: 'closeTab';\n  tabIds?: number[];\n  url?: string;\n}\n\nexport interface StepNavigate extends StepBase {\n  type: 'navigate';\n  url: string;\n}\n\nexport interface StepHandleDownload extends StepBase {\n  type: 'handleDownload';\n  filenameContains?: string;\n  saveAs?: string;\n  waitForComplete?: boolean;\n}\n\nexport interface StepExecuteFlow extends StepBase {\n  type: 'executeFlow';\n  flowId: string;\n  inline?: boolean;\n  args?: Record<string, any>;\n}\n\n// =============================================================================\n// Step Union Type\n// =============================================================================\n\nexport type Step =\n  | StepClick\n  | StepFill\n  | StepTriggerEvent\n  | StepSetAttribute\n  | StepScreenshot\n  | StepSwitchFrame\n  | StepLoopElements\n  | StepKey\n  | StepScroll\n  | StepDrag\n  | StepWait\n  | StepAssert\n  | StepScript\n  | StepIf\n  | StepForeach\n  | StepWhile\n  | StepNavigate\n  | StepHttp\n  | StepExtract\n  | StepOpenTab\n  | StepSwitchTab\n  | StepCloseTab\n  | StepHandleDownload\n  | StepExecuteFlow;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/assert.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepAssert } from '../types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const assertNode: NodeRuntime<StepAssert> = {\n  validate: (step) => {\n    const s = step as any;\n    const ok = !!s.assert;\n    if (ok && s.assert && 'attribute' in s.assert) {\n      const a = s.assert.attribute || {};\n      if (!a.selector || !a.name)\n        return { ok: false, errors: ['assert.attribute: 需提供 selector 与 name'] };\n    }\n    return ok ? { ok } : { ok, errors: ['缺少断言条件'] };\n  },\n  run: async (ctx: ExecCtx, step: StepAssert) => {\n    const s = expandTemplatesDeep(step as StepAssert, ctx.vars) as any;\n    const failStrategy = (s as any).failStrategy || 'stop';\n    const fail = (msg: string) => {\n      if (failStrategy === 'warn') {\n        ctx.logger({ stepId: (step as any).id, status: 'warning', message: msg });\n        return { alreadyLogged: true } as any;\n      }\n      throw new Error(msg);\n    };\n    if ('textPresent' in s.assert) {\n      const text = (s.assert as any).textPresent;\n      const res = await handleCallTool({\n        name: TOOL_NAMES.BROWSER.COMPUTER,\n        args: { action: 'wait', text, appear: true, timeout: (step as any).timeoutMs || 5000 },\n      });\n      if ((res as any).isError) return fail('assert text failed');\n    } else if ('exists' in s.assert || 'visible' in s.assert) {\n      const selector = (s.assert as any).exists || (s.assert as any).visible;\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const firstTab = tabs && tabs[0];\n      const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;\n      if (!tabId) return fail('Active tab not found');\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n      const ensured: any = (await chrome.tabs.sendMessage(\n        tabId,\n        {\n          action: 'ensureRefForSelector',\n          selector,\n        } as any,\n        { frameId: ctx.frameId } as any,\n      )) as any;\n      if (!ensured || !ensured.success) return fail('assert selector not found');\n      if ('visible' in s.assert) {\n        const rect = ensured && ensured.center ? ensured.center : null;\n        if (!rect) return fail('assert visible failed');\n      }\n    } else if ('attribute' in s.assert) {\n      const { selector, name, equals, matches } = (s.assert as any).attribute || {};\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const firstTab = tabs && tabs[0];\n      const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;\n      if (!tabId) return fail('Active tab not found');\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n      const resp: any = (await chrome.tabs.sendMessage(\n        tabId,\n        { action: 'getAttributeForSelector', selector, name } as any,\n        { frameId: ctx.frameId } as any,\n      )) as any;\n      if (!resp || !resp.success) return fail('assert attribute: element not found');\n      const actual: string | null = resp.value ?? null;\n      if (equals !== undefined && equals !== null) {\n        const expected = String(equals);\n        if (String(actual) !== String(expected))\n          return fail(\n            `assert attribute equals failed: ${name} actual=${String(actual)} expected=${String(expected)}`,\n          );\n      } else if (matches !== undefined && matches !== null) {\n        try {\n          const re = new RegExp(String(matches));\n          if (!re.test(String(actual)))\n            return fail(\n              `assert attribute matches failed: ${name} actual=${String(actual)} regex=${String(matches)}`,\n            );\n        } catch {\n          return fail(`invalid regex for attribute matches: ${String(matches)}`);\n        }\n      } else {\n        if (actual == null) return fail(`assert attribute failed: ${name} missing`);\n      }\n    }\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/click.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { Step } from '../types';\nimport { locateElement } from '../selector-engine';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const clickNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const ok = !!(step as any).target?.candidates?.length;\n    return ok ? { ok } : { ok, errors: ['缺少目标选择器候选'] };\n  },\n  run: async (ctx: ExecCtx, step: Step) => {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const firstTab = tabs && tabs[0];\n    const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;\n    if (!tabId) throw new Error('Active tab not found');\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const located = await locateElement(tabId, s.target, ctx.frameId);\n    const frameId = (located as any)?.frameId ?? ctx.frameId;\n    const first = s.target?.candidates?.[0]?.type;\n    const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');\n    const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;\n    if ((located as any)?.ref) {\n      const resolved: any = (await chrome.tabs.sendMessage(\n        tabId,\n        { action: 'resolveRef', ref: (located as any).ref } as any,\n        { frameId } as any,\n      )) as any;\n      const rect = resolved?.rect;\n      if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');\n    }\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.CLICK,\n      args: {\n        ref: (located as any)?.ref || (step as any).target?.ref,\n        selector: !(located as any)?.ref\n          ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value\n          : undefined,\n        waitForNavigation: false,\n        timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),\n        frameId,\n      },\n    });\n    if ((res as any).isError) throw new Error('click failed');\n    if (fallbackUsed)\n      ctx.logger({\n        stepId: step.id,\n        status: 'success',\n        message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,\n        fallbackUsed: true,\n        fallbackFrom: String(first),\n        fallbackTo: String(resolvedBy),\n      } as any);\n    return {} as ExecResult;\n  },\n};\n\nexport const dblclickNode: NodeRuntime<any> = {\n  validate: clickNode.validate,\n  run: async (ctx: ExecCtx, step: Step) => {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const firstTab = tabs && tabs[0];\n    const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;\n    if (!tabId) throw new Error('Active tab not found');\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const located = await locateElement(tabId, s.target, ctx.frameId);\n    const frameId = (located as any)?.frameId ?? ctx.frameId;\n    const first = s.target?.candidates?.[0]?.type;\n    const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');\n    const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;\n    if ((located as any)?.ref) {\n      const resolved: any = (await chrome.tabs.sendMessage(\n        tabId,\n        { action: 'resolveRef', ref: (located as any).ref } as any,\n        { frameId } as any,\n      )) as any;\n      const rect = resolved?.rect;\n      if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');\n    }\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.CLICK,\n      args: {\n        ref: (located as any)?.ref || (step as any).target?.ref,\n        selector: !(located as any)?.ref\n          ? s.target?.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value\n          : undefined,\n        waitForNavigation: false,\n        timeout: Math.max(1000, Math.min(s.timeoutMs || 10000, 30000)),\n        frameId,\n        double: true,\n      },\n    });\n    if ((res as any).isError) throw new Error('dblclick failed');\n    if (fallbackUsed)\n      ctx.logger({\n        stepId: step.id,\n        status: 'success',\n        message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,\n        fallbackUsed: true,\n        fallbackFrom: String(first),\n        fallbackTo: String(resolvedBy),\n      } as any);\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/conditional.ts",
    "content": "import type { Step } from '../types';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const ifNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s = step as any;\n    const hasBranches = Array.isArray(s.branches) && s.branches.length > 0;\n    const ok = hasBranches || !!s.condition;\n    return ok ? { ok } : { ok, errors: ['缺少条件或分支'] };\n  },\n  run: async (ctx: ExecCtx, step: Step) => {\n    const s: any = step;\n    if (Array.isArray(s.branches) && s.branches.length > 0) {\n      const evalExpr = (expr: string): boolean => {\n        const code = String(expr || '').trim();\n        if (!code) return false;\n        try {\n          const fn = new Function(\n            'vars',\n            'workflow',\n            `try { return !!(${code}); } catch (e) { return false; }`,\n          );\n          return !!fn(ctx.vars, ctx.vars);\n        } catch {\n          return false;\n        }\n      };\n      for (const br of s.branches) {\n        if (br?.expr && evalExpr(String(br.expr)))\n          return { nextLabel: String(br.label || `case:${br.id || 'match'}`) } as ExecResult;\n      }\n      if ('else' in s) return { nextLabel: String(s.else || 'default') } as ExecResult;\n      return { nextLabel: 'default' } as ExecResult;\n    }\n    // legacy condition: { var/equals | expression }\n    try {\n      let result = false;\n      const cond = s.condition;\n      if (cond && typeof cond.expression === 'string' && cond.expression.trim()) {\n        const fn = new Function(\n          'vars',\n          `try { return !!(${cond.expression}); } catch (e) { return false; }`,\n        );\n        result = !!fn(ctx.vars);\n      } else if (cond && typeof cond.var === 'string') {\n        const v = ctx.vars[cond.var];\n        if ('equals' in cond) result = String(v) === String(cond.equals);\n        else result = !!v;\n      }\n      return { nextLabel: result ? 'true' : 'false' } as ExecResult;\n    } catch {\n      return { nextLabel: 'false' } as ExecResult;\n    }\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/download-screenshot-attr-event-frame-loop.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { Step } from '../types';\nimport { locateElement } from '../selector-engine';\n\nexport const handleDownloadNode: NodeRuntime<any> = {\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const args: any = {\n      filenameContains: s.filenameContains || undefined,\n      timeoutMs: Math.max(1000, Math.min(Number(s.timeoutMs ?? 60000), 300000)),\n      waitForComplete: s.waitForComplete !== false,\n    };\n    const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD, args });\n    const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;\n    try {\n      const payload = text ? JSON.parse(text) : null;\n      if (s.saveAs && payload && payload.download) ctx.vars[s.saveAs] = payload.download;\n    } catch {}\n    return {} as ExecResult;\n  },\n};\n\nexport const screenshotNode: NodeRuntime<any> = {\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const args: any = { name: 'workflow', storeBase64: true };\n    if (s.fullPage) args.fullPage = true;\n    if (s.selector && typeof s.selector === 'string' && s.selector.trim())\n      args.selector = s.selector;\n    const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.SCREENSHOT, args });\n    const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;\n    try {\n      const payload = text ? JSON.parse(text) : null;\n      if (s.saveAs && payload && payload.base64Data) ctx.vars[s.saveAs] = payload.base64Data;\n    } catch {}\n    return {} as ExecResult;\n  },\n};\n\nexport const triggerEventNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s: any = step;\n    const ok = !!s?.target?.candidates?.length && typeof s?.event === 'string' && s.event;\n    return ok ? { ok } : { ok, errors: ['缺少目标选择器或事件类型'] };\n  },\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    const located = await locateElement(tabId, s.target, ctx.frameId);\n    const cssSelector = !(located as any)?.ref\n      ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value\n      : undefined;\n    let sel = cssSelector as string | undefined;\n    if (!sel && (located as any)?.ref) {\n      try {\n        const resolved: any = (await chrome.tabs.sendMessage(\n          tabId,\n          { action: 'resolveRef', ref: (located as any).ref } as any,\n          { frameId: ctx.frameId } as any,\n        )) as any;\n        sel = resolved?.selector;\n      } catch {}\n    }\n    if (!sel) throw new Error('triggerEvent: selector not resolved');\n    const world: any = 'MAIN';\n    const ev = String(s.event || '').trim();\n    const bubbles = s.bubbles !== false;\n    const cancelable = s.cancelable === true;\n    await chrome.scripting.executeScript({\n      target: {\n        tabId,\n        frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined,\n      } as any,\n      world,\n      func: (selector: string, type: string, bubbles: boolean, cancelable: boolean) => {\n        try {\n          const el = document.querySelector(selector);\n          if (!el) return false;\n          const e = new Event(type, { bubbles, cancelable });\n          (el as any).dispatchEvent(e);\n          return true;\n        } catch (e) {\n          return false;\n        }\n      },\n      args: [sel, ev, !!bubbles, !!cancelable],\n    } as any);\n    return {} as ExecResult;\n  },\n};\n\nexport const setAttributeNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s: any = step;\n    const ok = !!s?.target?.candidates?.length && typeof s?.name === 'string' && s.name;\n    return ok ? { ok } : { ok, errors: ['需提供目标选择器与属性名'] };\n  },\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    const located = await locateElement(tabId, s.target, ctx.frameId);\n    const frameId = (located as any)?.frameId ?? ctx.frameId;\n    const cssSelector = !(located as any)?.ref\n      ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value\n      : undefined;\n    let sel = cssSelector as string | undefined;\n    if (!sel && (located as any)?.ref) {\n      try {\n        const resolved: any = (await chrome.tabs.sendMessage(\n          tabId,\n          { action: 'resolveRef', ref: (located as any).ref } as any,\n          { frameId } as any,\n        )) as any;\n        sel = resolved?.selector;\n      } catch {}\n    }\n    if (!sel) throw new Error('setAttribute: selector not resolved');\n    const world: any = 'MAIN';\n    const name = String(s.name || '');\n    const value = s.value;\n    const remove = s.remove === true;\n    await chrome.scripting.executeScript({\n      target: { tabId, frameIds: typeof frameId === 'number' ? [frameId] : undefined } as any,\n      world,\n      func: (selector: string, name: string, value: any, remove: boolean) => {\n        try {\n          const el = document.querySelector(selector) as any;\n          if (!el) return false;\n          if (remove) el.removeAttribute(name);\n          else el.setAttribute(name, String(value ?? ''));\n          return true;\n        } catch {\n          return false;\n        }\n      },\n      args: [sel, name, value, remove],\n    } as any);\n    return {} as ExecResult;\n  },\n};\n\nexport const switchFrameNode: NodeRuntime<any> = {\n  run: async (ctx, step) => {\n    const s: any = step;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    const frames = await chrome.webNavigation.getAllFrames({ tabId });\n    if (!Array.isArray(frames) || frames.length === 0) {\n      ctx.frameId = undefined;\n      return {} as ExecResult;\n    }\n    let target: any | undefined;\n    const idx = Number(s?.frame?.index ?? NaN);\n    if (Number.isFinite(idx)) {\n      const list = frames.filter((f) => f.frameId !== 0);\n      target = list[Math.max(0, Math.min(list.length - 1, idx))];\n    }\n    const urlContains = String(s?.frame?.urlContains || '').trim();\n    if (!target && urlContains)\n      target = frames.find((f) => typeof f.url === 'string' && f.url.includes(urlContains));\n    if (!target) ctx.frameId = undefined;\n    else ctx.frameId = target.frameId;\n    try {\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    } catch {}\n    ctx.logger({\n      stepId: (step as any).id,\n      status: 'success',\n      message: `frameId=${String(ctx.frameId ?? 'top')}`,\n    } as any);\n    return {} as ExecResult;\n  },\n};\n\nexport const loopElementsNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s: any = step;\n    const ok =\n      typeof s?.selector === 'string' &&\n      s.selector &&\n      typeof s?.subflowId === 'string' &&\n      s.subflowId;\n    return ok ? { ok } : { ok, errors: ['需提供 selector 与 subflowId'] };\n  },\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    const world: any = 'MAIN';\n    const selector = String(s.selector || '');\n    const res = await chrome.scripting.executeScript({\n      target: {\n        tabId,\n        frameIds: typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined,\n      } as any,\n      world,\n      func: (sel: string) => {\n        try {\n          const list = Array.from(document.querySelectorAll(sel));\n          const toCss = (node: Element) => {\n            try {\n              if ((node as HTMLElement).id) {\n                const idSel = `#${CSS.escape((node as HTMLElement).id)}`;\n                if (document.querySelectorAll(idSel).length === 1) return idSel;\n              }\n            } catch {}\n            let path = '';\n            let current: Element | null = node;\n            while (current && current.tagName !== 'BODY') {\n              let part = current.tagName.toLowerCase();\n              const parentEl: Element | null = current.parentElement;\n              if (parentEl) {\n                const siblings = Array.from(parentEl.children).filter(\n                  (c) => (c as any).tagName === current!.tagName,\n                );\n                if (siblings.length > 1) {\n                  const idx = siblings.indexOf(current) + 1;\n                  part += `:nth-of-type(${idx})`;\n                }\n              }\n              path = path ? `${part} > ${path}` : part;\n              current = parentEl;\n            }\n            return path ? `body > ${path}` : 'body';\n          };\n          return list.map(toCss);\n        } catch (e) {\n          return [];\n        }\n      },\n      args: [selector],\n    } as any);\n    const arr: string[] = (res && Array.isArray(res[0]?.result) ? res[0].result : []) as any;\n    const listVar = String(s.saveAs || 'elements');\n    const itemVar = String(s.itemVar || 'item');\n    ctx.vars[listVar] = arr;\n    return {\n      control: { kind: 'foreach', listVar, itemVar, subflowId: String(s.subflowId) },\n    } as any;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/drag.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepDrag } from '../types';\nimport { locateElement } from '../selector-engine';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const dragNode: NodeRuntime<StepDrag> = {\n  run: async (_ctx, step: StepDrag) => {\n    const s = step as StepDrag;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    let startRef: string | undefined;\n    let endRef: string | undefined;\n    try {\n      if (typeof tabId === 'number') {\n        const locatedStart = await locateElement(tabId, (s as any).start);\n        const locatedEnd = await locateElement(tabId, (s as any).end);\n        startRef = (locatedStart as any)?.ref || (s as any).start.ref;\n        endRef = (locatedEnd as any)?.ref || (s as any).end.ref;\n      }\n    } catch {}\n    let startCoordinates: { x: number; y: number } | undefined;\n    let endCoordinates: { x: number; y: number } | undefined;\n    if ((!startRef || !endRef) && Array.isArray((s as any).path) && (s as any).path.length >= 2) {\n      startCoordinates = { x: Number((s as any).path[0].x), y: Number((s as any).path[0].y) };\n      const last = (s as any).path[(s as any).path.length - 1];\n      endCoordinates = { x: Number(last.x), y: Number(last.y) };\n    }\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.COMPUTER,\n      args: {\n        action: 'left_click_drag',\n        startRef,\n        ref: endRef,\n        startCoordinates,\n        coordinates: endCoordinates,\n      },\n    });\n    if ((res as any).isError) throw new Error('drag failed');\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/execute-flow.ts",
    "content": "import type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const executeFlowNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s: any = step;\n    const ok = typeof s.flowId === 'string' && !!s.flowId;\n    return ok ? { ok } : { ok, errors: ['需提供 flowId'] };\n  },\n  run: async (ctx: ExecCtx, step) => {\n    const s: any = step;\n    const { getFlow } = await import('../flow-store');\n    const flow = await getFlow(String(s.flowId));\n    if (!flow) throw new Error('referenced flow not found');\n    const inline = s.inline !== false; // default inline\n    if (!inline) {\n      const { runFlow } = await import('../flow-runner');\n      await runFlow(flow, { args: s.args || {}, returnLogs: false });\n      return {} as ExecResult;\n    }\n    const { defaultEdgesOnly, topoOrder, mapDagNodeToStep, waitForNetworkIdle, waitForNavigation } =\n      await import('../rr-utils');\n    const vars = ctx.vars;\n    if (s.args && typeof s.args === 'object') Object.assign(vars, s.args);\n\n    // DAG is required - flow-store guarantees nodes/edges via normalization\n    const nodes = ((flow as any).nodes || []) as any[];\n    const edges = ((flow as any).edges || []) as any[];\n    if (nodes.length === 0) {\n      throw new Error(\n        'Flow has no DAG nodes. Linear steps are no longer supported. Please migrate this flow to nodes/edges.',\n      );\n    }\n    const defaultEdges = defaultEdgesOnly(edges as any);\n    const order = topoOrder(nodes as any, defaultEdges as any);\n    const stepsToRun: any[] = order.map((n) => mapDagNodeToStep(n as any));\n    for (const st of stepsToRun) {\n      const t0 = Date.now();\n      const maxRetries = Math.max(0, (st as any).retry?.count ?? 0);\n      const baseInterval = Math.max(0, (st as any).retry?.intervalMs ?? 0);\n      let attempt = 0;\n      const doDelay = async (i: number) => {\n        const delay =\n          baseInterval > 0\n            ? (st as any).retry?.backoff === 'exp'\n              ? baseInterval * Math.pow(2, i)\n              : baseInterval\n            : 0;\n        if (delay > 0) await new Promise((r) => setTimeout(r, delay));\n      };\n      while (true) {\n        try {\n          const beforeInfo = await (async () => {\n            const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n            const tab = tabs[0];\n            return { url: tab?.url || '', status: (tab as any)?.status || '' };\n          })();\n          const { executeStep } = await import('../nodes');\n          const result = await executeStep(ctx as any, st as any);\n          if ((st.type === 'click' || st.type === 'dblclick') && (st as any).after) {\n            const after = (st as any).after as any;\n            if (after.waitForNavigation)\n              await waitForNavigation((st as any).timeoutMs, beforeInfo.url);\n            else if (after.waitForNetworkIdle)\n              await waitForNetworkIdle(Math.min((st as any).timeoutMs || 5000, 120000), 1200);\n          }\n          if (!result?.alreadyLogged)\n            ctx.logger({ stepId: st.id, status: 'success', tookMs: Date.now() - t0 } as any);\n          break;\n        } catch (e: any) {\n          if (attempt < maxRetries) {\n            ctx.logger({\n              stepId: st.id,\n              status: 'retrying',\n              message: e?.message || String(e),\n            } as any);\n            await doDelay(attempt);\n            attempt += 1;\n            continue;\n          }\n          ctx.logger({\n            stepId: st.id,\n            status: 'failed',\n            message: e?.message || String(e),\n            tookMs: Date.now() - t0,\n          } as any);\n          throw e;\n        }\n      }\n    }\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/extract.ts",
    "content": "import type { StepExtract } from '../types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const extractNode: NodeRuntime<StepExtract> = {\n  run: async (ctx: ExecCtx, step: StepExtract) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    let value: any = null;\n    if (s.js && String(s.js).trim()) {\n      const [{ result }] = await chrome.scripting.executeScript({\n        target: { tabId },\n        func: (code: string) => {\n          try {\n            return (0, eval)(code);\n          } catch (e) {\n            return null;\n          }\n        },\n        args: [String(s.js)],\n      } as any);\n      value = result;\n    } else if (s.selector) {\n      const attr = String(s.attr || 'text');\n      const sel = String(s.selector);\n      const [{ result }] = await chrome.scripting.executeScript({\n        target: { tabId },\n        func: (selector: string, attr: string) => {\n          try {\n            const el = document.querySelector(selector) as any;\n            if (!el) return null;\n            if (attr === 'text' || attr === 'textContent') return (el.textContent || '').trim();\n            return el.getAttribute ? el.getAttribute(attr) : null;\n          } catch {\n            return null;\n          }\n        },\n        args: [sel, attr],\n      } as any);\n      value = result;\n    }\n    if (s.saveAs) ctx.vars[s.saveAs] = value;\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/fill.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepFill } from '../types';\nimport { locateElement } from '../selector-engine';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const fillNode: NodeRuntime<StepFill> = {\n  validate: (step) => {\n    const ok = !!(step as any).target?.candidates?.length && 'value' in (step as any);\n    return ok ? { ok } : { ok, errors: ['缺少目标选择器候选或输入值'] };\n  },\n  run: async (ctx: ExecCtx, step: StepFill) => {\n    const s: any = step;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const firstTab = tabs && tabs[0];\n    const tabId = firstTab && typeof firstTab.id === 'number' ? firstTab.id : undefined;\n    if (!tabId) throw new Error('Active tab not found');\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });\n    const located = await locateElement(tabId, s.target, ctx.frameId);\n    const frameId = (located as any)?.frameId ?? ctx.frameId;\n    const first = s.target?.candidates?.[0]?.type;\n    const resolvedBy = (located as any)?.resolvedBy || ((located as any)?.ref ? 'ref' : '');\n    const fallbackUsed = resolvedBy && first && resolvedBy !== 'ref' && resolvedBy !== first;\n    const interpolate = (v: any) =>\n      typeof v === 'string'\n        ? v.replace(/\\{([^}]+)\\}/g, (_m, k) => (ctx.vars[k] ?? '').toString())\n        : v;\n    const value = interpolate(s.value);\n    if ((located as any)?.ref) {\n      const resolved: any = (await chrome.tabs.sendMessage(\n        tabId,\n        { action: 'resolveRef', ref: (located as any).ref } as any,\n        { frameId } as any,\n      )) as any;\n      const rect = resolved?.rect;\n      if (!rect || rect.width <= 0 || rect.height <= 0) throw new Error('element not visible');\n    }\n    const cssSelector = !(located as any)?.ref\n      ? s.target.candidates?.find((c: any) => c.type === 'css' || c.type === 'attr')?.value\n      : undefined;\n    if (cssSelector) {\n      try {\n        const attr: any = (await chrome.tabs.sendMessage(\n          tabId,\n          { action: 'getAttributeForSelector', selector: cssSelector, name: 'type' } as any,\n          { frameId } as any,\n        )) as any;\n        const typeName = (attr && attr.value ? String(attr.value) : '').toLowerCase();\n        if (typeName === 'file') {\n          const uploadRes = await handleCallTool({\n            name: TOOL_NAMES.BROWSER.FILE_UPLOAD,\n            args: { selector: cssSelector, filePath: String(value ?? '') },\n          });\n          if ((uploadRes as any).isError) throw new Error('file upload failed');\n          if (fallbackUsed)\n            ctx.logger({\n              stepId: (step as any).id,\n              status: 'success',\n              message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,\n              fallbackUsed: true,\n              fallbackFrom: String(first),\n              fallbackTo: String(resolvedBy),\n            } as any);\n          return {} as ExecResult;\n        }\n      } catch {}\n    }\n    try {\n      if (cssSelector)\n        await handleCallTool({\n          name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n          args: {\n            type: 'MAIN',\n            jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el){el.scrollIntoView({behavior:'instant',block:'center',inline:'nearest'});} }catch(e){}`,\n          },\n        });\n    } catch {}\n    try {\n      if ((located as any)?.ref)\n        await chrome.tabs.sendMessage(\n          tabId,\n          { action: 'focusByRef', ref: (located as any).ref } as any,\n          { frameId } as any,\n        );\n      else if (cssSelector)\n        await handleCallTool({\n          name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n          args: {\n            type: 'MAIN',\n            jsScript: `try{var el=document.querySelector(${JSON.stringify(cssSelector)});if(el&&el.focus){el.focus();}}catch(e){}`,\n          },\n        });\n    } catch {}\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.FILL,\n      args: {\n        ref: (located as any)?.ref || (s as any).target?.ref,\n        selector: cssSelector,\n        value,\n        frameId,\n      },\n    });\n    if ((res as any).isError) throw new Error('fill failed');\n    if (fallbackUsed)\n      ctx.logger({\n        stepId: (step as any).id,\n        status: 'success',\n        message: `Selector fallback used (${String(first)} -> ${String(resolvedBy)})`,\n        fallbackUsed: true,\n        fallbackFrom: String(first),\n        fallbackTo: String(resolvedBy),\n      } as any);\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/http.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepHttp } from '../types';\nimport { applyAssign, expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const httpNode: NodeRuntime<StepHttp> = {\n  run: async (ctx: ExecCtx, step: StepHttp) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.NETWORK_REQUEST,\n      args: {\n        url: s.url,\n        method: s.method || 'GET',\n        headers: s.headers || {},\n        body: s.body,\n        formData: s.formData,\n      },\n    });\n    const text = (res as any)?.content?.find((c: any) => c.type === 'text')?.text;\n    try {\n      const payload = text ? JSON.parse(text) : null;\n      if (s.saveAs && payload !== undefined) ctx.vars[s.saveAs] = payload;\n      if (s.assign && payload !== undefined) applyAssign(ctx.vars, payload, s.assign);\n    } catch {}\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/index.ts",
    "content": "import type { Step } from '../types';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\nimport { clickNode, dblclickNode } from './click';\nimport { fillNode } from './fill';\nimport { httpNode } from './http';\nimport { extractNode } from './extract';\nimport { scriptNode } from './script';\nimport { openTabNode, switchTabNode, closeTabNode } from './tabs';\nimport { scrollNode } from './scroll';\nimport { dragNode } from './drag';\nimport { keyNode } from './key';\nimport { waitNode } from './wait';\nimport { assertNode } from './assert';\nimport { navigateNode } from './navigate';\nimport { ifNode } from './conditional';\nimport { STEP_TYPES } from 'chrome-mcp-shared';\nimport { foreachNode, whileNode } from './loops';\nimport { executeFlowNode } from './execute-flow';\nimport {\n  handleDownloadNode,\n  screenshotNode,\n  triggerEventNode,\n  setAttributeNode,\n  switchFrameNode,\n  loopElementsNode,\n} from './download-screenshot-attr-event-frame-loop';\n\nconst registry = new Map<string, NodeRuntime<any>>([\n  [STEP_TYPES.CLICK, clickNode],\n  [STEP_TYPES.DBLCLICK, dblclickNode],\n  [STEP_TYPES.FILL, fillNode],\n  [STEP_TYPES.HTTP, httpNode],\n  [STEP_TYPES.EXTRACT, extractNode],\n  [STEP_TYPES.SCRIPT, scriptNode],\n  [STEP_TYPES.OPEN_TAB, openTabNode],\n  [STEP_TYPES.SWITCH_TAB, switchTabNode],\n  [STEP_TYPES.CLOSE_TAB, closeTabNode],\n  [STEP_TYPES.SCROLL, scrollNode],\n  [STEP_TYPES.DRAG, dragNode],\n  [STEP_TYPES.KEY, keyNode],\n  [STEP_TYPES.WAIT, waitNode],\n  [STEP_TYPES.ASSERT, assertNode],\n  [STEP_TYPES.NAVIGATE, navigateNode],\n  [STEP_TYPES.IF, ifNode],\n  [STEP_TYPES.FOREACH, foreachNode],\n  [STEP_TYPES.WHILE, whileNode],\n  [STEP_TYPES.EXECUTE_FLOW, executeFlowNode],\n  [STEP_TYPES.HANDLE_DOWNLOAD, handleDownloadNode],\n  [STEP_TYPES.SCREENSHOT, screenshotNode],\n  [STEP_TYPES.TRIGGER_EVENT, triggerEventNode],\n  [STEP_TYPES.SET_ATTRIBUTE, setAttributeNode],\n  [STEP_TYPES.SWITCH_FRAME, switchFrameNode],\n  [STEP_TYPES.LOOP_ELEMENTS, loopElementsNode],\n]);\n\nexport async function executeStep(ctx: ExecCtx, step: Step): Promise<ExecResult> {\n  const rt = registry.get(step.type);\n  if (!rt) throw new Error(`unsupported step type: ${String(step.type)}`);\n  const v = rt.validate ? rt.validate(step) : { ok: true };\n  if (!v.ok) throw new Error((v.errors || []).join(', ') || 'validation failed');\n  const out = await rt.run(ctx, step);\n  return out || {};\n}\n\nexport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/key.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepKey } from '../types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const keyNode: NodeRuntime<StepKey> = {\n  run: async (ctx, step: StepKey) => {\n    const s = expandTemplatesDeep(step as StepKey, ctx.vars) as StepKey;\n    const args: { keys: string; frameId?: number; selector?: string } = { keys: s.keys };\n\n    // Support target selector for focusing before key input\n    if (s.target && s.target.candidates?.length) {\n      const selector = s.target.candidates[0]?.value;\n      if (selector) {\n        args.selector = selector;\n      }\n    }\n\n    if (typeof ctx.frameId === 'number') {\n      args.frameId = ctx.frameId;\n    }\n\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.KEYBOARD,\n      args,\n    });\n    if ((res as any).isError) throw new Error('key failed');\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/loops.ts",
    "content": "import type { ExecCtx, ExecResult, NodeRuntime } from './types';\nimport { ENGINE_CONSTANTS } from '../engine/constants';\n\nexport const foreachNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s = step as any;\n    const ok =\n      typeof s.listVar === 'string' && s.listVar && typeof s.subflowId === 'string' && s.subflowId;\n    return ok ? { ok } : { ok, errors: ['foreach: 需提供 listVar 与 subflowId'] };\n  },\n  run: async (_ctx: ExecCtx, step) => {\n    const s: any = step;\n    const itemVar = typeof s.itemVar === 'string' && s.itemVar ? s.itemVar : 'item';\n    return {\n      control: {\n        kind: 'foreach',\n        listVar: String(s.listVar),\n        itemVar,\n        subflowId: String(s.subflowId),\n        concurrency: Math.max(\n          1,\n          Math.min(ENGINE_CONSTANTS.MAX_FOREACH_CONCURRENCY, Number(s.concurrency ?? 1)),\n        ),\n      },\n    } as ExecResult;\n  },\n};\n\nexport const whileNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const s = step as any;\n    const ok = !!s.condition && typeof s.subflowId === 'string' && s.subflowId;\n    return ok ? { ok } : { ok, errors: ['while: 需提供 condition 与 subflowId'] };\n  },\n  run: async (_ctx: ExecCtx, step) => {\n    const s: any = step;\n    const max = Math.max(1, Math.min(10000, Number(s.maxIterations ?? 100)));\n    return {\n      control: {\n        kind: 'while',\n        condition: s.condition,\n        subflowId: String(s.subflowId),\n        maxIterations: max,\n      },\n    } as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/navigate.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { Step } from '../types';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const navigateNode: NodeRuntime<any> = {\n  validate: (step) => {\n    const ok = !!(step as any).url;\n    return ok ? { ok } : { ok, errors: ['缺少 URL'] };\n  },\n  run: async (_ctx: ExecCtx, step: Step) => {\n    const url = (step as any).url;\n    const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url } });\n    if ((res as any).isError) throw new Error('navigate failed');\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/script.ts",
    "content": "import type { StepScript } from '../types';\nimport { expandTemplatesDeep, applyAssign } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const scriptNode: NodeRuntime<StepScript> = {\n  run: async (ctx: ExecCtx, step: StepScript) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    if (s.when === 'after') return { deferAfterScript: s } as ExecResult;\n    const world = s.world || 'ISOLATED';\n    const code = String(s.code || '');\n    if (!code.trim()) return {} as ExecResult;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') throw new Error('Active tab not found');\n    const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;\n    const [{ result }] = await chrome.scripting.executeScript({\n      target: { tabId, frameIds } as any,\n      func: (userCode: string) => {\n        try {\n          return (0, eval)(userCode);\n        } catch {\n          return null;\n        }\n      },\n      args: [code],\n      world: world as any,\n    } as any);\n    if (s.saveAs) ctx.vars[s.saveAs] = result;\n    if (s.assign && typeof s.assign === 'object') applyAssign(ctx.vars, result, s.assign);\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/scroll.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepScroll } from '../types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const scrollNode: NodeRuntime<StepScroll> = {\n  run: async (ctx, step: StepScroll) => {\n    const s = expandTemplatesDeep(step as StepScroll, ctx.vars);\n    const top = s.offset?.y ?? undefined;\n    const left = s.offset?.x ?? undefined;\n    const selectorFromTarget = (s as any).target?.candidates?.find(\n      (c: any) => c.type === 'css' || c.type === 'attr',\n    )?.value;\n    let code = '';\n    if (s.mode === 'offset' && !(s as any).target) {\n      const t = top != null ? Number(top) : 'undefined';\n      const l = left != null ? Number(left) : 'undefined';\n      code = `try { window.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {}`;\n    } else if (s.mode === 'element' && selectorFromTarget) {\n      code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el) el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); } catch (e) {} })();`;\n    } else if (s.mode === 'container' && selectorFromTarget) {\n      const t = top != null ? Number(top) : 'undefined';\n      const l = left != null ? Number(left) : 'undefined';\n      code = `(() => { try { const el = document.querySelector(${JSON.stringify(selectorFromTarget)}); if (el && typeof el.scrollTo === 'function') el.scrollTo({ top: ${t}, left: ${l}, behavior: 'instant' }); } catch (e) {} })();`;\n    } else {\n      const direction = top != null && Number(top) < 0 ? 'up' : 'down';\n      const amount = 3;\n      const res = await handleCallTool({\n        name: TOOL_NAMES.BROWSER.COMPUTER,\n        args: { action: 'scroll', scrollDirection: direction, scrollAmount: amount },\n      });\n      if ((res as any).isError) throw new Error('scroll failed');\n      return {} as ExecResult;\n    }\n    if (code) {\n      const res = await handleCallTool({\n        name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n        args: { type: 'MAIN', jsScript: code },\n      });\n      if ((res as any).isError) throw new Error('scroll failed');\n    }\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/tabs.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { handleCallTool } from '@/entrypoints/background/tools';\nimport type { StepOpenTab, StepSwitchTab, StepCloseTab } from '../types';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const openTabNode: NodeRuntime<StepOpenTab> = {\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    if (s.newWindow) await chrome.windows.create({ url: s.url || undefined, focused: true });\n    else await chrome.tabs.create({ url: s.url || undefined, active: true });\n    return {} as ExecResult;\n  },\n};\n\nexport const switchTabNode: NodeRuntime<StepSwitchTab> = {\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    let targetTabId: number | undefined = s.tabId;\n    if (!targetTabId) {\n      const tabs = await chrome.tabs.query({});\n      const hit = tabs.find(\n        (t) =>\n          (s.urlContains && (t.url || '').includes(String(s.urlContains))) ||\n          (s.titleContains && (t.title || '').includes(String(s.titleContains))),\n      );\n      targetTabId = (hit && hit.id) as number | undefined;\n    }\n    if (!targetTabId) throw new Error('switchTab: no matching tab');\n    const res = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.SWITCH_TAB,\n      args: { tabId: targetTabId },\n    });\n    if ((res as any).isError) throw new Error('switchTab failed');\n    return {} as ExecResult;\n  },\n};\n\nexport const closeTabNode: NodeRuntime<StepCloseTab> = {\n  run: async (ctx, step) => {\n    const s: any = expandTemplatesDeep(step as any, ctx.vars);\n    const args: any = {};\n    if (Array.isArray(s.tabIds) && s.tabIds.length) args.tabIds = s.tabIds;\n    if (s.url) args.url = s.url;\n    const res = await handleCallTool({ name: TOOL_NAMES.BROWSER.CLOSE_TABS, args });\n    if ((res as any).isError) throw new Error('closeTab failed');\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/types.ts",
    "content": "import type { RunLogEntry, Step, StepScript } from '../types';\n\n/**\n * Execution context for step execution.\n * Contains runtime state that may change during flow execution.\n */\nexport interface ExecCtx {\n  /** Runtime variables accessible to steps */\n  vars: Record<string, any>;\n  /** Logger function for recording execution events */\n  logger: (e: RunLogEntry) => void;\n  /**\n   * Current tab ID for this execution context.\n   * Managed by Scheduler, may change after openTab/switchTab actions.\n   */\n  tabId?: number;\n  /**\n   * Current frame ID within the tab.\n   * Used for iframe targeting, 0 for main frame.\n   */\n  frameId?: number;\n}\n\nexport interface ExecResult {\n  alreadyLogged?: boolean;\n  deferAfterScript?: StepScript | null;\n  nextLabel?: string;\n  control?:\n    | { kind: 'foreach'; listVar: string; itemVar: string; subflowId: string; concurrency?: number }\n    | { kind: 'while'; condition: any; subflowId: string; maxIterations: number };\n}\n\nexport interface NodeRuntime<S extends Step = Step> {\n  validate?: (step: S) => { ok: boolean; errors?: string[] };\n  run: (ctx: ExecCtx, step: S) => Promise<ExecResult | void>;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/nodes/wait.ts",
    "content": "import type { StepWait } from '../types';\nimport { waitForNetworkIdle, waitForNavigation } from '../rr-utils';\nimport { expandTemplatesDeep } from '../rr-utils';\nimport type { ExecCtx, ExecResult, NodeRuntime } from './types';\n\nexport const waitNode: NodeRuntime<StepWait> = {\n  validate: (step) => {\n    const ok = !!(step as any).condition;\n    return ok ? { ok } : { ok, errors: ['缺少等待条件'] };\n  },\n  run: async (ctx: ExecCtx, step: StepWait) => {\n    const s = expandTemplatesDeep(step as StepWait, ctx.vars);\n    const cond = (s as StepWait).condition as\n      | { selector: string; visible?: boolean }\n      | { text: string; appear?: boolean }\n      | { navigation: true }\n      | { networkIdle: true }\n      | { sleep: number };\n    if ('text' in cond) {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const tabId = tabs?.[0]?.id;\n      if (typeof tabId !== 'number') throw new Error('Active tab not found');\n      const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;\n      await chrome.scripting.executeScript({\n        target: { tabId, frameIds },\n        files: ['inject-scripts/wait-helper.js'],\n        world: 'ISOLATED',\n      } as any);\n      const resp: any = (await chrome.tabs.sendMessage(\n        tabId,\n        {\n          action: 'waitForText',\n          text: cond.text,\n          appear: (cond as any).appear !== false,\n          timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),\n        } as any,\n        { frameId: ctx.frameId } as any,\n      )) as any;\n      if (!resp || resp.success !== true) throw new Error('wait text failed');\n    } else if ('networkIdle' in cond) {\n      const total = Math.min(Math.max(1000, (s as any).timeoutMs || 5000), 120000);\n      const idle = Math.min(1500, Math.max(500, Math.floor(total / 3)));\n      await waitForNetworkIdle(total, idle);\n    } else if ('navigation' in cond) {\n      await waitForNavigation((s as any).timeoutMs);\n    } else if ('sleep' in cond) {\n      const ms = Math.max(0, Number(cond.sleep ?? 0));\n      await new Promise((r) => setTimeout(r, ms));\n    } else if ('selector' in cond) {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const tabId = tabs?.[0]?.id;\n      if (typeof tabId !== 'number') throw new Error('Active tab not found');\n      const frameIds = typeof ctx.frameId === 'number' ? [ctx.frameId] : undefined;\n      await chrome.scripting.executeScript({\n        target: { tabId, frameIds },\n        files: ['inject-scripts/wait-helper.js'],\n        world: 'ISOLATED',\n      } as any);\n      const resp: any = (await chrome.tabs.sendMessage(\n        tabId,\n        {\n          action: 'waitForSelector',\n          selector: (cond as any).selector,\n          visible: (cond as any).visible !== false,\n          timeout: Math.max(0, Math.min((s as any).timeoutMs || 10000, 120000)),\n        } as any,\n        { frameId: ctx.frameId } as any,\n      )) as any;\n      if (!resp || resp.success !== true) throw new Error('wait selector failed');\n    }\n    return {} as ExecResult;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts",
    "content": "import { addNavigationStep } from './flow-builder';\nimport { STEP_TYPES } from '@/common/step-types';\nimport { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection';\nimport type { RecordingSessionManager } from './session-manager';\nimport type { Step } from '../types';\n\nexport function initBrowserEventListeners(session: RecordingSessionManager): void {\n  chrome.tabs.onActivated.addListener(async (activeInfo) => {\n    try {\n      if (session.getStatus() !== 'recording') return;\n      const tabId = activeInfo.tabId;\n      await ensureRecorderInjected(tabId);\n      await broadcastControlToTab(tabId, REC_CMD.START);\n      // Track active tab for targeted STOP later\n      session.addActiveTab(tabId);\n\n      const flow = session.getFlow();\n      if (!flow) return;\n      const tab = await chrome.tabs.get(tabId);\n      const url = tab.url;\n      const step: Step = {\n        id: '',\n        type: STEP_TYPES.SWITCH_TAB,\n        ...(url ? { urlContains: url } : {}),\n      };\n      session.appendSteps([step]);\n    } catch (e) {\n      console.warn('onActivated handler failed', e);\n    }\n  });\n\n  chrome.webNavigation.onCommitted.addListener(async (details) => {\n    try {\n      if (session.getStatus() !== 'recording') return;\n      if (details.frameId !== 0) return;\n      const tabId = details.tabId;\n      const t = details.transitionType;\n      const link = t === 'link';\n      if (!link) {\n        const shouldRecord =\n          t === 'reload' ||\n          t === 'typed' ||\n          t === 'generated' ||\n          t === 'auto_bookmark' ||\n          t === 'keyword' ||\n          // include form_submit to better capture Enter-to-search navigations\n          t === 'form_submit';\n        if (shouldRecord) {\n          const tab = await chrome.tabs.get(tabId);\n          const url = tab.url || details.url;\n          const flow = session.getFlow();\n          if (flow && url) addNavigationStep(flow, url);\n        }\n      }\n      await ensureRecorderInjected(tabId);\n      await broadcastControlToTab(tabId, REC_CMD.START);\n      // Track active tab for targeted STOP later\n      session.addActiveTab(tabId);\n      if (session.getFlow()) {\n        session.broadcastTimelineUpdate();\n      }\n    } catch (e) {\n      console.warn('onCommitted handler failed', e);\n    }\n  });\n\n  // Remove closed tabs from the active set to avoid stale broadcasts\n  chrome.tabs.onRemoved.addListener((tabId) => {\n    try {\n      // Even if not recording, removing is harmless; keep guard for clarity\n      if (session.getStatus() !== 'recording') return;\n      session.removeActiveTab(tabId);\n    } catch (e) {\n      console.warn('onRemoved handler failed', e);\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/content-injection.ts",
    "content": "import { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\n// Avoid magic strings for recorder control commands\nexport type RecorderCmd = 'start' | 'stop' | 'pause' | 'resume';\nexport const REC_CMD = {\n  START: 'start',\n  STOP: 'stop',\n  PAUSE: 'pause',\n  RESUME: 'resume',\n} as const satisfies Record<string, RecorderCmd>;\n\nconst RECORDER_JS_SCRIPT = 'inject-scripts/recorder.js';\n\nexport async function ensureRecorderInjected(tabId: number): Promise<void> {\n  // Discover frames (top + subframes)\n  let frames: Array<{ frameId: number } & Record<string, any>> = [];\n  try {\n    const res = (await chrome.webNavigation.getAllFrames({ tabId })) as\n      | Array<{ frameId: number } & Record<string, any>>\n      | null\n      | undefined;\n    frames = Array.isArray(res) ? res : [];\n  } catch {\n    // ignore and fallback to top frame only\n  }\n  if (frames.length === 0) frames = [{ frameId: 0 }];\n\n  const needRecorder: number[] = [];\n  await Promise.all(\n    frames.map(async (f) => {\n      const frameId = f.frameId ?? 0;\n      try {\n        const res = await chrome.tabs.sendMessage(\n          tabId,\n          { action: 'rr_recorder_ping' },\n          { frameId },\n        );\n        const pong = res?.status === 'pong';\n        if (!pong) needRecorder.push(frameId);\n      } catch {\n        needRecorder.push(frameId);\n      }\n    }),\n  );\n\n  if (needRecorder.length > 0) {\n    try {\n      await chrome.scripting.executeScript({\n        target: { tabId, frameIds: needRecorder },\n        files: [RECORDER_JS_SCRIPT],\n        world: 'ISOLATED',\n      });\n    } catch {\n      // Fallback: try allFrames to cover dynamic/subframe changes; safe due to idempotent guard in recorder.js\n      try {\n        await chrome.scripting.executeScript({\n          target: { tabId, allFrames: true },\n          files: [RECORDER_JS_SCRIPT],\n          world: 'ISOLATED',\n        });\n      } catch {\n        // ignore injection failures per-tab\n      }\n    }\n  }\n}\n\nexport async function broadcastControlToTab(\n  tabId: number,\n  cmd: RecorderCmd,\n  meta?: unknown,\n): Promise<void> {\n  try {\n    const res = (await chrome.webNavigation.getAllFrames({ tabId })) as\n      | Array<{ frameId: number } & Record<string, any>>\n      | null\n      | undefined;\n    const targets = Array.isArray(res) && res.length ? res : [{ frameId: 0 }];\n    await Promise.all(\n      targets.map(async (f) => {\n        try {\n          await chrome.tabs.sendMessage(\n            tabId,\n            { action: TOOL_MESSAGE_TYPES.RR_RECORDER_CONTROL, cmd, meta },\n            { frameId: f.frameId },\n          );\n        } catch {\n          // ignore per-frame send failure\n        }\n      }),\n    );\n  } catch {\n    // ignore\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/content-message-handler.ts",
    "content": "import type { RecordingSessionManager } from './session-manager';\nimport type { Step, VariableDef } from '../types';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\n/**\n * Initialize the content message handler for receiving steps and variables from content scripts.\n *\n * Supports the following payload kinds:\n * - 'steps' | 'step': Append steps to the current flow\n * - 'variables': Append variables to the current flow (for sensitive input handling)\n * - 'finalize': Content script has finished flushing (used during stop barrier)\n */\nexport function initContentMessageHandler(session: RecordingSessionManager): void {\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    try {\n      if (!message || message.type !== TOOL_MESSAGE_TYPES.RR_RECORDER_EVENT) return false;\n\n      // Accept messages during 'recording' or 'stopping' states\n      // 'stopping' allows final steps to arrive during the drain phase\n      if (!session.canAcceptSteps()) {\n        sendResponse({ ok: true, ignored: true });\n        return true;\n      }\n\n      const flow = session.getFlow();\n      if (!flow) {\n        sendResponse({ ok: true, ignored: true });\n        return true;\n      }\n\n      const payload = message?.payload || {};\n\n      // Handle steps\n      if (payload.kind === 'steps' || payload.kind === 'step') {\n        const steps: Step[] = Array.isArray(payload.steps)\n          ? (payload.steps as Step[])\n          : payload.step\n            ? [payload.step as Step]\n            : [];\n        if (steps.length > 0) {\n          session.appendSteps(steps);\n        }\n      }\n\n      // Handle variables (for sensitive input handling)\n      if (payload.kind === 'variables') {\n        const variables: VariableDef[] = Array.isArray(payload.variables)\n          ? (payload.variables as VariableDef[])\n          : [];\n        if (variables.length > 0) {\n          session.appendVariables(variables);\n        }\n      }\n\n      // Handle combined payload (steps + variables in one message)\n      if (payload.kind === 'batch') {\n        const steps: Step[] = Array.isArray(payload.steps) ? (payload.steps as Step[]) : [];\n        const variables: VariableDef[] = Array.isArray(payload.variables)\n          ? (payload.variables as VariableDef[])\n          : [];\n        if (steps.length > 0) {\n          session.appendSteps(steps);\n        }\n        if (variables.length > 0) {\n          session.appendVariables(variables);\n        }\n      }\n\n      // payload.kind === 'start'|'stop'|'finalize' are no-ops here (lifecycle handled elsewhere)\n      sendResponse({ ok: true });\n      return true;\n    } catch (e) {\n      console.warn('ContentMessageHandler: processing message failed', e);\n      sendResponse({ ok: false, error: String((e as Error)?.message || e) });\n      return true;\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts",
    "content": "import type { Edge, Flow, NodeBase, Step } from '../types';\nimport { STEP_TYPES } from '@/common/step-types';\nimport { recordingSession } from './session-manager';\nimport { mapStepToNodeConfig, EDGE_LABELS } from 'chrome-mcp-shared';\n\nconst WORKFLOW_VERSION = 1;\n\n/**\n * Creates an initial flow structure for recording.\n * Initializes with nodes/edges (DAG) instead of steps.\n */\nexport function createInitialFlow(meta?: Partial<Flow>): Flow {\n  const timeStamp = new Date().toISOString();\n  const flow: Flow = {\n    id: meta?.id || `flow_${Date.now()}`,\n    name: meta?.name || 'new_workflow',\n    version: WORKFLOW_VERSION,\n    nodes: [],\n    edges: [],\n    variables: [],\n    meta: {\n      createdAt: timeStamp,\n      updatedAt: timeStamp,\n      ...meta?.meta,\n    },\n  };\n  return flow;\n}\n\nexport function generateStepId(): string {\n  return `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;\n}\n\n/**\n * Appends a navigation step to the flow.\n * Prefers centralized session append when recording is active.\n * Falls back to direct DAG mutation (does NOT write flow.steps).\n */\nexport function addNavigationStep(flow: Flow, url: string): void {\n  const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step;\n\n  // Prefer centralized session append (single broadcast path) when active and matching flow\n  const sessFlow = recordingSession.getFlow?.();\n  if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) {\n    recordingSession.appendSteps([step]);\n    return;\n  }\n\n  // Fallback: mutate DAG directly (do not write flow.steps)\n  appendNodeToFlow(flow, step);\n}\n\n/**\n * Appends a step as a node to the flow's DAG structure.\n * Creates node and edge from the previous node if exists.\n *\n * Internal helper - rarely invoked in practice. During active recording,\n * addNavigationStep() routes to session.appendSteps() which handles DAG\n * maintenance, caching, and timeline broadcast. This fallback only runs\n * when session is not active or flow reference doesn't match.\n */\nfunction appendNodeToFlow(flow: Flow, step: Step): void {\n  // Ensure DAG arrays exist\n  if (!Array.isArray(flow.nodes)) flow.nodes = [];\n  if (!Array.isArray(flow.edges)) flow.edges = [];\n\n  const prevNodeId = flow.nodes.length > 0 ? flow.nodes[flow.nodes.length - 1]?.id : undefined;\n\n  // Create new node\n  const newNode: NodeBase = {\n    id: step.id,\n    type: step.type as NodeBase['type'],\n    config: mapStepToNodeConfig(step),\n  };\n  flow.nodes.push(newNode);\n\n  // Create edge from previous node if exists\n  if (prevNodeId) {\n    const edgeId = `e_${flow.edges.length}_${prevNodeId}_${step.id}`;\n    const edge: Edge = {\n      id: edgeId,\n      from: prevNodeId,\n      to: step.id,\n      label: EDGE_LABELS.DEFAULT,\n    };\n    flow.edges.push(edge);\n  }\n\n  // Update meta timestamp (with error tolerance like session-manager)\n  try {\n    const timeStamp = new Date().toISOString();\n    if (!flow.meta) {\n      flow.meta = { createdAt: timeStamp, updatedAt: timeStamp };\n    } else {\n      flow.meta.updatedAt = timeStamp;\n    }\n  } catch {\n    // ignore meta update errors to not block recording\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts",
    "content": "import type { Flow } from '../types';\nimport { saveFlow } from '../flow-store';\nimport { broadcastControlToTab, ensureRecorderInjected, REC_CMD } from './content-injection';\nimport { recordingSession as session } from './session-manager';\nimport { createInitialFlow, addNavigationStep } from './flow-builder';\nimport { initBrowserEventListeners } from './browser-event-listener';\nimport { initContentMessageHandler } from './content-message-handler';\n\n/** Timeout for waiting for the top-frame content script to acknowledge stop. */\nconst STOP_BARRIER_TOP_TIMEOUT_MS = 5000;\n\n/** Best-effort stop timeout for subframes (keeps top-frame still listening). */\nconst STOP_BARRIER_SUBFRAME_TIMEOUT_MS = 1500;\n\n/** Small grace period for in-flight messages after all ACKs. */\nconst STOP_BARRIER_GRACE_MS = 150;\n\n/** Types for stop barrier results */\ninterface StopAckStats {\n  ack: boolean;\n  steps: number;\n  variables: number;\n}\n\ninterface StopFrameAck {\n  frameId: number;\n  ack: boolean;\n  timedOut: boolean;\n  error?: string;\n  stats?: StopAckStats;\n}\n\ninterface StopTabBarrierResult {\n  tabId: number;\n  ok: boolean;\n  skipped?: boolean;\n  reason?: string;\n  top?: StopFrameAck;\n  subframes: StopFrameAck[];\n}\n\n/**\n * List frameIds for a tab. Always includes 0 (main frame).\n */\nasync function listFrameIds(tabId: number): Promise<number[]> {\n  try {\n    const res = await chrome.webNavigation.getAllFrames({ tabId });\n    const ids = Array.isArray(res)\n      ? res.map((f) => f.frameId).filter((n) => typeof n === 'number')\n      : [];\n    if (!ids.includes(0)) ids.unshift(0);\n    return Array.from(new Set(ids)).sort((a, b) => a - b);\n  } catch {\n    return [0];\n  }\n}\n\n/**\n * Send stop command to a specific frame and wait for acknowledgment.\n */\nasync function sendStopToFrameWithAck(\n  tabId: number,\n  sessionId: string,\n  frameId: number,\n  timeoutMs: number,\n): Promise<StopFrameAck> {\n  return new Promise((resolve) => {\n    const t = setTimeout(() => {\n      resolve({ frameId, ack: false, timedOut: true });\n    }, timeoutMs);\n\n    chrome.tabs\n      .sendMessage(\n        tabId,\n        {\n          action: REC_CMD.STOP,\n          sessionId,\n          requireAck: true,\n        },\n        { frameId },\n      )\n      .then((response) => {\n        clearTimeout(t);\n        const ack = !!(response && response.ack);\n        const stats = response && response.stats ? (response.stats as StopAckStats) : undefined;\n        resolve({ frameId, ack, timedOut: false, stats });\n      })\n      .catch((err) => {\n        clearTimeout(t);\n        resolve({ frameId, ack: false, timedOut: false, error: String(err) });\n      });\n  });\n}\n\n/**\n * Stop a tab with full barrier support.\n * 1. Stop subframes first (so they can finalize and postMessage to top while top is still listening)\n * 2. Stop the main frame (top) and wait for ACK\n */\nasync function stopTabWithBarrier(tabId: number, sessionId: string): Promise<StopTabBarrierResult> {\n  // If the tab is already gone, don't block stop.\n  try {\n    await chrome.tabs.get(tabId);\n  } catch {\n    return { tabId, ok: true, skipped: true, reason: 'tab not found', subframes: [] };\n  }\n\n  // Ensure recorder is available in frames (best-effort).\n  try {\n    await ensureRecorderInjected(tabId);\n  } catch {}\n\n  const frameIds = await listFrameIds(tabId);\n  const subframeIds = frameIds.filter((id) => id !== 0);\n\n  // Stop subframes first so they can finalize and postMessage to top while top is still listening.\n  const subframes = await Promise.all(\n    subframeIds.map((fid) =>\n      sendStopToFrameWithAck(tabId, sessionId, fid, STOP_BARRIER_SUBFRAME_TIMEOUT_MS),\n    ),\n  );\n\n  // Stop the main frame (top) with longer timeout\n  const top = await sendStopToFrameWithAck(tabId, sessionId, 0, STOP_BARRIER_TOP_TIMEOUT_MS);\n\n  return { tabId, ok: top.ack, top, subframes };\n}\n\nclass RecorderManagerImpl {\n  private initialized = false;\n\n  async init(): Promise<void> {\n    if (this.initialized) return;\n    initBrowserEventListeners(session);\n    initContentMessageHandler(session);\n    this.initialized = true;\n  }\n\n  async start(meta?: Partial<Flow>): Promise<{ success: boolean; error?: string }> {\n    if (session.getStatus() !== 'idle')\n      return { success: false, error: 'Recording already active' };\n    // Resolve active tab\n    const [active] = await chrome.tabs.query({ active: true, currentWindow: true });\n    if (!active?.id) return { success: false, error: 'Active tab not found' };\n\n    // Initialize flow & session\n    const flow: Flow = createInitialFlow(meta);\n    await session.startSession(flow, active.id);\n\n    // Ensure recorder available and start listening\n    await ensureRecorderInjected(active.id);\n    await broadcastControlToTab(active.id, REC_CMD.START, {\n      id: flow.id,\n      name: flow.name,\n      description: flow.description,\n      sessionId: session.getSession().sessionId,\n    });\n    // Track active tab for targeted STOP broadcasts\n    session.addActiveTab(active.id);\n\n    // Record first step\n    const url = active.url;\n    if (url) {\n      addNavigationStep(flow, url);\n      try {\n        await saveFlow(flow);\n      } catch (e) {\n        console.warn('RecorderManager: initial saveFlow failed', e);\n      }\n    }\n\n    return { success: true };\n  }\n\n  /**\n   * Stop recording with reliable step collection using barrier protocol.\n   *\n   * Flow:\n   * 1. Transition to 'stopping' state (still accepts final steps)\n   * 2. For each tab: stop subframes first (best-effort), then stop main frame\n   * 3. Wait for main frame ACK (required) with timeout\n   * 4. Grace period for any final messages in flight\n   * 5. Finalize session and save flow with barrier metadata\n   *\n   * The barrier ensures:\n   * - All tabs have flushed their data before save\n   * - Subframes finalize to top before top stops\n   * - Barrier status is recorded in flow.meta for debugging\n   */\n  async stop(): Promise<{ success: boolean; error?: string; flow?: Flow }> {\n    const currentStatus = session.getStatus();\n    if (currentStatus === 'idle' || !session.getFlow()) {\n      return { success: false, error: 'No active recording' };\n    }\n\n    // Already stopping - don't double-stop\n    if (currentStatus === 'stopping') {\n      return { success: false, error: 'Stop already in progress' };\n    }\n\n    // Step 1: Transition to stopping state\n    const sessionId = session.beginStopping();\n    const tabs = session.getActiveTabs();\n\n    // Step 2: Send stop commands to all tabs with full barrier support\n    // Each tab: stop subframes first, then stop main frame and wait for ACK\n    let results: StopTabBarrierResult[] = [];\n    try {\n      results = await Promise.all(tabs.map((tabId) => stopTabWithBarrier(tabId, sessionId)));\n    } catch (e) {\n      console.warn('RecorderManager: Error during stop broadcast:', e);\n    }\n\n    // Step 3: Allow a small grace period for any final messages in flight\n    await new Promise((resolve) => setTimeout(resolve, STOP_BARRIER_GRACE_MS));\n\n    // Step 4: Finalize - clear session state and save with barrier metadata\n    const flow = await session.stopSession();\n    const barrierOk = results.length === tabs.length && results.every((r) => r.ok || r.skipped);\n    const stoppedAt = new Date().toISOString();\n\n    if (flow) {\n      // Add barrier metadata to flow\n      try {\n        if (!flow.meta) flow.meta = { createdAt: stoppedAt, updatedAt: stoppedAt };\n        const failed = results\n          .filter((r) => !r.ok || r.skipped || r.subframes.some((sf) => !sf.ack))\n          .map((r) => ({\n            tabId: r.tabId,\n            skipped: r.skipped || undefined,\n            reason: r.reason || undefined,\n            topTimedOut: r.top?.timedOut || undefined,\n            topError: r.top?.error || undefined,\n            subframesFailed: r.subframes.filter((sf) => !sf.ack).length || undefined,\n          }))\n          .slice(0, 20); // Limit to first 20 to avoid bloating metadata\n\n        flow.meta.stopBarrier = {\n          ok: barrierOk,\n          sessionId,\n          stoppedAt,\n          failed: failed.length ? failed : undefined,\n        };\n      } catch {}\n\n      await saveFlow(flow);\n    }\n\n    // Return with barrier status\n    if (!barrierOk) {\n      const failedTabs = results.filter((r) => !r.ok && !r.skipped).map((r) => r.tabId);\n      return {\n        success: true, // Flow is still saved, but with incomplete barrier\n        flow: flow || undefined,\n        error: failedTabs.length\n          ? `Stop barrier incomplete; missing ACK from tabs: ${failedTabs.join(', ')}`\n          : 'Stop barrier incomplete; missing ACK(s)',\n      };\n    }\n\n    return flow ? { success: true, flow } : { success: true };\n  }\n\n  /**\n   * Pause recording. Steps are not collected while paused.\n   */\n  async pause(): Promise<{ success: boolean; error?: string }> {\n    if (session.getStatus() !== 'recording') {\n      return { success: false, error: 'Not currently recording' };\n    }\n\n    session.pause();\n\n    // Broadcast pause to all active tabs\n    const tabs = session.getActiveTabs();\n    try {\n      await Promise.all(tabs.map((id) => broadcastControlToTab(id, REC_CMD.PAUSE)));\n    } catch (e) {\n      console.warn('RecorderManager: Error during pause broadcast:', e);\n    }\n\n    return { success: true };\n  }\n\n  /**\n   * Resume recording after pause.\n   */\n  async resume(): Promise<{ success: boolean; error?: string }> {\n    if (session.getStatus() !== 'paused') {\n      return { success: false, error: 'Not currently paused' };\n    }\n\n    session.resume();\n\n    // Broadcast resume to all active tabs\n    const tabs = session.getActiveTabs();\n    try {\n      await Promise.all(tabs.map((id) => broadcastControlToTab(id, REC_CMD.RESUME)));\n    } catch (e) {\n      console.warn('RecorderManager: Error during resume broadcast:', e);\n    }\n\n    return { success: true };\n  }\n}\n\nexport const RecorderManager = new RecorderManagerImpl();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts",
    "content": "import type { Edge, Flow, NodeBase, Step, VariableDef } from '../types';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { NODE_TYPES } from '@/common/node-types';\nimport { mapStepToNodeConfig, stepsToDAG, EDGE_LABELS } from 'chrome-mcp-shared';\n\n/**\n * Recording status state machine:\n * - idle: No active recording\n * - recording: Actively capturing user interactions\n * - paused: Temporarily paused (UI can resume)\n * - stopping: Draining final steps from content scripts before save\n */\nexport type RecordingStatus = 'idle' | 'recording' | 'paused' | 'stopping';\n\nexport interface RecordingSessionState {\n  sessionId: string;\n  status: RecordingStatus;\n  originTabId: number | null;\n  flow: Flow | null;\n  // Track tabs that have participated in this recording session\n  activeTabs: Set<number>;\n  // Track which tabs have acknowledged stop command\n  stoppedTabs: Set<number>;\n}\n\n// Valid node types for type checking\nconst VALID_NODE_TYPES = new Set<string>(Object.values(NODE_TYPES));\n\nexport class RecordingSessionManager {\n  private state: RecordingSessionState = {\n    sessionId: '',\n    status: 'idle',\n    originTabId: null,\n    flow: null,\n    activeTabs: new Set<number>(),\n    stoppedTabs: new Set<number>(),\n  };\n\n  // Session-level cache for incremental DAG sync (cleared on session start/stop)\n  // Note: stepIndexMap removed - we no longer write to flow.steps\n  private nodeIndexMap: Map<string, number> = new Map();\n  // Monotonic counter for edge id generation (avoids collision on delete/reorder)\n  private edgeSeq: number = 0;\n\n  getStatus(): RecordingStatus {\n    return this.state.status;\n  }\n\n  getSession(): Readonly<RecordingSessionState> {\n    return this.state;\n  }\n\n  getFlow(): Flow | null {\n    return this.state.flow;\n  }\n\n  getOriginTabId(): number | null {\n    return this.state.originTabId;\n  }\n\n  addActiveTab(tabId: number): void {\n    if (typeof tabId === 'number') this.state.activeTabs.add(tabId);\n  }\n\n  removeActiveTab(tabId: number): void {\n    this.state.activeTabs.delete(tabId);\n  }\n\n  getActiveTabs(): number[] {\n    return Array.from(this.state.activeTabs);\n  }\n\n  async startSession(flow: Flow, originTabId: number): Promise<void> {\n    // Clear cache for fresh session\n    this.nodeIndexMap.clear();\n    this.edgeSeq = 0;\n\n    this.state = {\n      sessionId: `sess_${Date.now()}`,\n      status: 'recording',\n      originTabId,\n      flow,\n      activeTabs: new Set<number>([originTabId]),\n      stoppedTabs: new Set<number>(),\n    };\n\n    // Initialize caches from existing flow data (supports resume scenarios)\n    this.rebuildCaches();\n  }\n\n  /**\n   * Transition to stopping state. Content scripts can still send final steps.\n   * Returns the sessionId for barrier verification.\n   */\n  beginStopping(): string {\n    if (this.state.status === 'idle') return '';\n    this.state.status = 'stopping';\n    this.state.stoppedTabs.clear();\n    return this.state.sessionId;\n  }\n\n  /**\n   * Mark a tab as having acknowledged the stop command.\n   * Returns true if all active tabs have stopped.\n   */\n  markTabStopped(tabId: number): boolean {\n    this.state.stoppedTabs.add(tabId);\n    // Check if all active tabs have acknowledged\n    for (const activeTabId of this.state.activeTabs) {\n      if (!this.state.stoppedTabs.has(activeTabId)) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  /**\n   * Check if we're in stopping state (still accepting final steps).\n   */\n  isStopping(): boolean {\n    return this.state.status === 'stopping';\n  }\n\n  /**\n   * Check if we can accept steps (recording or stopping).\n   */\n  canAcceptSteps(): boolean {\n    return this.state.status === 'recording' || this.state.status === 'stopping';\n  }\n\n  /**\n   * Transition to paused state.\n   */\n  pause(): void {\n    if (this.state.status === 'recording') {\n      this.state.status = 'paused';\n    }\n  }\n\n  /**\n   * Resume from paused state.\n   */\n  resume(): void {\n    if (this.state.status === 'paused') {\n      this.state.status = 'recording';\n    }\n  }\n\n  /**\n   * Finalize stop and clear session state.\n   */\n  async stopSession(): Promise<Flow | null> {\n    const flow = this.state.flow;\n    this.state.status = 'idle';\n    this.state.flow = null;\n    this.state.originTabId = null;\n    this.state.activeTabs.clear();\n    this.state.stoppedTabs.clear();\n    // Clear cache\n    this.nodeIndexMap.clear();\n    this.edgeSeq = 0;\n    return flow;\n  }\n\n  updateFlow(mutator: (f: Flow) => void): void {\n    const f = this.state.flow;\n    if (!f) return;\n    mutator(f);\n    try {\n      (f.meta as any).updatedAt = new Date().toISOString();\n    } catch (e) {\n      // ignore meta update errors\n    }\n  }\n\n  /**\n   * Append or upsert steps to the flow with incremental DAG sync.\n   * Uses upsert semantics: if a step with the same id exists, update it in place.\n   * This ensures fill steps get their final value even after initial flush.\n   *\n   * DAG sync: maintains flow.nodes/edges during recording.\n   * - New step → create node + edge from previous node\n   * - Upsert step → update node.config and node.type\n   * - Invariant violation → fallback to linear DAG rebuild\n   *\n   * Note: flow.steps is no longer written. Nodes are the source of truth.\n   */\n  appendSteps(steps: Step[]): void {\n    const f = this.state.flow;\n    if (!f || !Array.isArray(steps) || steps.length === 0) return;\n\n    // Initialize arrays if missing\n    if (!Array.isArray(f.nodes)) f.nodes = [];\n    if (!Array.isArray(f.edges)) f.edges = [];\n\n    // Legacy compatibility: if flow only has steps, initialize DAG from them once\n    if (f.nodes.length === 0 && Array.isArray(f.steps) && f.steps.length > 0) {\n      this.rebuildDagFromSteps();\n    }\n\n    const nodes = f.nodes;\n    const edges = f.edges;\n\n    // Check invariants: edges must match linear chain\n    // If violated (e.g., imported flow, manual edit), rebuild linear chain\n    if (!this.checkDagInvariant(nodes, edges)) {\n      this.rechainEdges();\n    }\n\n    // Process each incoming step with upsert semantics + incremental DAG sync\n    let needsRebuild = false;\n    for (const step of steps) {\n      // Ensure step has an id\n      if (!step.id) {\n        step.id = `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;\n      }\n\n      const nodeIdx = this.nodeIndexMap.get(step.id);\n      if (nodeIdx !== undefined) {\n        // Upsert: update existing node in place\n        if (!nodes[nodeIdx]) {\n          needsRebuild = true;\n          continue;\n        }\n        nodes[nodeIdx] = {\n          ...nodes[nodeIdx],\n          type: this.toNodeType(step.type),\n          config: mapStepToNodeConfig(step),\n        };\n      } else {\n        // Append: new node\n        const prevNodeId = nodes.length > 0 ? nodes[nodes.length - 1]?.id : undefined;\n\n        // Create corresponding node\n        const newNode: NodeBase = {\n          id: step.id,\n          type: this.toNodeType(step.type),\n          config: mapStepToNodeConfig(step),\n        };\n        nodes.push(newNode);\n        this.nodeIndexMap.set(step.id, nodes.length - 1);\n\n        // Create edge from previous node (if exists)\n        if (prevNodeId) {\n          if (!this.nodeIndexMap.has(prevNodeId)) {\n            needsRebuild = true;\n            continue;\n          }\n          const edgeId = `e_${this.edgeSeq++}_${prevNodeId}_${step.id}`;\n          edges.push({\n            id: edgeId,\n            from: prevNodeId,\n            to: step.id,\n            label: EDGE_LABELS.DEFAULT,\n          });\n        }\n      }\n    }\n\n    // Final invariant check: if any inconsistency detected, rebuild edges\n    if (needsRebuild || !this.checkDagInvariant(nodes, edges)) {\n      this.rechainEdges();\n    }\n\n    // Update meta timestamp\n    try {\n      if (f.meta) {\n        f.meta.updatedAt = new Date().toISOString();\n      }\n    } catch {\n      // ignore meta update errors\n    }\n\n    this.broadcastTimelineUpdate();\n  }\n\n  /**\n   * Convert step type to valid NodeType with fallback to SCRIPT.\n   * Logs a warning for unknown types to help detect upstream type drift.\n   */\n  private toNodeType(stepType: string): NodeBase['type'] {\n    if (VALID_NODE_TYPES.has(stepType)) {\n      return stepType as NodeBase['type'];\n    }\n    console.warn(`[RecordingSession] Unknown step type \"${stepType}\", falling back to \"script\"`);\n    return NODE_TYPES.SCRIPT;\n  }\n\n  /**\n   * Check DAG invariant for linear recording:\n   * - edges.length === max(0, nodes.length - 1)\n   * - Last edge (if exists) points to the last node\n   */\n  private checkDagInvariant(nodes: NodeBase[], edges: Edge[]): boolean {\n    const nodeCount = nodes.length;\n    const expectedEdgeCount = Math.max(0, nodeCount - 1);\n\n    // Check edge count matches expected linear chain\n    if (edges.length !== expectedEdgeCount) {\n      return false;\n    }\n\n    // Check last edge points to last node (if edges exist)\n    if (edges.length > 0 && nodes.length > 0) {\n      const lastEdge = edges[edges.length - 1];\n      const lastNodeId = nodes[nodes.length - 1]?.id;\n      if (lastEdge.to !== lastNodeId) {\n        return false;\n      }\n    }\n\n    return true;\n  }\n\n  /**\n   * Rebuild caches from current flow state.\n   * Called on session start and after DAG rebuild.\n   */\n  private rebuildCaches(): void {\n    const f = this.state.flow;\n    if (!f) return;\n\n    this.nodeIndexMap.clear();\n\n    if (Array.isArray(f.nodes)) {\n      for (let i = 0; i < f.nodes.length; i++) {\n        const id = f.nodes[i]?.id;\n        if (id) this.nodeIndexMap.set(id, i);\n      }\n    }\n\n    // Sync edgeSeq to continue from current edge count (avoids id collision)\n    this.edgeSeq = Array.isArray(f.edges) ? f.edges.length : 0;\n  }\n\n  /**\n   * Full DAG rebuild from legacy steps.\n   * Used when flow only has steps[] but no nodes[].\n   */\n  private rebuildDagFromSteps(): void {\n    const f = this.state.flow;\n    if (!f || !Array.isArray(f.steps) || f.steps.length === 0) return;\n\n    const dag = stepsToDAG(f.steps);\n\n    // Clear and repopulate nodes\n    if (!Array.isArray(f.nodes)) f.nodes = [];\n    f.nodes.length = 0;\n    for (const n of dag.nodes) {\n      f.nodes.push({\n        id: n.id,\n        type: this.toNodeType(n.type),\n        config: n.config,\n      });\n    }\n\n    // Clear and repopulate edges\n    if (!Array.isArray(f.edges)) f.edges = [];\n    f.edges.length = 0;\n    for (const e of dag.edges) {\n      f.edges.push({\n        id: e.id,\n        from: e.from,\n        to: e.to,\n        label: e.label,\n      });\n    }\n\n    // Rebuild caches\n    this.rebuildCaches();\n  }\n\n  /**\n   * Re-chain edges linearly according to current nodes order.\n   * Used when edge invariant is violated but nodes exist.\n   */\n  private rechainEdges(): void {\n    const f = this.state.flow;\n    if (!f) return;\n\n    if (!Array.isArray(f.nodes)) f.nodes = [];\n    if (!Array.isArray(f.edges)) f.edges = [];\n\n    // Clear and re-chain edges\n    f.edges.length = 0;\n    for (let i = 0; i < f.nodes.length - 1; i++) {\n      const from = f.nodes[i].id;\n      const to = f.nodes[i + 1].id;\n      f.edges.push({\n        id: `e_${i}_${from}_${to}`,\n        from,\n        to,\n        label: EDGE_LABELS.DEFAULT,\n      });\n    }\n\n    // Rebuild caches\n    this.rebuildCaches();\n  }\n\n  /**\n   * Append variables to the flow. Deduplicates by key.\n   */\n  appendVariables(variables: VariableDef[]): void {\n    const f = this.state.flow;\n    if (!f || !Array.isArray(variables) || variables.length === 0) return;\n\n    if (!f.variables) {\n      f.variables = [];\n    }\n\n    // Deduplicate by key - newer definitions override older ones\n    const existingKeys = new Set(f.variables.map((v) => v.key));\n    for (const v of variables) {\n      if (!v.key) continue;\n      if (existingKeys.has(v.key)) {\n        // Update existing variable\n        const idx = f.variables.findIndex((fv) => fv.key === v.key);\n        if (idx >= 0) {\n          f.variables[idx] = v;\n        }\n      } else {\n        f.variables.push(v);\n        existingKeys.add(v.key);\n      }\n    }\n\n    // Update meta timestamp\n    try {\n      if (f.meta) {\n        f.meta.updatedAt = new Date().toISOString();\n      }\n    } catch {\n      // ignore meta update errors\n    }\n  }\n\n  /**\n   * Derive timeline steps from nodes for UI broadcast.\n   * This keeps protocol compatibility with recorder.js without storing steps.\n   */\n  private getTimelineSteps(): Step[] {\n    const f = this.state.flow;\n    if (!f) return [];\n\n    // Primary: derive from nodes\n    if (Array.isArray(f.nodes) && f.nodes.length > 0) {\n      return f.nodes.map((n) => {\n        const cfg =\n          n && typeof n.config === 'object' && n.config != null\n            ? (n.config as Record<string, unknown>)\n            : {};\n        // Important: id and type must override any values in config\n        // (config may contain 'type' for trigger nodes, etc.)\n        return { ...cfg, id: n.id, type: n.type } as Step;\n      });\n    }\n\n    // Legacy fallback: use steps if no nodes (shouldn't happen in normal recording)\n    if (Array.isArray(f.steps) && f.steps.length > 0) {\n      return f.steps;\n    }\n\n    return [];\n  }\n\n  // Broadcast timeline updates to relevant tabs (top-frame only)\n  broadcastTimelineUpdate(): void {\n    try {\n      // Derive steps from nodes for UI consumption (protocol unchanged)\n      const fullSteps = this.getTimelineSteps();\n      if (fullSteps.length === 0) return;\n\n      // Prefer broadcasting to all tabs that participated in this session, so timeline\n      // stays consistent when user switches across tabs/windows during a single session.\n      const targets = this.getActiveTabs();\n      const list =\n        targets && targets.length\n          ? targets\n          : this.state.originTabId != null\n            ? [this.state.originTabId]\n            : [];\n      for (const tabId of list) {\n        chrome.tabs.sendMessage(\n          tabId,\n          { action: TOOL_MESSAGE_TYPES.RR_TIMELINE_UPDATE, steps: fullSteps },\n          { frameId: 0 },\n        );\n      }\n    } catch {}\n  }\n}\n\n// Singleton for wiring convenience\nexport const recordingSession = new RecordingSessionManager();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/rr-utils.ts",
    "content": "// rr-utils.ts — shared helpers for record-replay runner\n// Note: comments in English\n\nimport {\n  TOOL_NAMES,\n  topoOrder as sharedTopoOrder,\n  mapNodeToStep as sharedMapNodeToStep,\n} from 'chrome-mcp-shared';\nimport type { Edge as DagEdge, NodeBase as DagNode, Step } from './types';\nimport { handleCallTool } from '../tools';\nimport { EDGE_LABELS } from 'chrome-mcp-shared';\n\nexport function applyAssign(\n  target: Record<string, any>,\n  source: any,\n  assign: Record<string, string>,\n) {\n  const getByPath = (obj: any, path: string) => {\n    try {\n      const parts = path\n        .replace(/\\[(\\d+)\\]/g, '.$1')\n        .split('.')\n        .filter(Boolean);\n      let cur = obj;\n      for (const p of parts) {\n        if (cur == null) return undefined;\n        cur = (cur as any)[p as any];\n      }\n      return cur;\n    } catch {\n      return undefined;\n    }\n  };\n  for (const [k, v] of Object.entries(assign || {})) {\n    target[k] = getByPath(source, String(v));\n  }\n}\n\nexport function expandTemplatesDeep<T = any>(value: T, scope: Record<string, any>): T {\n  const replaceOne = (s: string) =>\n    s.replace(/\\{([^}]+)\\}/g, (_m, k) => (scope[k] ?? '').toString());\n  const walk = (v: any): any => {\n    if (v == null) return v;\n    if (typeof v === 'string') return replaceOne(v);\n    if (Array.isArray(v)) return v.map((x) => walk(x));\n    if (typeof v === 'object') {\n      const out: any = {};\n      for (const [k, val] of Object.entries(v)) out[k] = walk(val);\n      return out;\n    }\n    return v;\n  };\n  return walk(value);\n}\n\nexport async function ensureTab(options: {\n  tabTarget?: 'current' | 'new';\n  startUrl?: string;\n  refresh?: boolean;\n}): Promise<{ tabId: number; url?: string }> {\n  const target = options.tabTarget || 'current';\n  const startUrl = options.startUrl;\n  const isWebUrl = (u?: string | null) => !!u && /^(https?:|file:)/i.test(u);\n\n  const tabs = await chrome.tabs.query({ currentWindow: true });\n  const [active] = tabs.filter((t) => t.active);\n\n  if (target === 'new') {\n    let urlToOpen = startUrl;\n    if (!urlToOpen) urlToOpen = isWebUrl(active?.url) ? active!.url! : 'about:blank';\n    const created = await chrome.tabs.create({ url: urlToOpen, active: true });\n    await new Promise((r) => setTimeout(r, 300));\n    return { tabId: created.id!, url: created.url };\n  }\n\n  // current tab target\n  if (startUrl) {\n    await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } });\n  } else if (options.refresh) {\n    // only refresh if current tab is a web page\n    if (isWebUrl(active?.url))\n      await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });\n  }\n\n  // Re-evaluate active after potential navigation\n  const cur = (await chrome.tabs.query({ active: true, currentWindow: true }))[0];\n  let tabId = cur?.id;\n  let url = cur?.url;\n\n  // If still on extension/internal page and no startUrl, try switch to an existing web tab\n  if (!isWebUrl(url) && !startUrl) {\n    const candidate = tabs.find((t) => isWebUrl(t.url));\n    if (candidate?.id) {\n      await chrome.tabs.update(candidate.id, { active: true });\n      tabId = candidate.id;\n      url = candidate.url;\n    }\n  }\n  return { tabId: tabId!, url };\n}\n\nexport async function waitForNetworkIdle(totalTimeoutMs: number, idleThresholdMs: number) {\n  const deadline = Date.now() + Math.max(500, totalTimeoutMs);\n  const threshold = Math.max(200, idleThresholdMs);\n  while (Date.now() < deadline) {\n    await handleCallTool({\n      name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,\n      args: {\n        includeStatic: false,\n        // Ensure capture remains active until we explicitly stop it\n        maxCaptureTime: Math.min(60_000, Math.max(threshold + 500, 2_000)),\n        inactivityTimeout: 0,\n      },\n    });\n    await new Promise((r) => setTimeout(r, threshold + 200));\n    const stopRes = await handleCallTool({\n      name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,\n      args: {},\n    });\n    const text = (stopRes as any)?.content?.find((c: any) => c.type === 'text')?.text;\n    try {\n      const json = text ? JSON.parse(text) : null;\n      const captureEnd = Number(json?.captureEndTime) || Date.now();\n      const reqs: any[] = Array.isArray(json?.requests) ? json.requests : [];\n      const lastActivity = reqs.reduce(\n        (acc, r) => {\n          const t = Number(r.responseTime || r.requestTime || 0);\n          return t > acc ? t : acc;\n        },\n        Number(json?.captureStartTime || 0),\n      );\n      if (captureEnd - lastActivity >= threshold) return; // idle reached\n    } catch {\n      // ignore parse errors\n    }\n    await new Promise((r) => setTimeout(r, Math.min(500, threshold)));\n  }\n  throw new Error('wait for network idle timed out');\n}\n\n// Event-driven navigation wait helper\n// Waits for top-frame navigation completion or SPA history updates on active tab.\n// Falls back to short network idle on timeout.\nexport async function waitForNavigation(timeoutMs?: number, prevUrl?: string): Promise<void> {\n  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n  const tabId = tabs?.[0]?.id;\n  if (typeof tabId !== 'number') throw new Error('Active tab not found');\n  const timeout = Math.max(1000, Math.min(timeoutMs || 15000, 30000));\n  const startedAt = Date.now();\n\n  await new Promise<void>((resolve, reject) => {\n    let done = false;\n    let timer: any = null;\n    const cleanup = () => {\n      try {\n        chrome.webNavigation.onCommitted.removeListener(onCommitted);\n      } catch {}\n      try {\n        chrome.webNavigation.onCompleted.removeListener(onCompleted);\n      } catch {}\n      try {\n        (chrome.webNavigation as any).onHistoryStateUpdated?.removeListener?.(\n          onHistoryStateUpdated,\n        );\n      } catch {}\n      try {\n        chrome.tabs.onUpdated.removeListener(onTabUpdated);\n      } catch {}\n      if (timer) {\n        try {\n          clearTimeout(timer);\n        } catch {}\n      }\n    };\n    const finish = () => {\n      if (done) return;\n      done = true;\n      cleanup();\n      resolve();\n    };\n    const onCommitted = (details: any) => {\n      if (\n        details &&\n        details.tabId === tabId &&\n        details.frameId === 0 &&\n        details.timeStamp >= startedAt\n      ) {\n        // committed observed; we'll wait for completion or SPA fallback\n      }\n    };\n    const onCompleted = (details: any) => {\n      if (\n        details &&\n        details.tabId === tabId &&\n        details.frameId === 0 &&\n        details.timeStamp >= startedAt\n      )\n        finish();\n    };\n    const onHistoryStateUpdated = (details: any) => {\n      if (\n        details &&\n        details.tabId === tabId &&\n        details.frameId === 0 &&\n        details.timeStamp >= startedAt\n      )\n        finish();\n    };\n    const onTabUpdated = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {\n      if (updatedTabId !== tabId) return;\n      if (changeInfo.status === 'complete') finish();\n      if (typeof changeInfo.url === 'string' && (!prevUrl || changeInfo.url !== prevUrl)) finish();\n    };\n    const onTimeout = async () => {\n      cleanup();\n      try {\n        await waitForNetworkIdle(2000, 800);\n        resolve();\n      } catch {\n        reject(new Error('navigation timeout'));\n      }\n    };\n\n    chrome.webNavigation.onCommitted.addListener(onCommitted);\n    chrome.webNavigation.onCompleted.addListener(onCompleted);\n    try {\n      (chrome.webNavigation as any).onHistoryStateUpdated?.addListener?.(onHistoryStateUpdated);\n    } catch {}\n    chrome.tabs.onUpdated.addListener(onTabUpdated);\n    timer = setTimeout(onTimeout, timeout);\n  });\n}\n\nexport function topoOrder(nodes: DagNode[], edges: DagEdge[]): DagNode[] {\n  return sharedTopoOrder(nodes, edges as any);\n}\n\n// Helper: filter only default edges (no label or label === 'default')\nexport function defaultEdgesOnly(edges: DagEdge[] = []): DagEdge[] {\n  return (edges || []).filter((e) => !e.label || e.label === EDGE_LABELS.DEFAULT);\n}\n\nexport function mapDagNodeToStep(n: DagNode): Step {\n  const s: any = sharedMapNodeToStep(n as any);\n  if ((n as any)?.type === 'if') {\n    // forward extended conditional config for DAG mode\n    const cfg: any = (n as any).config || {};\n    if (Array.isArray(cfg.branches)) s.branches = cfg.branches;\n    if ('else' in cfg) s.else = cfg.else;\n    if (cfg.condition && !s.condition) s.condition = cfg.condition; // backward-compat\n  }\n  return s as Step;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/selector-engine.ts",
    "content": "import { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { TargetLocator, SelectorCandidate } from './types';\n\n// design note: minimal selector engine that tries ref then candidates\n\nexport interface LocatedElement {\n  ref?: string;\n  center?: { x: number; y: number };\n  resolvedBy?: 'ref' | SelectorCandidate['type'];\n  frameId?: number;\n}\n\n// Helper: decide whether selector is a composite cross-frame selector\nfunction isCompositeSelector(sel: string): boolean {\n  return typeof sel === 'string' && sel.includes('|>');\n}\n\n// Helper: typed wrapper for chrome.tabs.sendMessage with optional frameId\nasync function sendToTab(tabId: number, message: any, frameId?: number): Promise<any> {\n  if (typeof frameId === 'number') {\n    return await chrome.tabs.sendMessage(tabId, message, { frameId });\n  }\n  return await chrome.tabs.sendMessage(tabId, message);\n}\n\n// Helper: ensure ref for a selector, handling composite selectors and mapping frameId\nasync function ensureRefForSelector(\n  tabId: number,\n  selector: string,\n  frameId?: number,\n): Promise<{ ref: string; center: { x: number; y: number }; frameId?: number } | null> {\n  try {\n    let ensured: any = null;\n    if (isCompositeSelector(selector)) {\n      // Always query top for composite; helper will bridge to child and return href\n      ensured = await sendToTab(tabId, {\n        action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n        selector,\n      });\n    } else {\n      ensured = await sendToTab(\n        tabId,\n        { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector },\n        frameId,\n      );\n    }\n    if (!ensured || !ensured.success || !ensured.ref || !ensured.center) return null;\n    // Map frameId when composite via returned href\n    let locFrameId: number | undefined = undefined;\n    if (isCompositeSelector(selector) && ensured.href) {\n      try {\n        const frames = (await chrome.webNavigation.getAllFrames({ tabId })) as any[];\n        const match = frames?.find((f) => typeof f.url === 'string' && f.url === ensured.href);\n        if (match) locFrameId = match.frameId;\n      } catch {}\n    }\n    return { ref: ensured.ref, center: ensured.center, frameId: locFrameId };\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Try to resolve an element using ref or candidates via content scripts\n */\nexport async function locateElement(\n  tabId: number,\n  target: TargetLocator,\n  frameId?: number,\n): Promise<LocatedElement | null> {\n  // 0) Fast path: try primary selector if provided\n  const primarySel = (target as any)?.selector ? String((target as any).selector).trim() : '';\n  if (primarySel) {\n    const ensured = await ensureRefForSelector(tabId, primarySel, frameId);\n    if (ensured) return { ...ensured, resolvedBy: 'css' };\n  }\n\n  // 1) Non-text candidates first for stability (css/attr/aria/xpath)\n  const nonText = (target.candidates || []).filter((c) => c.type !== 'text');\n  for (const c of nonText) {\n    try {\n      if (c.type === 'css' || c.type === 'attr') {\n        const ensured = await ensureRefForSelector(tabId, String(c.value || ''), frameId);\n        if (ensured) return { ...ensured, resolvedBy: c.type };\n      } else if (c.type === 'aria') {\n        // Minimal ARIA role+name parser like: \"button[name=提交]\" or \"textbox[name=用户名]\"\n        const v = String(c.value || '').trim();\n        const m = v.match(/^(\\w+)\\s*\\[\\s*name\\s*=\\s*([^\\]]+)\\]$/);\n        const role = m ? m[1] : '';\n        const name = m ? m[2] : '';\n        const cleanName = name.replace(/^['\"]|['\"]$/g, '');\n        const ariaSelectors: string[] = [];\n        if (role === 'textbox') {\n          ariaSelectors.push(\n            `[role=\"textbox\"][aria-label=${JSON.stringify(cleanName)}]`,\n            `input[aria-label=${JSON.stringify(cleanName)}]`,\n            `textarea[aria-label=${JSON.stringify(cleanName)}]`,\n          );\n        } else if (role === 'button') {\n          ariaSelectors.push(\n            `[role=\"button\"][aria-label=${JSON.stringify(cleanName)}]`,\n            `button[aria-label=${JSON.stringify(cleanName)}]`,\n          );\n        } else if (role === 'link') {\n          ariaSelectors.push(\n            `[role=\"link\"][aria-label=${JSON.stringify(cleanName)}]`,\n            `a[aria-label=${JSON.stringify(cleanName)}]`,\n          );\n        }\n        if (!ariaSelectors.length && role) {\n          ariaSelectors.push(\n            `[role=${JSON.stringify(role)}][aria-label=${JSON.stringify(cleanName)}]`,\n          );\n        }\n        for (const sel of ariaSelectors) {\n          const ensured = await sendToTab(\n            tabId,\n            { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector: sel } as any,\n            frameId,\n          );\n          if (ensured && ensured.success && ensured.ref && ensured.center) {\n            return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };\n          }\n        }\n      } else if (c.type === 'xpath') {\n        // Minimal xpath support via document.evaluate through injected helper\n        const ensured = await sendToTab(\n          tabId,\n          {\n            action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n            selector: c.value,\n            isXPath: true,\n          } as any,\n          frameId,\n        );\n        if (ensured && ensured.success && ensured.ref && ensured.center) {\n          return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type, frameId };\n        }\n      }\n    } catch (e) {\n      // continue to next candidate\n    }\n  }\n  // 2) Human-intent fallback: text-based search as last resort\n  const textCands = (target.candidates || []).filter((c) => c.type === 'text');\n  const tagName = ((target as any)?.tag || '').toString();\n  for (const c of textCands) {\n    try {\n      const ensured = await sendToTab(\n        tabId,\n        {\n          action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n          useText: true,\n          text: c.value,\n          tagName,\n        } as any,\n        frameId,\n      );\n      if (ensured && ensured.success && ensured.ref && ensured.center) {\n        return { ref: ensured.ref, center: ensured.center, resolvedBy: c.type };\n      }\n    } catch {}\n  }\n  // Fallback: try ref (works when ref was produced in the same page lifecycle)\n  if (target.ref) {\n    try {\n      const res = await sendToTab(\n        tabId,\n        { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref: target.ref } as any,\n        frameId,\n      );\n      if (res && res.success && res.center) {\n        return { ref: target.ref, center: res.center, resolvedBy: 'ref' };\n      }\n    } catch (e) {\n      // ignore\n    }\n  }\n  return null;\n}\n\n/**\n * Ensure screenshot context hostname is still valid for coordinate-based actions\n */\n// Note: screenshot hostname validation is handled elsewhere; removed legacy stub.\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/storage/indexeddb-manager.ts",
    "content": "// indexeddb-manager.ts\n// IndexedDB storage manager for Record & Replay data.\n// Stores: flows, runs, published, schedules, triggers.\n\nimport type { Flow, RunRecord } from '../types';\nimport type { FlowSchedule } from '../flow-store';\nimport type { PublishedFlowInfo } from '../flow-store';\nimport type { FlowTrigger } from '../trigger-store';\nimport { IndexedDbClient } from '@/utils/indexeddb-client';\n\ntype StoreName = 'flows' | 'runs' | 'published' | 'schedules' | 'triggers';\n\nconst DB_NAME = 'rr_storage';\n// Version history:\n// v1: Initial schema with flows, runs, published, schedules, triggers stores\n// v2: (Previous iteration - no schema change, version was bumped during development)\n// v3: Current - ensure all stores exist, support upgrade from any previous version\nconst DB_VERSION = 3;\n\nconst REQUIRED_STORES = ['flows', 'runs', 'published', 'schedules', 'triggers'] as const;\n\nconst idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {\n  // Idempotent upgrade: ensure all required stores exist regardless of oldVersion\n  // This handles both fresh installs (oldVersion=0) and upgrades from any version\n  for (const storeName of REQUIRED_STORES) {\n    if (!db.objectStoreNames.contains(storeName)) {\n      db.createObjectStore(storeName, { keyPath: 'id' });\n    }\n  }\n});\n\nconst tx = <T>(\n  store: StoreName,\n  mode: IDBTransactionMode,\n  op: (s: IDBObjectStore, t: IDBTransaction) => T | Promise<T>,\n) => idb.tx<T>(store, mode, op);\n\nasync function getAll<T>(store: StoreName): Promise<T[]> {\n  return idb.getAll<T>(store);\n}\n\nasync function getOne<T>(store: StoreName, key: string): Promise<T | undefined> {\n  return idb.get<T>(store, key);\n}\n\nasync function putOne<T>(store: StoreName, value: T): Promise<void> {\n  return idb.put(store, value);\n}\n\nasync function deleteOne(store: StoreName, key: string): Promise<void> {\n  return idb.delete(store, key);\n}\n\nasync function clearStore(store: StoreName): Promise<void> {\n  return idb.clear(store);\n}\n\nasync function putMany<T>(storeName: StoreName, values: T[]): Promise<void> {\n  return idb.putMany(storeName, values);\n}\n\nexport const IndexedDbStorage = {\n  flows: {\n    async list(): Promise<Flow[]> {\n      return getAll<Flow>('flows');\n    },\n    async get(id: string): Promise<Flow | undefined> {\n      return getOne<Flow>('flows', id);\n    },\n    async save(flow: Flow): Promise<void> {\n      return putOne<Flow>('flows', flow);\n    },\n    async delete(id: string): Promise<void> {\n      return deleteOne('flows', id);\n    },\n  },\n  runs: {\n    async list(): Promise<RunRecord[]> {\n      return getAll<RunRecord>('runs');\n    },\n    async save(record: RunRecord): Promise<void> {\n      return putOne<RunRecord>('runs', record);\n    },\n    async replaceAll(records: RunRecord[]): Promise<void> {\n      return tx<void>('runs', 'readwrite', async (st) => {\n        st.clear();\n        for (const r of records) st.put(r);\n        return;\n      });\n    },\n  },\n  published: {\n    async list(): Promise<PublishedFlowInfo[]> {\n      return getAll<PublishedFlowInfo>('published');\n    },\n    async save(info: PublishedFlowInfo): Promise<void> {\n      return putOne<PublishedFlowInfo>('published', info);\n    },\n    async delete(id: string): Promise<void> {\n      return deleteOne('published', id);\n    },\n  },\n  schedules: {\n    async list(): Promise<FlowSchedule[]> {\n      return getAll<FlowSchedule>('schedules');\n    },\n    async save(s: FlowSchedule): Promise<void> {\n      return putOne<FlowSchedule>('schedules', s);\n    },\n    async delete(id: string): Promise<void> {\n      return deleteOne('schedules', id);\n    },\n  },\n  triggers: {\n    async list(): Promise<FlowTrigger[]> {\n      return getAll<FlowTrigger>('triggers');\n    },\n    async save(t: FlowTrigger): Promise<void> {\n      return putOne<FlowTrigger>('triggers', t);\n    },\n    async delete(id: string): Promise<void> {\n      return deleteOne('triggers', id);\n    },\n  },\n};\n\n// One-time migration from chrome.storage.local to IndexedDB\nlet migrationPromise: Promise<void> | null = null;\nlet migrationFailed = false;\n\nexport async function ensureMigratedFromLocal(): Promise<void> {\n  // If previous migration failed, allow retry\n  if (migrationFailed) {\n    migrationPromise = null;\n    migrationFailed = false;\n  }\n  if (migrationPromise) return migrationPromise;\n\n  migrationPromise = (async () => {\n    try {\n      const flag = await chrome.storage.local.get(['rr_idb_migrated']);\n      if (flag && flag['rr_idb_migrated']) return;\n\n      // Read existing data from chrome.storage.local\n      const res = await chrome.storage.local.get([\n        'rr_flows',\n        'rr_runs',\n        'rr_published_flows',\n        'rr_schedules',\n        'rr_triggers',\n      ]);\n      const flows = (res['rr_flows'] as Flow[]) || [];\n      const runs = (res['rr_runs'] as RunRecord[]) || [];\n      const published = (res['rr_published_flows'] as PublishedFlowInfo[]) || [];\n      const schedules = (res['rr_schedules'] as FlowSchedule[]) || [];\n      const triggers = (res['rr_triggers'] as FlowTrigger[]) || [];\n\n      // Write into IDB\n      if (flows.length) await putMany('flows', flows);\n      if (runs.length) await putMany('runs', runs);\n      if (published.length) await putMany('published', published);\n      if (schedules.length) await putMany('schedules', schedules);\n      if (triggers.length) await putMany('triggers', triggers);\n\n      await chrome.storage.local.set({ rr_idb_migrated: true });\n    } catch (e) {\n      migrationFailed = true;\n      console.error('IndexedDbStorage migration failed:', e);\n      // Re-throw to let callers know migration failed\n      throw e;\n    }\n  })();\n  return migrationPromise;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/trigger-store.ts",
    "content": "import { IndexedDbStorage, ensureMigratedFromLocal } from './storage/indexeddb-manager';\n\nexport type TriggerType = 'url' | 'contextMenu' | 'command' | 'dom';\n\nexport interface BaseTrigger {\n  id: string;\n  type: TriggerType;\n  enabled: boolean;\n  flowId: string;\n  args?: Record<string, any>;\n}\n\nexport interface UrlTrigger extends BaseTrigger {\n  type: 'url';\n  match: Array<{ kind: 'url' | 'domain' | 'path'; value: string }>;\n}\n\nexport interface ContextMenuTrigger extends BaseTrigger {\n  type: 'contextMenu';\n  title: string;\n  contexts?: chrome.contextMenus.ContextType[];\n}\n\nexport interface CommandTrigger extends BaseTrigger {\n  type: 'command';\n  commandKey: string; // e.g., run_quick_trigger_1\n}\n\nexport interface DomTrigger extends BaseTrigger {\n  type: 'dom';\n  selector: string;\n  appear?: boolean; // default true\n  once?: boolean; // default true\n  debounceMs?: number; // default 800\n}\n\nexport type FlowTrigger = UrlTrigger | ContextMenuTrigger | CommandTrigger | DomTrigger;\n\nexport async function listTriggers(): Promise<FlowTrigger[]> {\n  await ensureMigratedFromLocal();\n  return await IndexedDbStorage.triggers.list();\n}\n\nexport async function saveTrigger(t: FlowTrigger): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.triggers.save(t);\n}\n\nexport async function deleteTrigger(id: string): Promise<void> {\n  await ensureMigratedFromLocal();\n  await IndexedDbStorage.triggers.delete(id);\n}\n\nexport function toId(prefix = 'trg') {\n  return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay/types.ts",
    "content": "/**\n * Record & Replay Core Types\n *\n * This file contains the core type definitions for the record-replay system.\n * Legacy Step types have been moved to ./legacy-types.ts and are re-exported\n * here for backward compatibility.\n *\n * Type system architecture:\n * - Legacy types (./legacy-types.ts): Step-based execution model (being phased out)\n * - Action types (./actions/types.ts): DAG-based execution model (new standard)\n * - Core types (this file): Flow, Node, Edge, Run records (shared by both)\n */\n\nimport { NODE_TYPES } from '@/common/node-types';\n\n// =============================================================================\n// Re-export Legacy Types for Backward Compatibility\n// =============================================================================\n\nexport type {\n  // Selector types\n  SelectorType,\n  SelectorCandidate,\n  TargetLocator,\n  // Step types\n  StepType,\n  StepBase,\n  StepClick,\n  StepFill,\n  StepTriggerEvent,\n  StepSetAttribute,\n  StepScreenshot,\n  StepSwitchFrame,\n  StepLoopElements,\n  StepKey,\n  StepScroll,\n  StepDrag,\n  StepWait,\n  StepAssert,\n  StepScript,\n  StepIf,\n  StepForeach,\n  StepWhile,\n  StepHttp,\n  StepExtract,\n  StepOpenTab,\n  StepSwitchTab,\n  StepCloseTab,\n  StepNavigate,\n  StepHandleDownload,\n  StepExecuteFlow,\n  Step,\n} from './legacy-types';\n\n// Import Step type for use in Flow interface\nimport type { Step } from './legacy-types';\n\n// =============================================================================\n// Variable Definitions\n// =============================================================================\n\nexport type VariableType = 'string' | 'number' | 'boolean' | 'enum' | 'array';\n\nexport interface VariableDef {\n  key: string;\n  label?: string;\n  sensitive?: boolean;\n  // default value can be string/number/boolean/array depending on type\n  default?: any; // keep broad for backward compatibility\n  type?: VariableType; // default to 'string' when omitted\n  rules?: { required?: boolean; pattern?: string; enum?: string[] };\n}\n\n// =============================================================================\n// DAG Node and Edge Types (Flow V2)\n// =============================================================================\n\nexport type NodeType = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];\n\nexport interface NodeBase {\n  id: string;\n  type: NodeType;\n  name?: string;\n  disabled?: boolean;\n  config?: any;\n  ui?: { x: number; y: number };\n}\n\nexport interface Edge {\n  id: string;\n  from: string;\n  to: string;\n  // label identifies the logical branch. Keep 'default' for linear/main path.\n  // For conditionals, use arbitrary strings like 'case:<id>' or 'else'.\n  label?: string;\n}\n\n// =============================================================================\n// Flow Definition\n// =============================================================================\n\nexport interface Flow {\n  id: string;\n  name: string;\n  description?: string;\n  version: number;\n  meta?: {\n    createdAt: string;\n    updatedAt: string;\n    domain?: string;\n    tags?: string[];\n    bindings?: Array<{ type: 'domain' | 'path' | 'url'; value: string }>;\n    tool?: { category?: string; description?: string };\n    exposedOutputs?: Array<{ nodeId: string; as: string }>;\n    /** Recording stop barrier status (used during recording stop) */\n    stopBarrier?: {\n      ok: boolean;\n      sessionId?: string;\n      stoppedAt?: string;\n      failed?: Array<{\n        tabId: number;\n        skipped?: boolean;\n        reason?: string;\n        topTimedOut?: boolean;\n        topError?: string;\n        subframesFailed?: number;\n      }>;\n    };\n  };\n  variables?: VariableDef[];\n  /**\n   * @deprecated Use nodes/edges instead. This field is no longer written to storage.\n   * Kept as optional for backward compatibility with existing flows and imports.\n   */\n  steps?: Step[];\n  // Flow V2: DAG-based execution model\n  nodes?: NodeBase[];\n  edges?: Edge[];\n  subflows?: Record<string, { nodes: NodeBase[]; edges: Edge[] }>;\n}\n\n// =============================================================================\n// Run Records and Results\n// =============================================================================\n\nexport interface RunLogEntry {\n  stepId: string;\n  status: 'success' | 'failed' | 'retrying' | 'warning';\n  message?: string;\n  tookMs?: number;\n  screenshotBase64?: string; // small thumbnail (optional)\n  consoleSnippets?: string[]; // critical lines\n  networkSnippets?: Array<{ method: string; url: string; status?: number; ms?: number }>;\n  // selector fallback info\n  fallbackUsed?: boolean;\n  fallbackFrom?: string;\n  fallbackTo?: string;\n}\n\nexport interface RunRecord {\n  id: string;\n  flowId: string;\n  startedAt: string;\n  finishedAt?: string;\n  success?: boolean;\n  entries: RunLogEntry[];\n}\n\nexport interface RunResult {\n  runId: string;\n  success: boolean;\n  summary: { total: number; success: number; failed: number; tookMs: number };\n  url?: string | null;\n  outputs?: Record<string, any> | null;\n  logs?: RunLogEntry[];\n  screenshots?: { onFailure?: string | null };\n  paused?: boolean; // when true, the run was intentionally paused (e.g., breakpoint)\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/bootstrap.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 composition root (bootstrap)\n * @description\n * Wires storage, events, scheduler, triggers and RPC for the MV3 background service worker.\n *\n * 设计说明：\n * - 必须先执行 recoverFromCrash() 再启动 scheduler.start()\n * - 使用全局单例 keepalive-manager 避免多个控制器冲突\n * - RunExecutor 使用 RunRunner 执行实际的 Flow\n */\n\nimport type { UnixMillis } from './domain/json';\nimport type { RunId } from './domain/ids';\nimport { RR_ERROR_CODES, createRRError, type RRError } from './domain/errors';\n\nimport type { StoragePort } from './engine/storage/storage-port';\nimport { StorageBackedEventsBus, type EventsBus } from './engine/transport/events-bus';\n\nimport { DEFAULT_QUEUE_CONFIG, type RunQueueItem } from './engine/queue/queue';\nimport { createLeaseManager, generateOwnerId, type LeaseManager } from './engine/queue/leasing';\nimport { createRunScheduler, type RunExecutor, type RunScheduler } from './engine/queue/scheduler';\nimport { recoverFromCrash } from './engine/recovery/recovery-coordinator';\n\nimport { RpcServer } from './engine/transport/rpc-server';\n\nimport { createTriggerManager, type TriggerManager } from './engine/triggers/trigger-manager';\nimport { createUrlTriggerHandlerFactory } from './engine/triggers/url-trigger';\nimport { createCommandTriggerHandlerFactory } from './engine/triggers/command-trigger';\nimport { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger';\nimport { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger';\nimport { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger';\nimport { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger';\nimport { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger';\nimport { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger';\n\nimport { createChromeArtifactService } from './engine/kernel/artifacts';\nimport { createRunRunnerFactory, type RunRunnerFactory } from './engine/kernel/runner';\nimport {\n  createDebugController,\n  createRunnerRegistry,\n  type DebugController,\n  type RunnerRegistry,\n} from './engine/kernel/debug-controller';\n\nimport { PluginRegistry } from './engine/plugins/registry';\nimport {\n  registerV2ReplayNodesAsV3Nodes,\n  DEFAULT_V2_EXCLUDE_LIST,\n} from './engine/plugins/register-v2-replay-nodes';\n\nimport { acquireKeepalive } from '../keepalive-manager';\nimport { createStoragePort } from './index';\n\n// ==================== Types ====================\n\ntype Logger = Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n\n/**\n * V3 运行时句柄\n */\nexport interface V3Runtime {\n  ownerId: string;\n  storage: StoragePort;\n  events: EventsBus;\n  leaseManager: LeaseManager;\n  scheduler: RunScheduler;\n  runners: RunnerRegistry;\n  debugController: DebugController;\n  triggers: TriggerManager;\n  rpcServer: RpcServer;\n  stop(): Promise<void>;\n}\n\n// ==================== Singleton State ====================\n\nlet runtime: V3Runtime | null = null;\nlet bootstrapPromise: Promise<V3Runtime> | null = null;\n\n// ==================== Utilities ====================\n\nfunction errorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message;\n  if (err && typeof err === 'object' && 'message' in err)\n    return String((err as { message: unknown }).message);\n  return String(err);\n}\n\nfunction isFiniteNumber(v: unknown): v is number {\n  return typeof v === 'number' && Number.isFinite(v);\n}\n\nasync function tabExists(tabId: number): Promise<boolean> {\n  try {\n    await chrome.tabs.get(tabId);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nasync function createEphemeralTab(logger: Logger): Promise<number> {\n  const tab = await chrome.tabs.create({ url: 'about:blank', active: false });\n  if (tab.id === undefined) {\n    throw new Error('chrome.tabs.create returned a tab without id');\n  }\n  logger.debug(`[RR-V3] Allocated ephemeral tab ${tab.id}`);\n  return tab.id;\n}\n\nasync function safeRemoveTab(tabId: number, logger: Logger): Promise<void> {\n  try {\n    await chrome.tabs.remove(tabId);\n  } catch (e) {\n    logger.debug(`[RR-V3] Failed to close tab ${tabId}:`, e);\n  }\n}\n\n/**\n * 解析运行 Run 所需的 Tab ID\n * 优先级: run.tabId > queue.tabId > trigger.sourceTabId > 创建新 Tab\n */\nasync function resolveRunTab(input: {\n  runTabId?: number;\n  queueTabId?: number;\n  triggerTabId?: number;\n  logger: Logger;\n}): Promise<{ tabId: number; shouldClose: boolean }> {\n  const candidates = [input.runTabId, input.queueTabId, input.triggerTabId].filter(\n    (x): x is number => isFiniteNumber(x),\n  );\n\n  for (const tabId of candidates) {\n    if (await tabExists(tabId)) {\n      return { tabId, shouldClose: false };\n    }\n  }\n\n  const tabId = await createEphemeralTab(input.logger);\n  return { tabId, shouldClose: true };\n}\n\n/**\n * 将 Run 标记为失败\n * 注意：会重新读取最新的 RunRecord 以获取正确的 startedAt\n */\nasync function failRun(\n  deps: { storage: StoragePort; events: EventsBus; now: () => UnixMillis; logger: Logger },\n  runId: RunId,\n  error: RRError,\n): Promise<void> {\n  const finishedAt = deps.now();\n\n  // 重新获取最新的 run 记录以获取正确的 startedAt\n  let startedAt = finishedAt;\n  try {\n    const latestRun = await deps.storage.runs.get(runId);\n    if (latestRun?.startedAt !== undefined) {\n      startedAt = latestRun.startedAt;\n    }\n  } catch {\n    // ignore - use finishedAt as startedAt\n  }\n\n  const tookMs = Math.max(0, finishedAt - startedAt);\n\n  try {\n    await deps.storage.runs.patch(runId, {\n      status: 'failed',\n      finishedAt,\n      tookMs,\n      error,\n    });\n  } catch (e) {\n    deps.logger.error(`[RR-V3] Failed to patch run \"${runId}\" as failed:`, e);\n    return;\n  }\n\n  try {\n    await deps.events.append({ runId, type: 'run.failed', error });\n  } catch (e) {\n    deps.logger.warn(`[RR-V3] Failed to append run.failed for \"${runId}\":`, e);\n  }\n}\n\n// ==================== Run Executor ====================\n\n/**\n * 创建默认的 RunExecutor\n * 使用 RunRunner 执行 Flow\n */\nfunction createDefaultRunExecutor(deps: {\n  storage: StoragePort;\n  events: EventsBus;\n  runnerFactory: RunRunnerFactory;\n  runners: RunnerRegistry;\n  now: () => UnixMillis;\n  logger: Logger;\n}): RunExecutor {\n  return async (item: RunQueueItem): Promise<void> => {\n    const runId = item.id;\n\n    // 1. 获取 RunRecord\n    const run = await deps.storage.runs.get(runId);\n    if (!run) {\n      deps.logger.warn(`[RR-V3] RunRecord not found for queue item \"${runId}\", skipping execution`);\n      return;\n    }\n\n    // 2. 获取 Flow\n    const flow = await deps.storage.flows.get(item.flowId);\n    if (!flow) {\n      await failRun(\n        deps,\n        runId,\n        createRRError(RR_ERROR_CODES.VALIDATION_ERROR, `Flow \"${item.flowId}\" not found`),\n      );\n      return;\n    }\n\n    // 3. 解析 Tab ID\n    const { tabId, shouldClose } = await resolveRunTab({\n      runTabId: run.tabId,\n      queueTabId: item.tabId,\n      triggerTabId: item.trigger?.sourceTabId,\n      logger: deps.logger,\n    });\n\n    // 4. 同步 attempt 到 RunRecord\n    try {\n      await deps.storage.runs.patch(runId, {\n        attempt: item.attempt,\n        maxAttempts: item.maxAttempts,\n        tabId,\n      });\n    } catch (e) {\n      deps.logger.debug(`[RR-V3] Failed to patch run \"${runId}\" attempt/tabId:`, e);\n    }\n\n    // 5. 执行 Run\n    let runner;\n    try {\n      runner = deps.runnerFactory.create(runId, {\n        flow,\n        tabId,\n        args: item.args,\n        startNodeId: run.startNodeId,\n        debug: item.debug,\n      });\n\n      // 注册到 RunnerRegistry，供 DebugController 和 RPC 使用\n      deps.runners.register(runId, runner);\n\n      await runner.start();\n    } catch (e) {\n      await failRun(\n        deps,\n        runId,\n        createRRError(RR_ERROR_CODES.INTERNAL, `Executor crashed: ${errorMessage(e)}`),\n      );\n    } finally {\n      // 6. 注销 Runner\n      if (runner) {\n        deps.runners.unregister(runId);\n      }\n\n      // 7. 清理临时 Tab\n      if (shouldClose) {\n        await safeRemoveTab(tabId, deps.logger);\n      }\n    }\n  };\n}\n\n// ==================== Bootstrap ====================\n\n/**\n * 启动 RR-V3 运行时\n * @returns 运行时句柄\n */\nexport async function bootstrapV3(): Promise<V3Runtime> {\n  if (runtime) return runtime;\n  if (bootstrapPromise) return bootstrapPromise;\n\n  bootstrapPromise = (async () => {\n    const logger: Logger = console;\n    const now = (): UnixMillis => Date.now();\n\n    logger.info('[RR-V3] Bootstrapping...');\n\n    // 1) Storage\n    const storage = createStoragePort();\n\n    // 2) EventsBus\n    const events: EventsBus = new StorageBackedEventsBus(storage.events);\n\n    // 3) Lease owner identity (per SW instance)\n    const ownerId = generateOwnerId();\n    logger.debug(`[RR-V3] Owner ID: ${ownerId}`);\n\n    // 4) LeaseManager\n    const leaseManager = createLeaseManager(storage.queue, DEFAULT_QUEUE_CONFIG);\n\n    // 5) RunnerRegistry + DebugController\n    const runners = createRunnerRegistry();\n    const debugController = createDebugController({ storage, events, runners });\n\n    // 6) Keepalive (reuse global singleton to avoid multiple controllers fighting)\n    const keepalive = {\n      acquire: (tag: string) => acquireKeepalive(`rr_v3:${tag}`),\n    };\n\n    // 7) PluginRegistry - register V2 action handlers as V3 nodes\n    const plugins = new PluginRegistry();\n    const registeredNodes = registerV2ReplayNodesAsV3Nodes(plugins, {\n      // Exclude control directives that V3 runner doesn't support\n      exclude: [...DEFAULT_V2_EXCLUDE_LIST],\n    });\n    logger.debug(`[RR-V3] Registered ${registeredNodes.length} V2 action handlers as V3 nodes`);\n\n    // 8) RunExecutor via RunRunnerFactory\n    const runnerFactory = createRunRunnerFactory({\n      storage,\n      events,\n      plugins,\n      artifactService: createChromeArtifactService(),\n      now,\n    });\n\n    const execute = createDefaultRunExecutor({\n      storage,\n      events,\n      runnerFactory,\n      runners,\n      now,\n      logger,\n    });\n\n    // 7) Scheduler\n    const scheduler = createRunScheduler({\n      queue: storage.queue,\n      leaseManager,\n      keepalive,\n      config: DEFAULT_QUEUE_CONFIG,\n      ownerId,\n      execute,\n      now,\n      logger,\n    });\n\n    // 8) TriggerManager\n    const triggers = createTriggerManager({\n      storage,\n      events,\n      scheduler,\n      handlerFactories: {\n        url: createUrlTriggerHandlerFactory({ logger }),\n        command: createCommandTriggerHandlerFactory({ logger }),\n        contextMenu: createContextMenuTriggerHandlerFactory({ logger }),\n        dom: createDomTriggerHandlerFactory({ logger }),\n        cron: createCronTriggerHandlerFactory({ logger, now }),\n        interval: createIntervalTriggerHandlerFactory({ logger }),\n        once: createOnceTriggerHandlerFactory({ logger }),\n        manual: createManualTriggerHandlerFactory({ logger }),\n      },\n      now,\n      logger,\n    });\n\n    // 10) RpcServer (created but started after recovery)\n    const rpcServer = new RpcServer({\n      storage,\n      events,\n      scheduler,\n      debugController,\n      runners,\n      triggerManager: triggers,\n      now,\n    });\n\n    // Cleanup helper for error recovery\n    const cleanup = async (): Promise<void> => {\n      try {\n        rpcServer.stop();\n      } catch {\n        /* ignore */\n      }\n      try {\n        await triggers.stop();\n      } catch {\n        /* ignore */\n      }\n      try {\n        scheduler.stop();\n      } catch {\n        /* ignore */\n      }\n      try {\n        leaseManager.dispose();\n      } catch {\n        /* ignore */\n      }\n      try {\n        debugController.stop();\n      } catch {\n        /* ignore */\n      }\n    };\n\n    try {\n      // 10) Recovery - MUST run before scheduler.start()\n      logger.info('[RR-V3] Running crash recovery...');\n      await recoverFromCrash({ storage, events, ownerId, now, logger });\n\n      // 11) Start components\n      scheduler.start();\n      await triggers.start();\n      rpcServer.start();\n\n      logger.info('[RR-V3] Bootstrap complete');\n    } catch (e) {\n      await cleanup();\n      throw e;\n    }\n\n    // Build runtime handle\n    runtime = {\n      ownerId,\n      storage,\n      events,\n      leaseManager,\n      scheduler,\n      runners,\n      debugController,\n      triggers,\n      rpcServer,\n      stop: async () => {\n        logger.info('[RR-V3] Stopping...');\n        // Stop order: RPC first (block new requests) -> triggers -> scheduler -> lease -> debug\n        rpcServer.stop();\n        await triggers.stop().catch(() => {});\n        scheduler.stop();\n        leaseManager.dispose();\n        debugController.stop();\n        runtime = null;\n        logger.info('[RR-V3] Stopped');\n      },\n    };\n\n    return runtime;\n  })().finally(() => {\n    bootstrapPromise = null;\n  });\n\n  return bootstrapPromise;\n}\n\n/**\n * 获取当前运行时（如果已启动）\n */\nexport function getV3Runtime(): V3Runtime | null {\n  return runtime;\n}\n\n/**\n * 检查 V3 是否已启动\n */\nexport function isV3Running(): boolean {\n  return runtime !== null;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/debug.ts",
    "content": "/**\n * @fileoverview 调试器类型定义\n * @description 定义 Record-Replay V3 中的调试器状态和协议\n */\n\nimport type { JsonValue } from './json';\nimport type { NodeId, RunId } from './ids';\nimport type { PauseReason } from './events';\n\n/**\n * 断点定义\n */\nexport interface Breakpoint {\n  /** 断点所在节点 ID */\n  nodeId: NodeId;\n  /** 是否启用 */\n  enabled: boolean;\n}\n\n/**\n * 调试器状态\n * @description 描述调试器当前的连接和执行状态\n */\nexport interface DebuggerState {\n  /** 关联的 Run ID */\n  runId: RunId;\n  /** 调试器连接状态 */\n  status: 'attached' | 'detached';\n  /** 执行状态 */\n  execution: 'running' | 'paused';\n  /** 暂停原因（仅当 execution='paused' 时有效） */\n  pauseReason?: PauseReason;\n  /** 当前节点 ID */\n  currentNodeId?: NodeId;\n  /** 断点列表 */\n  breakpoints: Breakpoint[];\n  /** 单步模式 */\n  stepMode?: 'none' | 'stepOver';\n}\n\n/**\n * 调试器命令\n * @description 客户端发送给调试器的命令\n */\nexport type DebuggerCommand =\n  // ===== 连接控制 =====\n  | { type: 'debug.attach'; runId: RunId }\n  | { type: 'debug.detach'; runId: RunId }\n\n  // ===== 执行控制 =====\n  | { type: 'debug.pause'; runId: RunId }\n  | { type: 'debug.resume'; runId: RunId }\n  | { type: 'debug.stepOver'; runId: RunId }\n\n  // ===== 断点管理 =====\n  | { type: 'debug.setBreakpoints'; runId: RunId; nodeIds: NodeId[] }\n  | { type: 'debug.addBreakpoint'; runId: RunId; nodeId: NodeId }\n  | { type: 'debug.removeBreakpoint'; runId: RunId; nodeId: NodeId }\n\n  // ===== 状态查询 =====\n  | { type: 'debug.getState'; runId: RunId }\n\n  // ===== 变量操作 =====\n  | { type: 'debug.getVar'; runId: RunId; name: string }\n  | { type: 'debug.setVar'; runId: RunId; name: string; value: JsonValue };\n\n/** 调试器命令类型（从联合类型提取） */\nexport type DebuggerCommandType = DebuggerCommand['type'];\n\n/**\n * 调试器命令响应\n */\nexport type DebuggerResponse =\n  | { ok: true; state?: DebuggerState; value?: JsonValue }\n  | { ok: false; error: string };\n\n/**\n * 创建初始调试器状态\n */\nexport function createInitialDebuggerState(runId: RunId): DebuggerState {\n  return {\n    runId,\n    status: 'detached',\n    execution: 'running',\n    breakpoints: [],\n    stepMode: 'none',\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/errors.ts",
    "content": "/**\n * @fileoverview 错误类型定义\n * @description 定义 Record-Replay V3 中使用的错误码和错误类型\n */\n\nimport type { JsonValue } from './json';\n\n/** 错误码常量 */\nexport const RR_ERROR_CODES = {\n  // ===== 验证错误 =====\n  /** 通用验证错误 */\n  VALIDATION_ERROR: 'VALIDATION_ERROR',\n  /** 不支持的节点类型 */\n  UNSUPPORTED_NODE: 'UNSUPPORTED_NODE',\n  /** DAG 结构无效 */\n  DAG_INVALID: 'DAG_INVALID',\n  /** DAG 存在循环 */\n  DAG_CYCLE: 'DAG_CYCLE',\n\n  // ===== 运行时错误 =====\n  /** 操作超时 */\n  TIMEOUT: 'TIMEOUT',\n  /** Tab 未找到 */\n  TAB_NOT_FOUND: 'TAB_NOT_FOUND',\n  /** Frame 未找到 */\n  FRAME_NOT_FOUND: 'FRAME_NOT_FOUND',\n  /** 目标元素未找到 */\n  TARGET_NOT_FOUND: 'TARGET_NOT_FOUND',\n  /** 元素不可见 */\n  ELEMENT_NOT_VISIBLE: 'ELEMENT_NOT_VISIBLE',\n  /** 导航失败 */\n  NAVIGATION_FAILED: 'NAVIGATION_FAILED',\n  /** 网络请求失败 */\n  NETWORK_REQUEST_FAILED: 'NETWORK_REQUEST_FAILED',\n\n  // ===== 脚本/工具错误 =====\n  /** 脚本执行失败 */\n  SCRIPT_FAILED: 'SCRIPT_FAILED',\n  /** 权限被拒绝 */\n  PERMISSION_DENIED: 'PERMISSION_DENIED',\n  /** 工具执行错误 */\n  TOOL_ERROR: 'TOOL_ERROR',\n\n  // ===== 控制错误 =====\n  /** Run 被取消 */\n  RUN_CANCELED: 'RUN_CANCELED',\n  /** Run 被暂停 */\n  RUN_PAUSED: 'RUN_PAUSED',\n\n  // ===== 内部错误 =====\n  /** 内部错误 */\n  INTERNAL: 'INTERNAL',\n  /** 不变量违规 */\n  INVARIANT_VIOLATION: 'INVARIANT_VIOLATION',\n} as const;\n\n/** 错误码类型 */\nexport type RRErrorCode = (typeof RR_ERROR_CODES)[keyof typeof RR_ERROR_CODES];\n\n/**\n * Record-Replay 错误接口\n * @description 统一的错误表示，支持错误链和可重试标记\n */\nexport interface RRError {\n  /** 错误码 */\n  code: RRErrorCode;\n  /** 错误消息 */\n  message: string;\n  /** 附加数据 */\n  data?: JsonValue;\n  /** 是否可重试 */\n  retryable?: boolean;\n  /** 原因错误（错误链） */\n  cause?: RRError;\n}\n\n/**\n * 创建 RRError 的工厂函数\n */\nexport function createRRError(\n  code: RRErrorCode,\n  message: string,\n  options?: { data?: JsonValue; retryable?: boolean; cause?: RRError },\n): RRError {\n  return {\n    code,\n    message,\n    ...(options?.data !== undefined && { data: options.data }),\n    ...(options?.retryable !== undefined && { retryable: options.retryable }),\n    ...(options?.cause !== undefined && { cause: options.cause }),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/events.ts",
    "content": "/**\n * @fileoverview 事件类型定义\n * @description 定义 Record-Replay V3 中的运行事件和状态\n */\n\nimport type { JsonObject, JsonValue, UnixMillis } from './json';\nimport type { EdgeLabel, FlowId, NodeId, RunId } from './ids';\nimport type { RRError } from './errors';\nimport type { TriggerFireContext } from './triggers';\n\n/** 取消订阅函数类型 */\nexport type Unsubscribe = () => void;\n\n/** Run 状态 */\nexport type RunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'canceled';\n\n/**\n * 事件基础接口\n * @description 所有事件的公共字段\n */\nexport interface EventBase {\n  /** 所属 Run ID */\n  runId: RunId;\n  /** 事件时间戳 */\n  ts: UnixMillis;\n  /** 单调递增序列号 */\n  seq: number;\n}\n\n/**\n * 暂停原因\n * @description 描述 Run 暂停的原因\n */\nexport type PauseReason =\n  | { kind: 'breakpoint'; nodeId: NodeId }\n  | { kind: 'step'; nodeId: NodeId }\n  | { kind: 'command' }\n  | { kind: 'policy'; nodeId: NodeId; reason: string };\n\n/** 恢复原因 */\nexport type RecoveryReason = 'sw_restart' | 'lease_expired';\n\n/**\n * Run 事件联合类型\n * @description 所有可能的运行时事件\n */\nexport type RunEvent =\n  // ===== Run 生命周期事件 =====\n  | (EventBase & { type: 'run.queued'; flowId: FlowId })\n  | (EventBase & { type: 'run.started'; flowId: FlowId; tabId: number })\n  | (EventBase & { type: 'run.paused'; reason: PauseReason; nodeId?: NodeId })\n  | (EventBase & { type: 'run.resumed' })\n  | (EventBase & {\n      type: 'run.recovered';\n      /** 恢复原因 */\n      reason: RecoveryReason;\n      /** 恢复前状态 */\n      fromStatus: 'running' | 'paused';\n      /** 恢复后状态 */\n      toStatus: 'queued';\n      /** 原 ownerId（用于审计） */\n      prevOwnerId?: string;\n    })\n  | (EventBase & { type: 'run.canceled'; reason?: string })\n  | (EventBase & { type: 'run.succeeded'; tookMs: number; outputs?: JsonObject })\n  | (EventBase & { type: 'run.failed'; error: RRError; nodeId?: NodeId })\n\n  // ===== Node 执行事件 =====\n  | (EventBase & { type: 'node.queued'; nodeId: NodeId })\n  | (EventBase & { type: 'node.started'; nodeId: NodeId; attempt: number })\n  | (EventBase & {\n      type: 'node.succeeded';\n      nodeId: NodeId;\n      tookMs: number;\n      next?: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'end' };\n    })\n  | (EventBase & {\n      type: 'node.failed';\n      nodeId: NodeId;\n      attempt: number;\n      error: RRError;\n      decision: 'retry' | 'continue' | 'stop' | 'goto';\n    })\n  | (EventBase & { type: 'node.skipped'; nodeId: NodeId; reason: 'disabled' | 'unreachable' })\n\n  // ===== 变量和日志事件 =====\n  | (EventBase & {\n      type: 'vars.patch';\n      patch: Array<{ op: 'set' | 'delete'; name: string; value?: JsonValue }>;\n    })\n  | (EventBase & { type: 'artifact.screenshot'; nodeId: NodeId; data: string; savedAs?: string })\n  | (EventBase & {\n      type: 'log';\n      level: 'debug' | 'info' | 'warn' | 'error';\n      message: string;\n      data?: JsonValue;\n    });\n\n/** Run 事件类型（从联合类型提取） */\nexport type RunEventType = RunEvent['type'];\n\n/**\n * 分布式 Omit（保留联合类型）\n */\ntype DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;\n\n/**\n * Run 事件输入类型\n * @description seq 必须由 storage 层原子分配（通过 RunRecordV3.nextSeq）\n * ts 可选，默认为 Date.now()\n */\nexport type RunEventInput = DistributiveOmit<RunEvent, 'seq' | 'ts'> & {\n  ts?: UnixMillis;\n};\n\n/** Run Schema 版本 */\nexport const RUN_SCHEMA_VERSION = 3 as const;\n\n/**\n * Run 记录 V3\n * @description 存储在 IndexedDB 中的 Run 摘要记录\n */\nexport interface RunRecordV3 {\n  /** Schema 版本 */\n  schemaVersion: typeof RUN_SCHEMA_VERSION;\n  /** Run 唯一标识符 */\n  id: RunId;\n  /** 关联的 Flow ID */\n  flowId: FlowId;\n\n  /** 当前状态 */\n  status: RunStatus;\n  /** 创建时间 */\n  createdAt: UnixMillis;\n  /** 最后更新时间 */\n  updatedAt: UnixMillis;\n\n  /** 开始执行时间 */\n  startedAt?: UnixMillis;\n  /** 结束时间 */\n  finishedAt?: UnixMillis;\n  /** 总耗时（毫秒） */\n  tookMs?: number;\n\n  /** 绑定的 Tab ID（每 Run 独占） */\n  tabId?: number;\n  /** 起始节点 ID（如果不是默认入口） */\n  startNodeId?: NodeId;\n  /** 当前执行节点 ID */\n  currentNodeId?: NodeId;\n\n  /** 当前尝试次数 */\n  attempt: number;\n  /** 最大尝试次数 */\n  maxAttempts: number;\n\n  /** 运行参数 */\n  args?: JsonObject;\n  /** 触发器上下文 */\n  trigger?: TriggerFireContext;\n  /** 调试配置 */\n  debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };\n\n  /** 错误信息（如果失败） */\n  error?: RRError;\n  /** 输出结果 */\n  outputs?: JsonObject;\n\n  /** 下一个事件序列号（缓存字段） */\n  nextSeq: number;\n}\n\n/**\n * 判断 Run 是否已终止\n */\nexport function isTerminalStatus(status: RunStatus): boolean {\n  return status === 'succeeded' || status === 'failed' || status === 'canceled';\n}\n\n/**\n * 判断 Run 是否正在执行\n */\nexport function isActiveStatus(status: RunStatus): boolean {\n  return status === 'running' || status === 'paused';\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/flow.ts",
    "content": "/**\n * @fileoverview Flow 类型定义\n * @description 定义 Record-Replay V3 中的 Flow IR（中间表示）\n */\n\nimport type { ISODateTimeString, JsonObject } from './json';\nimport type { EdgeId, EdgeLabel, FlowId, NodeId } from './ids';\nimport type { FlowPolicy, NodePolicy } from './policy';\nimport type { VariableDefinition } from './variables';\n\n/** Flow Schema 版本 */\nexport const FLOW_SCHEMA_VERSION = 3 as const;\n\n/**\n * Edge V3\n * @description DAG 中的边，连接两个节点\n */\nexport interface EdgeV3 {\n  /** Edge 唯一标识符 */\n  id: EdgeId;\n  /** 源节点 ID */\n  from: NodeId;\n  /** 目标节点 ID */\n  to: NodeId;\n  /** 边标签（用于条件分支和错误处理） */\n  label?: EdgeLabel;\n}\n\n/** 节点类型（可扩展） */\nexport type NodeKind = string;\n\n/**\n * Node V3\n * @description DAG 中的节点，代表一个可执行的操作\n */\nexport interface NodeV3 {\n  /** Node 唯一标识符 */\n  id: NodeId;\n  /** 节点类型 */\n  kind: NodeKind;\n  /** 节点名称（用于显示） */\n  name?: string;\n  /** 是否禁用 */\n  disabled?: boolean;\n  /** 节点级策略 */\n  policy?: NodePolicy;\n  /** 节点配置（类型由 kind 决定） */\n  config: JsonObject;\n  /** UI 布局信息 */\n  ui?: { x: number; y: number };\n}\n\n/**\n * Flow 元数据绑定\n * @description 定义 Flow 与特定域名/路径/URL 的关联\n */\nexport interface FlowBinding {\n  kind: 'domain' | 'path' | 'url';\n  value: string;\n}\n\n/**\n * Flow V3\n * @description 完整的 Flow 定义，包含节点、边和配置\n */\nexport interface FlowV3 {\n  /** Schema 版本 */\n  schemaVersion: typeof FLOW_SCHEMA_VERSION;\n  /** Flow 唯一标识符 */\n  id: FlowId;\n  /** Flow 名称 */\n  name: string;\n  /** Flow 描述 */\n  description?: string;\n  /** 创建时间 */\n  createdAt: ISODateTimeString;\n  /** 更新时间 */\n  updatedAt: ISODateTimeString;\n\n  /** 入口节点 ID（显式指定，不依赖入度推断） */\n  entryNodeId: NodeId;\n  /** 节点列表 */\n  nodes: NodeV3[];\n  /** 边列表 */\n  edges: EdgeV3[];\n\n  /** 变量定义 */\n  variables?: VariableDefinition[];\n  /** Flow 级策略 */\n  policy?: FlowPolicy;\n  /** 元数据 */\n  meta?: {\n    /** 标签 */\n    tags?: string[];\n    /** 绑定规则 */\n    bindings?: FlowBinding[];\n  };\n}\n\n/**\n * 根据 ID 查找节点\n */\nexport function findNodeById(flow: FlowV3, nodeId: NodeId): NodeV3 | undefined {\n  return flow.nodes.find((n) => n.id === nodeId);\n}\n\n/**\n * 查找从指定节点出发的所有边\n */\nexport function findEdgesFrom(flow: FlowV3, nodeId: NodeId): EdgeV3[] {\n  return flow.edges.filter((e) => e.from === nodeId);\n}\n\n/**\n * 查找指向指定节点的所有边\n */\nexport function findEdgesTo(flow: FlowV3, nodeId: NodeId): EdgeV3[] {\n  return flow.edges.filter((e) => e.to === nodeId);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/ids.ts",
    "content": "/**\n * @fileoverview ID 类型定义\n * @description 定义 Record-Replay V3 中使用的各种 ID 类型\n */\n\n/** Flow 唯一标识符 */\nexport type FlowId = string;\n\n/** Node 唯一标识符 */\nexport type NodeId = string;\n\n/** Edge 唯一标识符 */\nexport type EdgeId = string;\n\n/** Run 唯一标识符 */\nexport type RunId = string;\n\n/** Trigger 唯一标识符 */\nexport type TriggerId = string;\n\n/** Edge 标签类型 */\nexport type EdgeLabel = string;\n\n/** 预定义的 Edge 标签常量 */\nexport const EDGE_LABELS = {\n  /** 默认边 */\n  DEFAULT: 'default',\n  /** 错误处理边 */\n  ON_ERROR: 'onError',\n  /** 条件为真时的边 */\n  TRUE: 'true',\n  /** 条件为假时的边 */\n  FALSE: 'false',\n} as const;\n\n/** Edge 标签类型（从常量推导） */\nexport type EdgeLabelValue = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/index.ts",
    "content": "/**\n * @fileoverview Domain 层导出入口\n * @description 导出所有 Domain 类型定义\n */\n\n// JSON 基础类型\nexport * from './json';\n\n// ID 类型\nexport * from './ids';\n\n// 错误类型\nexport * from './errors';\n\n// 策略类型\nexport * from './policy';\n\n// 变量类型\nexport * from './variables';\n\n// Flow 类型\nexport * from './flow';\n\n// 事件类型\nexport * from './events';\n\n// 调试器类型\nexport * from './debug';\n\n// 触发器类型\nexport * from './triggers';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/json.ts",
    "content": "/**\n * @fileoverview JSON 基础类型定义\n * @description 定义 Record-Replay V3 中使用的 JSON 相关类型\n */\n\n/** JSON 原始类型 */\nexport type JsonPrimitive = string | number | boolean | null;\n\n/** JSON 对象类型 */\nexport interface JsonObject {\n  [key: string]: JsonValue;\n}\n\n/** JSON 数组类型 */\nexport type JsonArray = JsonValue[];\n\n/** 任意 JSON 值类型 */\nexport type JsonValue = JsonPrimitive | JsonObject | JsonArray;\n\n/** ISO 8601 日期时间字符串 */\nexport type ISODateTimeString = string;\n\n/** Unix 毫秒时间戳 */\nexport type UnixMillis = number;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/policy.ts",
    "content": "/**\n * @fileoverview 策略类型定义\n * @description 定义 Record-Replay V3 中使用的超时、重试、错误处理和工件策略\n */\n\nimport type { EdgeLabel, NodeId } from './ids';\nimport type { RRErrorCode } from './errors';\nimport type { UnixMillis } from './json';\n\n/**\n * 超时策略\n * @description 定义操作的超时时间和作用范围\n */\nexport interface TimeoutPolicy {\n  /** 超时时间（毫秒） */\n  ms: UnixMillis;\n  /** 超时范围：attempt=每次尝试, node=整个节点执行 */\n  scope?: 'attempt' | 'node';\n}\n\n/**\n * 重试策略\n * @description 定义失败后的重试行为\n */\nexport interface RetryPolicy {\n  /** 最大重试次数 */\n  retries: number;\n  /** 重试间隔（毫秒） */\n  intervalMs: UnixMillis;\n  /** 退避策略：none=固定间隔, exp=指数退避, linear=线性增长 */\n  backoff?: 'none' | 'exp' | 'linear';\n  /** 最大重试间隔（毫秒） */\n  maxIntervalMs?: UnixMillis;\n  /** 抖动策略：none=无抖动, full=完全随机 */\n  jitter?: 'none' | 'full';\n  /** 仅在这些错误码时重试 */\n  retryOn?: ReadonlyArray<RRErrorCode>;\n}\n\n/**\n * 错误处理策略\n * @description 定义节点执行失败后的处理方式\n */\nexport type OnErrorPolicy =\n  | { kind: 'stop' }\n  | { kind: 'continue'; as?: 'warning' | 'error' }\n  | {\n      kind: 'goto';\n      target: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'node'; nodeId: NodeId };\n    }\n  | { kind: 'retry'; override?: Partial<RetryPolicy> };\n\n/**\n * 工件策略\n * @description 定义截图和日志收集的行为\n */\nexport interface ArtifactPolicy {\n  /** 截图策略：never=从不, onFailure=失败时, always=总是 */\n  screenshot?: 'never' | 'onFailure' | 'always';\n  /** 截图保存路径模板 */\n  saveScreenshotAs?: string;\n  /** 是否包含控制台日志 */\n  includeConsole?: boolean;\n  /** 是否包含网络请求 */\n  includeNetwork?: boolean;\n}\n\n/**\n * 节点级策略\n * @description 单个节点的执行策略配置\n */\nexport interface NodePolicy {\n  /** 超时策略 */\n  timeout?: TimeoutPolicy;\n  /** 重试策略 */\n  retry?: RetryPolicy;\n  /** 错误处理策略 */\n  onError?: OnErrorPolicy;\n  /** 工件策略 */\n  artifacts?: ArtifactPolicy;\n}\n\n/**\n * Flow 级策略\n * @description 整个 Flow 的执行策略配置\n */\nexport interface FlowPolicy {\n  /** 默认节点策略 */\n  defaultNodePolicy?: NodePolicy;\n  /** 不支持节点的处理策略 */\n  unsupportedNodePolicy?: OnErrorPolicy;\n  /** Run 总超时时间（毫秒） */\n  runTimeoutMs?: UnixMillis;\n}\n\n/**\n * 合并节点策略\n * @description 将 Flow 级默认策略与节点级策略合并\n */\nexport function mergeNodePolicy(\n  flowDefault: NodePolicy | undefined,\n  nodePolicy: NodePolicy | undefined,\n): NodePolicy {\n  if (!flowDefault) return nodePolicy ?? {};\n  if (!nodePolicy) return flowDefault;\n\n  return {\n    timeout: nodePolicy.timeout ?? flowDefault.timeout,\n    retry: nodePolicy.retry ?? flowDefault.retry,\n    onError: nodePolicy.onError ?? flowDefault.onError,\n    artifacts: nodePolicy.artifacts\n      ? { ...flowDefault.artifacts, ...nodePolicy.artifacts }\n      : flowDefault.artifacts,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/triggers.ts",
    "content": "/**\n * @fileoverview 触发器类型定义\n * @description 定义 Record-Replay V3 中的触发器规范\n */\n\nimport type { JsonObject, UnixMillis } from './json';\nimport type { FlowId, TriggerId } from './ids';\n\n/** 触发器类型 */\nexport type TriggerKind =\n  | 'manual'\n  | 'url'\n  | 'cron'\n  | 'interval'\n  | 'once'\n  | 'command'\n  | 'contextMenu'\n  | 'dom';\n\n/**\n * 触发器基础接口\n */\nexport interface TriggerSpecBase {\n  /** 触发器 ID */\n  id: TriggerId;\n  /** 触发器类型 */\n  kind: TriggerKind;\n  /** 是否启用 */\n  enabled: boolean;\n  /** 关联的 Flow ID */\n  flowId: FlowId;\n  /** 传递给 Flow 的参数 */\n  args?: JsonObject;\n}\n\n/**\n * URL 匹配规则\n */\nexport interface UrlMatchRule {\n  kind: 'url' | 'domain' | 'path';\n  value: string;\n}\n\n/**\n * 触发器规范联合类型\n */\nexport type TriggerSpec =\n  // 手动触发\n  | (TriggerSpecBase & { kind: 'manual' })\n\n  // URL 触发\n  | (TriggerSpecBase & {\n      kind: 'url';\n      match: UrlMatchRule[];\n    })\n\n  // Cron 定时触发\n  | (TriggerSpecBase & {\n      kind: 'cron';\n      cron: string;\n      timezone?: string;\n    })\n\n  // Interval 定时触发（固定间隔重复）\n  | (TriggerSpecBase & {\n      kind: 'interval';\n      /** 间隔分钟数，最小为 1 */\n      periodMinutes: number;\n    })\n\n  // Once 定时触发（指定时间触发一次后自动禁用）\n  | (TriggerSpecBase & {\n      kind: 'once';\n      /** 触发时间戳 (Unix milliseconds) */\n      whenMs: UnixMillis;\n    })\n\n  // 快捷键触发\n  | (TriggerSpecBase & {\n      kind: 'command';\n      commandKey: string;\n    })\n\n  // 右键菜单触发\n  | (TriggerSpecBase & {\n      kind: 'contextMenu';\n      title: string;\n      contexts?: ReadonlyArray<string>;\n    })\n\n  // DOM 元素出现触发\n  | (TriggerSpecBase & {\n      kind: 'dom';\n      selector: string;\n      appear?: boolean;\n      once?: boolean;\n      debounceMs?: UnixMillis;\n    });\n\n/**\n * 触发器触发上下文\n * @description 描述触发器被触发时的上下文信息\n */\nexport interface TriggerFireContext {\n  /** 触发器 ID */\n  triggerId: TriggerId;\n  /** 触发器类型 */\n  kind: TriggerKind;\n  /** 触发时间 */\n  firedAt: UnixMillis;\n  /** 来源 Tab ID */\n  sourceTabId?: number;\n  /** 来源 URL */\n  sourceUrl?: string;\n}\n\n/**\n * 根据触发器类型获取类型化的触发器规范\n */\nexport type TriggerSpecByKind<K extends TriggerKind> = Extract<TriggerSpec, { kind: K }>;\n\n/**\n * 判断触发器是否启用\n */\nexport function isTriggerEnabled(trigger: TriggerSpec): boolean {\n  return trigger.enabled;\n}\n\n/**\n * 创建触发器触发上下文\n */\nexport function createTriggerFireContext(\n  trigger: TriggerSpec,\n  options?: { sourceTabId?: number; sourceUrl?: string },\n): TriggerFireContext {\n  return {\n    triggerId: trigger.id,\n    kind: trigger.kind,\n    firedAt: Date.now(),\n    sourceTabId: options?.sourceTabId,\n    sourceUrl: options?.sourceUrl,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/domain/variables.ts",
    "content": "/**\n * @fileoverview 变量类型定义\n * @description 定义 Record-Replay V3 中使用的变量指针和持久化变量\n */\n\nimport type { JsonValue, UnixMillis } from './json';\n\n/** 变量名称 */\nexport type VariableName = string;\n\n/** 持久化变量名称（以 $ 开头） */\nexport type PersistentVariableName = `$${string}`;\n\n/** 变量作用域 */\nexport type VariableScope = 'run' | 'flow' | 'persistent';\n\n/**\n * 变量指针\n * @description 指向变量的引用，支持 JSON path 访问\n */\nexport interface VariablePointer {\n  /** 变量作用域 */\n  scope: VariableScope;\n  /** 变量名称 */\n  name: VariableName;\n  /** JSON path（用于访问嵌套属性） */\n  path?: ReadonlyArray<string | number>;\n}\n\n/**\n * 变量定义\n * @description Flow 中声明的变量\n */\nexport interface VariableDefinition {\n  /** 变量名称 */\n  name: VariableName;\n  /** 显示标签 */\n  label?: string;\n  /** 描述 */\n  description?: string;\n  /** 是否敏感（不显示/导出） */\n  sensitive?: boolean;\n  /** 是否必需 */\n  required?: boolean;\n  /** 默认值 */\n  default?: JsonValue;\n  /** 作用域（不含 persistent，persistent 通过 $ 前缀判断） */\n  scope?: Exclude<VariableScope, 'persistent'>;\n}\n\n/**\n * 持久化变量记录\n * @description 存储在 IndexedDB 中的持久化变量\n */\nexport interface PersistentVarRecord {\n  /** 变量键（以 $ 开头） */\n  key: PersistentVariableName;\n  /** 变量值 */\n  value: JsonValue;\n  /** 最后更新时间 */\n  updatedAt: UnixMillis;\n  /** 版本号（单调递增，用于 LWW 和调试） */\n  version: number;\n}\n\n/**\n * 判断变量名是否为持久化变量\n */\nexport function isPersistentVariable(name: string): name is PersistentVariableName {\n  return name.startsWith('$');\n}\n\n/**\n * 解析变量指针字符串\n * @example \"$user.name\" -> { scope: 'persistent', name: '$user', path: ['name'] }\n */\nexport function parseVariablePointer(ref: string): VariablePointer | null {\n  if (!ref) return null;\n\n  const parts = ref.split('.');\n  const name = parts[0];\n  const path = parts.slice(1);\n\n  if (isPersistentVariable(name)) {\n    return {\n      scope: 'persistent',\n      name,\n      path: path.length > 0 ? path : undefined,\n    };\n  }\n\n  // 默认为 run 作用域\n  return {\n    scope: 'run',\n    name,\n    path: path.length > 0 ? path : undefined,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/index.ts",
    "content": "/**\n * @fileoverview Engine 层导出入口\n */\n\n// Kernel\nexport * from './kernel';\n\n// Queue\nexport * from './queue';\n\n// Plugins\nexport * from './plugins';\n\n// Transport\nexport * from './transport';\n\n// Keepalive\nexport * from './keepalive';\n\n// Recovery\nexport * from './recovery';\n\n// Triggers\nexport * from './triggers';\n\n// Storage Port\nexport * from './storage';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/index.ts",
    "content": "/**\n * @fileoverview Keepalive 模块导出入口\n */\n\nexport * from './offscreen-keepalive';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive.ts",
    "content": "/**\n * @fileoverview Offscreen Keepalive Controller\n * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat.\n *\n * Architecture:\n * - Background (this module) listens for an Offscreen Port connection.\n * - Offscreen connects and sends heartbeat pings.\n * - Background replies with pong and controls the heartbeat via `start`/`stop`.\n *\n * Contract:\n * - When at least one keepalive reference is held, keepalive must be running.\n * - When the reference count drops to zero, keepalive must fully stop (no ping loop, no Port, no reconnect).\n */\n\nimport { offscreenManager } from '@/utils/offscreen-manager';\nimport {\n  RR_V3_KEEPALIVE_PORT_NAME,\n  type KeepaliveMessage,\n} from '@/common/rr-v3-keepalive-protocol';\n\n// ==================== Runtime Control Protocol ====================\n\nconst KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const;\n\ntype KeepaliveControlCommand = 'start' | 'stop';\n\ninterface KeepaliveControlMessage {\n  type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE;\n  command: KeepaliveControlCommand;\n}\n\n// ==================== Types ====================\n\n/**\n * Keepalive controller interface.\n * @description Manages Service Worker keepalive state.\n */\nexport interface KeepaliveController {\n  /**\n   * Acquire (increment reference count).\n   * @param tag Tag used for debugging.\n   * @returns Release function.\n   */\n  acquire(tag: string): () => void;\n\n  /** Whether any keepalive reference is currently held. */\n  isActive(): boolean;\n\n  /** Current reference count. */\n  getRefCount(): number;\n\n  /** Release all references (primarily for testing). */\n  releaseAll(): void;\n}\n\n/**\n * Offscreen keepalive options.\n */\nexport interface OffscreenKeepaliveOptions {\n  /** Logger. */\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\n// ==================== Factory ====================\n\n/**\n * Create an Offscreen keepalive controller.\n * @description Reuses the global OffscreenManager to avoid creating multiple Offscreen Documents concurrently.\n */\nexport function createOffscreenKeepaliveController(\n  options: OffscreenKeepaliveOptions = {},\n): KeepaliveController {\n  return new OffscreenKeepaliveControllerImpl(options);\n}\n\n/**\n * Create a NotImplemented KeepaliveController.\n * @description Placeholder implementation.\n */\nexport function createNotImplementedKeepaliveController(): KeepaliveController {\n  return {\n    acquire: () => {\n      console.warn('[KeepaliveController] Not implemented, returning no-op release');\n      return () => {};\n    },\n    isActive: () => false,\n    getRefCount: () => 0,\n    releaseAll: () => {},\n  };\n}\n\n// ==================== Implementation ====================\n\n/**\n * Offscreen keepalive controller implementation.\n */\nclass OffscreenKeepaliveControllerImpl implements KeepaliveController {\n  private readonly refs = new Map<string, number>();\n  private totalRefs = 0;\n\n  private offscreenPort: chrome.runtime.Port | null = null;\n  private connectionListenerRegistered = false;\n\n  // Used to serialize async operations to avoid races.\n  private syncPromise: Promise<void> = Promise.resolve();\n\n  private readonly logger: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n\n  constructor(options: OffscreenKeepaliveOptions) {\n    this.logger = options.logger ?? console;\n    // Register listener eagerly to avoid missing Offscreen connect events.\n    // This prevents race conditions where Offscreen connects before we start listening.\n    this.ensureConnectionListener();\n  }\n\n  acquire(tag: string): () => void {\n    this.totalRefs += 1;\n\n    const count = this.refs.get(tag) ?? 0;\n    this.refs.set(tag, count + 1);\n\n    this.logger.debug(`[OffscreenKeepalive] acquire(${tag}), totalRefs=${this.totalRefs}`);\n\n    // Start keepalive when the first reference is acquired.\n    if (this.totalRefs === 1) {\n      this.scheduleSync();\n    }\n\n    let released = false;\n    return () => {\n      if (released) return;\n      released = true;\n\n      if (this.totalRefs > 0) {\n        this.totalRefs -= 1;\n      }\n\n      const currentCount = this.refs.get(tag) ?? 0;\n      if (currentCount <= 1) {\n        this.refs.delete(tag);\n      } else {\n        this.refs.set(tag, currentCount - 1);\n      }\n\n      this.logger.debug(`[OffscreenKeepalive] release(${tag}), totalRefs=${this.totalRefs}`);\n\n      // Stop keepalive when the reference count drops to zero.\n      if (this.totalRefs === 0) {\n        this.scheduleSync();\n      }\n    };\n  }\n\n  isActive(): boolean {\n    return this.totalRefs > 0;\n  }\n\n  getRefCount(): number {\n    return this.totalRefs;\n  }\n\n  releaseAll(): void {\n    if (this.totalRefs === 0) return;\n\n    this.logger.debug('[OffscreenKeepalive] releaseAll()');\n    this.refs.clear();\n    this.totalRefs = 0;\n    this.scheduleSync();\n  }\n\n  /**\n   * Get the current reference counts grouped by tag.\n   * @description Useful for debugging.\n   */\n  getRefsByTag(): Record<string, number> {\n    return Object.fromEntries(this.refs);\n  }\n\n  // ==================== Private Methods ====================\n\n  /**\n   * Schedule a sync operation.\n   * @description Serializes async operations to avoid races.\n   */\n  private scheduleSync(): void {\n    this.syncPromise = this.syncPromise\n      .catch(() => {\n        // Ignore previous operation errors.\n      })\n      .then(() => this.syncOnce())\n      .catch((e) => {\n        this.logger.warn('[OffscreenKeepalive] sync failed:', e);\n      });\n  }\n\n  /**\n   * Perform a single sync step based on the current ref count.\n   */\n  private async syncOnce(): Promise<void> {\n    if (this.totalRefs > 0) {\n      // Ensure listener exists before Offscreen connects (race prevention).\n      this.ensureConnectionListener();\n\n      // Ensure the Offscreen document exists.\n      await offscreenManager.ensureOffscreenDocument();\n\n      // Re-check after await: state may have changed while we were creating the document.\n      if (this.totalRefs === 0) {\n        await this.teardown();\n        return;\n      }\n\n      // Send start command via runtime message (works even if Port is not connected).\n      await this.sendRuntimeControl('start');\n      // Also send via Port if connected.\n      this.sendStartCommand();\n    } else {\n      // Send stop via Port first (if connected).\n      this.sendStopCommand();\n      // Then send via runtime message to ensure Offscreen stops.\n      await this.sendRuntimeControl('stop');\n      await this.teardown();\n    }\n  }\n\n  /**\n   * Clean up resources.\n   */\n  private async teardown(): Promise<void> {\n    this.disconnectPort();\n    // Note: We do not close the Offscreen Document here because it may be used by other modules.\n    // If Offscreen Document lifecycle needs ref-counting, it should be implemented in OffscreenManager.\n  }\n\n  /**\n   * Ensure the Port connection listener is registered.\n   */\n  private ensureConnectionListener(): void {\n    if (this.connectionListenerRegistered) return;\n\n    if (typeof chrome === 'undefined' || !chrome.runtime?.onConnect) {\n      this.logger.warn('[OffscreenKeepalive] chrome.runtime.onConnect not available');\n      return;\n    }\n\n    chrome.runtime.onConnect.addListener(this.handleConnect);\n    this.connectionListenerRegistered = true;\n\n    this.logger.debug('[OffscreenKeepalive] Connection listener registered');\n  }\n\n  /**\n   * Handle Port connections from Offscreen.\n   */\n  private handleConnect = (port: chrome.runtime.Port): void => {\n    if (port.name !== RR_V3_KEEPALIVE_PORT_NAME) return;\n\n    this.logger.debug('[OffscreenKeepalive] Offscreen connected');\n\n    // Store Port reference.\n    this.offscreenPort = port;\n\n    // Listen to messages.\n    port.onMessage.addListener(this.handlePortMessage);\n\n    // Listen to disconnect.\n    port.onDisconnect.addListener(() => {\n      this.logger.debug('[OffscreenKeepalive] Offscreen disconnected');\n      if (this.offscreenPort === port) {\n        this.offscreenPort = null;\n      }\n    });\n\n    // If active, send the start command.\n    if (this.totalRefs > 0) {\n      this.sendStartCommand();\n    }\n  };\n\n  /**\n   * Handle messages from Offscreen.\n   */\n  private handlePortMessage = (msg: unknown): void => {\n    const m = msg as Partial<KeepaliveMessage> | null;\n    if (!m || typeof m !== 'object') return;\n\n    if (m.type === 'keepalive.ping') {\n      this.logger.debug('[OffscreenKeepalive] Received ping, sending pong');\n      this.sendPong();\n    }\n  };\n\n  /**\n   * Disconnect the Port.\n   */\n  private disconnectPort(): void {\n    if (!this.offscreenPort) return;\n\n    const port = this.offscreenPort;\n    this.offscreenPort = null;\n\n    try {\n      port.disconnect();\n    } catch {\n      // Port may already be disconnected.\n    }\n\n    this.logger.debug('[OffscreenKeepalive] Port disconnected');\n  }\n\n  /**\n   * Send the start command to Offscreen (Port channel).\n   */\n  private sendStartCommand(): void {\n    if (!this.offscreenPort) return;\n\n    const msg: KeepaliveMessage = {\n      type: 'keepalive.start',\n      timestamp: Date.now(),\n    };\n\n    try {\n      this.offscreenPort.postMessage(msg);\n      this.logger.debug('[OffscreenKeepalive] Sent start command via Port');\n    } catch (e) {\n      this.logger.warn('[OffscreenKeepalive] Failed to send start command:', e);\n    }\n  }\n\n  /**\n   * Send the stop command to Offscreen (Port channel).\n   */\n  private sendStopCommand(): void {\n    if (!this.offscreenPort) return;\n\n    const msg: KeepaliveMessage = {\n      type: 'keepalive.stop',\n      timestamp: Date.now(),\n    };\n\n    try {\n      this.offscreenPort.postMessage(msg);\n      this.logger.debug('[OffscreenKeepalive] Sent stop command via Port');\n    } catch (e) {\n      this.logger.warn('[OffscreenKeepalive] Failed to send stop command:', e);\n    }\n  }\n\n  /**\n   * Send a pong response.\n   */\n  private sendPong(): void {\n    if (!this.offscreenPort) return;\n\n    const msg: KeepaliveMessage = {\n      type: 'keepalive.pong',\n      timestamp: Date.now(),\n    };\n\n    try {\n      this.offscreenPort.postMessage(msg);\n    } catch (e) {\n      this.logger.warn('[OffscreenKeepalive] Failed to send pong:', e);\n    }\n  }\n\n  /**\n   * Send a runtime control command to Offscreen.\n   * This is the control plane used to start/stop keepalive even when the Port is not connected.\n   */\n  private async sendRuntimeControl(command: KeepaliveControlCommand): Promise<void> {\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      this.logger.warn('[OffscreenKeepalive] chrome.runtime.sendMessage not available');\n      return;\n    }\n\n    const msg: KeepaliveControlMessage = {\n      type: KEEPALIVE_CONTROL_MESSAGE_TYPE,\n      command,\n    };\n\n    // Retry with delays for start command (Offscreen document may not be ready yet).\n    const delaysMs = command === 'start' ? [0, 50, 200] : [0];\n    for (const delayMs of delaysMs) {\n      if (delayMs > 0) {\n        await new Promise((resolve) => setTimeout(resolve, delayMs));\n      }\n      try {\n        await chrome.runtime.sendMessage(msg);\n        this.logger.debug(`[OffscreenKeepalive] Sent runtime ${command} command`);\n        return;\n      } catch {\n        // Best-effort: Offscreen document may not be ready yet.\n      }\n    }\n\n    this.logger.warn(`[OffscreenKeepalive] Failed to send runtime ${command} command`);\n  }\n}\n\n// ==================== Test Utilities ====================\n\n/**\n * In-memory keepalive controller.\n * @description For tests only: tracks reference counts without using Offscreen.\n */\nexport class InMemoryKeepaliveController implements KeepaliveController {\n  private refs = new Map<string, number>();\n\n  acquire(tag: string): () => void {\n    const count = this.refs.get(tag) ?? 0;\n    this.refs.set(tag, count + 1);\n\n    let released = false;\n    return () => {\n      if (released) return;\n      released = true;\n\n      const currentCount = this.refs.get(tag) ?? 0;\n      if (currentCount <= 1) {\n        this.refs.delete(tag);\n      } else {\n        this.refs.set(tag, currentCount - 1);\n      }\n    };\n  }\n\n  isActive(): boolean {\n    return this.refs.size > 0;\n  }\n\n  getRefCount(): number {\n    let total = 0;\n    for (const count of this.refs.values()) {\n      total += count;\n    }\n    return total;\n  }\n\n  releaseAll(): void {\n    this.refs.clear();\n  }\n\n  /**\n   * Get the current reference counts grouped by tag.\n   * @description Useful for debugging.\n   */\n  getRefsByTag(): Record<string, number> {\n    return Object.fromEntries(this.refs);\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/artifacts.ts",
    "content": "/**\n * @fileoverview 工件（Artifacts）接口\n * @description 定义截图等工件的获取和存储接口\n */\n\nimport type { NodeId, RunId } from '../../domain/ids';\nimport type { RRError } from '../../domain/errors';\nimport { RR_ERROR_CODES, createRRError } from '../../domain/errors';\n\n/**\n * 截图结果\n */\nexport type ScreenshotResult = { ok: true; base64: string } | { ok: false; error: RRError };\n\n/**\n * 工件服务接口\n * @description 提供工件获取和存储功能\n */\nexport interface ArtifactService {\n  /**\n   * 截取页面截图\n   * @param tabId Tab ID\n   * @param options 截图选项\n   */\n  screenshot(\n    tabId: number,\n    options?: {\n      format?: 'png' | 'jpeg';\n      quality?: number;\n    },\n  ): Promise<ScreenshotResult>;\n\n  /**\n   * 保存截图\n   * @param runId Run ID\n   * @param nodeId Node ID\n   * @param base64 截图数据\n   * @param filename 文件名（可选）\n   */\n  saveScreenshot(\n    runId: RunId,\n    nodeId: NodeId,\n    base64: string,\n    filename?: string,\n  ): Promise<{ savedAs: string } | { error: RRError }>;\n}\n\n/**\n * 创建 NotImplemented 的 ArtifactService\n * @description Phase 0-1 占位实现\n */\nexport function createNotImplementedArtifactService(): ArtifactService {\n  return {\n    screenshot: async () => ({\n      ok: false,\n      error: createRRError(RR_ERROR_CODES.INTERNAL, 'ArtifactService.screenshot not implemented'),\n    }),\n    saveScreenshot: async () => ({\n      error: createRRError(\n        RR_ERROR_CODES.INTERNAL,\n        'ArtifactService.saveScreenshot not implemented',\n      ),\n    }),\n  };\n}\n\n/**\n * 创建基于 chrome.tabs.captureVisibleTab 的 ArtifactService\n * @description 使用 Chrome API 截取可见标签页\n */\nexport function createChromeArtifactService(): ArtifactService {\n  // In-memory storage for screenshots (could be replaced with IndexedDB)\n  const screenshotStore = new Map<string, string>();\n\n  return {\n    screenshot: async (tabId, options) => {\n      try {\n        // Get the window ID for the tab\n        const tab = await chrome.tabs.get(tabId);\n        if (!tab.windowId) {\n          return {\n            ok: false,\n            error: createRRError(RR_ERROR_CODES.INTERNAL, `Tab ${tabId} has no window`),\n          };\n        }\n\n        // Capture the visible tab\n        const format = options?.format ?? 'png';\n        const quality = options?.quality ?? 100;\n\n        const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {\n          format,\n          quality: format === 'jpeg' ? quality : undefined,\n        });\n\n        // Extract base64 from data URL\n        const base64Match = dataUrl.match(/^data:image\\/\\w+;base64,(.+)$/);\n        if (!base64Match) {\n          return {\n            ok: false,\n            error: createRRError(RR_ERROR_CODES.INTERNAL, 'Invalid screenshot data URL'),\n          };\n        }\n\n        return { ok: true, base64: base64Match[1] };\n      } catch (e) {\n        const message = e instanceof Error ? e.message : String(e);\n        return {\n          ok: false,\n          error: createRRError(RR_ERROR_CODES.INTERNAL, `Screenshot failed: ${message}`),\n        };\n      }\n    },\n\n    saveScreenshot: async (runId, nodeId, base64, filename) => {\n      try {\n        // Generate filename if not provided\n        const savedAs = filename ?? `${runId}_${nodeId}_${Date.now()}.png`;\n        const key = `${runId}/${savedAs}`;\n\n        // Store in memory (in production, this would go to IndexedDB or cloud storage)\n        screenshotStore.set(key, base64);\n\n        return { savedAs };\n      } catch (e) {\n        const message = e instanceof Error ? e.message : String(e);\n        return {\n          error: createRRError(RR_ERROR_CODES.INTERNAL, `Save screenshot failed: ${message}`),\n        };\n      }\n    },\n  };\n}\n\n/**\n * 工件策略执行器\n * @description 根据策略配置决定是否获取工件\n */\nexport interface ArtifactPolicyExecutor {\n  /**\n   * 执行截图策略\n   * @param policy 截图策略\n   * @param context 上下文\n   */\n  executeScreenshotPolicy(\n    policy: 'never' | 'onFailure' | 'always',\n    context: {\n      tabId: number;\n      runId: RunId;\n      nodeId: NodeId;\n      failed: boolean;\n      saveAs?: string;\n    },\n  ): Promise<{ captured: boolean; savedAs?: string; error?: RRError }>;\n}\n\n/**\n * 创建默认的工件策略执行器\n */\nexport function createArtifactPolicyExecutor(service: ArtifactService): ArtifactPolicyExecutor {\n  return {\n    executeScreenshotPolicy: async (policy, context) => {\n      // 根据策略决定是否截图\n      const shouldCapture = policy === 'always' || (policy === 'onFailure' && context.failed);\n\n      if (!shouldCapture) {\n        return { captured: false };\n      }\n\n      // 截图\n      const result = await service.screenshot(context.tabId);\n      if (!result.ok) {\n        return { captured: false, error: result.error };\n      }\n\n      // 保存（如果指定了文件名）\n      if (context.saveAs) {\n        const saveResult = await service.saveScreenshot(\n          context.runId,\n          context.nodeId,\n          result.base64,\n          context.saveAs,\n        );\n        if ('error' in saveResult) {\n          return { captured: true, error: saveResult.error };\n        }\n        return { captured: true, savedAs: saveResult.savedAs };\n      }\n\n      return { captured: true };\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/breakpoints.ts",
    "content": "/**\n * @fileoverview 断点管理器\n * @description 管理调试断点的添加、删除和命中检测\n */\n\nimport type { NodeId, RunId } from '../../domain/ids';\nimport type { Breakpoint, DebuggerState } from '../../domain/debug';\n\n/**\n * 断点管理器\n * @description 管理单个 Run 的断点\n */\nexport class BreakpointManager {\n  private breakpoints = new Map<NodeId, Breakpoint>();\n  private stepMode: 'none' | 'stepOver' = 'none';\n\n  constructor(initialBreakpoints?: NodeId[]) {\n    if (initialBreakpoints) {\n      for (const nodeId of initialBreakpoints) {\n        this.add(nodeId);\n      }\n    }\n  }\n\n  /**\n   * 添加断点\n   */\n  add(nodeId: NodeId): void {\n    this.breakpoints.set(nodeId, { nodeId, enabled: true });\n  }\n\n  /**\n   * 删除断点\n   */\n  remove(nodeId: NodeId): void {\n    this.breakpoints.delete(nodeId);\n  }\n\n  /**\n   * 设置断点列表（替换所有现有断点）\n   */\n  setAll(nodeIds: NodeId[]): void {\n    this.breakpoints.clear();\n    for (const nodeId of nodeIds) {\n      this.add(nodeId);\n    }\n  }\n\n  /**\n   * 启用断点\n   */\n  enable(nodeId: NodeId): void {\n    const bp = this.breakpoints.get(nodeId);\n    if (bp) {\n      bp.enabled = true;\n    }\n  }\n\n  /**\n   * 禁用断点\n   */\n  disable(nodeId: NodeId): void {\n    const bp = this.breakpoints.get(nodeId);\n    if (bp) {\n      bp.enabled = false;\n    }\n  }\n\n  /**\n   * 检查节点是否有启用的断点\n   */\n  hasBreakpoint(nodeId: NodeId): boolean {\n    const bp = this.breakpoints.get(nodeId);\n    return bp?.enabled ?? false;\n  }\n\n  /**\n   * 检查是否应该在节点处暂停\n   * @description 考虑断点和单步模式\n   */\n  shouldPauseAt(nodeId: NodeId): boolean {\n    // 如果在单步模式，总是暂停\n    if (this.stepMode === 'stepOver') {\n      return true;\n    }\n    // 否则检查断点\n    return this.hasBreakpoint(nodeId);\n  }\n\n  /**\n   * 获取所有断点\n   */\n  getAll(): Breakpoint[] {\n    return Array.from(this.breakpoints.values());\n  }\n\n  /**\n   * 获取启用的断点\n   */\n  getEnabled(): Breakpoint[] {\n    return this.getAll().filter((bp) => bp.enabled);\n  }\n\n  /**\n   * 设置单步模式\n   */\n  setStepMode(mode: 'none' | 'stepOver'): void {\n    this.stepMode = mode;\n  }\n\n  /**\n   * 获取单步模式\n   */\n  getStepMode(): 'none' | 'stepOver' {\n    return this.stepMode;\n  }\n\n  /**\n   * 清除所有断点\n   */\n  clear(): void {\n    this.breakpoints.clear();\n    this.stepMode = 'none';\n  }\n}\n\n/**\n * 断点管理器注册表\n * @description 管理多个 Run 的断点管理器\n */\nexport class BreakpointRegistry {\n  private managers = new Map<RunId, BreakpointManager>();\n\n  /**\n   * 获取或创建断点管理器\n   */\n  getOrCreate(runId: RunId, initialBreakpoints?: NodeId[]): BreakpointManager {\n    let manager = this.managers.get(runId);\n    if (!manager) {\n      manager = new BreakpointManager(initialBreakpoints);\n      this.managers.set(runId, manager);\n    }\n    return manager;\n  }\n\n  /**\n   * 获取断点管理器\n   */\n  get(runId: RunId): BreakpointManager | undefined {\n    return this.managers.get(runId);\n  }\n\n  /**\n   * 删除断点管理器\n   */\n  remove(runId: RunId): void {\n    this.managers.delete(runId);\n  }\n\n  /**\n   * 清空所有\n   */\n  clear(): void {\n    this.managers.clear();\n  }\n}\n\n/** 全局断点注册表 */\nlet globalBreakpointRegistry: BreakpointRegistry | null = null;\n\n/**\n * 获取全局断点注册表\n */\nexport function getBreakpointRegistry(): BreakpointRegistry {\n  if (!globalBreakpointRegistry) {\n    globalBreakpointRegistry = new BreakpointRegistry();\n  }\n  return globalBreakpointRegistry;\n}\n\n/**\n * 重置全局断点注册表\n * @description 主要用于测试\n */\nexport function resetBreakpointRegistry(): void {\n  globalBreakpointRegistry = null;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/debug-controller.ts",
    "content": "/**\n * @fileoverview Debug Controller\n * @description Central control plane for debugging - command routing, state aggregation, and UI push\n */\n\nimport type { NodeId, RunId } from '../../domain/ids';\nimport type { JsonValue } from '../../domain/json';\nimport type { PauseReason, RunEvent, Unsubscribe } from '../../domain/events';\nimport type {\n  DebuggerCommand,\n  DebuggerResponse,\n  DebuggerState,\n  Breakpoint,\n} from '../../domain/debug';\nimport { createInitialDebuggerState } from '../../domain/debug';\n\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from '../transport/events-bus';\nimport type { RunRunner } from './runner';\nimport { BreakpointManager, getBreakpointRegistry } from './breakpoints';\n\n/**\n * Runner registry for managing active runners\n */\nexport interface RunnerRegistry {\n  get(runId: RunId): RunRunner | undefined;\n  register(runId: RunId, runner: RunRunner): void;\n  unregister(runId: RunId): void;\n  list(): RunId[];\n}\n\n/**\n * Create a simple runner registry\n */\nexport function createRunnerRegistry(): RunnerRegistry {\n  const runners = new Map<RunId, RunRunner>();\n  return {\n    get: (runId) => runners.get(runId),\n    register: (runId, runner) => runners.set(runId, runner),\n    unregister: (runId) => runners.delete(runId),\n    list: () => Array.from(runners.keys()),\n  };\n}\n\n/**\n * Debug session state (per-run)\n */\ninterface DebugSession {\n  runId: RunId;\n  attached: boolean;\n  lastPauseReason?: PauseReason;\n  lastKnownNodeId?: NodeId;\n  lastKnownExecution: 'running' | 'paused';\n}\n\n/**\n * Debug state listener\n */\ntype DebugStateListener = (state: DebuggerState) => void;\n\n/**\n * Debug Controller Configuration\n */\nexport interface DebugControllerConfig {\n  storage: StoragePort;\n  events: EventsBus;\n  runners: RunnerRegistry;\n}\n\n/**\n * Debug Controller\n * @description Single entry point for all debug operations\n */\nexport class DebugController {\n  private readonly storage: StoragePort;\n  private readonly events: EventsBus;\n  private readonly runners: RunnerRegistry;\n\n  private readonly sessions = new Map<RunId, DebugSession>();\n  private readonly listeners = new Map<RunId | null, Set<DebugStateListener>>();\n  private eventUnsubscribe: Unsubscribe | null = null;\n\n  constructor(config: DebugControllerConfig) {\n    this.storage = config.storage;\n    this.events = config.events;\n    this.runners = config.runners;\n  }\n\n  /**\n   * Start the debug controller\n   */\n  start(): void {\n    // Subscribe to all events to track pause/resume state\n    this.eventUnsubscribe = this.events.subscribe((event) => {\n      this.handleEvent(event);\n    });\n  }\n\n  /**\n   * Stop the debug controller\n   */\n  stop(): void {\n    if (this.eventUnsubscribe) {\n      this.eventUnsubscribe();\n      this.eventUnsubscribe = null;\n    }\n    this.sessions.clear();\n    this.listeners.clear();\n  }\n\n  /**\n   * Handle a debug command\n   */\n  async handle(cmd: DebuggerCommand): Promise<DebuggerResponse> {\n    try {\n      switch (cmd.type) {\n        case 'debug.attach':\n          return this.handleAttach(cmd.runId);\n\n        case 'debug.detach':\n          return this.handleDetach(cmd.runId);\n\n        case 'debug.pause':\n          return this.handlePause(cmd.runId);\n\n        case 'debug.resume':\n          return this.handleResume(cmd.runId);\n\n        case 'debug.stepOver':\n          return this.handleStepOver(cmd.runId);\n\n        case 'debug.setBreakpoints':\n          return this.handleSetBreakpoints(cmd.runId, cmd.nodeIds);\n\n        case 'debug.addBreakpoint':\n          return this.handleAddBreakpoint(cmd.runId, cmd.nodeId);\n\n        case 'debug.removeBreakpoint':\n          return this.handleRemoveBreakpoint(cmd.runId, cmd.nodeId);\n\n        case 'debug.getState':\n          return this.handleGetState(cmd.runId);\n\n        case 'debug.getVar':\n          return this.handleGetVar(cmd.runId, cmd.name);\n\n        case 'debug.setVar':\n          return this.handleSetVar(cmd.runId, cmd.name, cmd.value);\n\n        default:\n          return { ok: false, error: `Unknown debug command: ${(cmd as { type: string }).type}` };\n      }\n    } catch (e) {\n      const message = e instanceof Error ? e.message : String(e);\n      return { ok: false, error: message };\n    }\n  }\n\n  /**\n   * Subscribe to debug state changes\n   */\n  subscribe(listener: DebugStateListener, filter?: { runId?: RunId }): Unsubscribe {\n    const key = filter?.runId ?? null;\n    let set = this.listeners.get(key);\n    if (!set) {\n      set = new Set();\n      this.listeners.set(key, set);\n    }\n    set.add(listener);\n\n    return () => {\n      set?.delete(listener);\n      if (set?.size === 0) {\n        this.listeners.delete(key);\n      }\n    };\n  }\n\n  /**\n   * Get current debug state for a run\n   */\n  async getState(runId: RunId): Promise<DebuggerState> {\n    const session = this.sessions.get(runId);\n    const run = await this.storage.runs.get(runId);\n    const bpManager = getBreakpointRegistry().get(runId);\n\n    const state: DebuggerState = {\n      runId,\n      status: session?.attached ? 'attached' : 'detached',\n      execution: session?.lastKnownExecution ?? (run?.status === 'paused' ? 'paused' : 'running'),\n      pauseReason: session?.lastPauseReason,\n      currentNodeId: session?.lastKnownNodeId ?? run?.currentNodeId,\n      breakpoints: bpManager?.getAll() ?? [],\n      stepMode: bpManager?.getStepMode() ?? 'none',\n    };\n\n    return state;\n  }\n\n  // ==================== Command Handlers ====================\n\n  private async handleAttach(runId: RunId): Promise<DebuggerResponse> {\n    const run = await this.storage.runs.get(runId);\n    if (!run) {\n      return { ok: false, error: `Run \"${runId}\" not found` };\n    }\n\n    // Create or update session\n    let session = this.sessions.get(runId);\n    if (!session) {\n      session = {\n        runId,\n        attached: true,\n        lastKnownExecution: run.status === 'paused' ? 'paused' : 'running',\n        lastKnownNodeId: run.currentNodeId,\n      };\n      this.sessions.set(runId, session);\n    } else {\n      session.attached = true;\n    }\n\n    // Get or create breakpoint manager\n    getBreakpointRegistry().getOrCreate(runId, run.debug?.breakpoints);\n\n    const state = await this.getState(runId);\n    this.notifyStateChange(runId, state);\n    return { ok: true, state };\n  }\n\n  private async handleDetach(runId: RunId): Promise<DebuggerResponse> {\n    const session = this.sessions.get(runId);\n    if (session) {\n      session.attached = false;\n    }\n\n    const state = await this.getState(runId);\n    this.notifyStateChange(runId, state);\n    return { ok: true, state };\n  }\n\n  private async handlePause(runId: RunId): Promise<DebuggerResponse> {\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      return { ok: false, error: `Runner for \"${runId}\" not found` };\n    }\n\n    runner.pause();\n    const state = await this.getState(runId);\n    return { ok: true, state };\n  }\n\n  private async handleResume(runId: RunId): Promise<DebuggerResponse> {\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      return { ok: false, error: `Runner for \"${runId}\" not found` };\n    }\n\n    runner.resume();\n    const state = await this.getState(runId);\n    return { ok: true, state };\n  }\n\n  private async handleStepOver(runId: RunId): Promise<DebuggerResponse> {\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      return { ok: false, error: `Runner for \"${runId}\" not found` };\n    }\n\n    // Set step mode to stepOver (will pause at next node)\n    const bpManager = getBreakpointRegistry().getOrCreate(runId);\n    bpManager.setStepMode('stepOver');\n\n    // Resume execution - runner will pause at next node due to stepOver mode\n    runner.resume();\n\n    const state = await this.getState(runId);\n    return { ok: true, state };\n  }\n\n  private async handleSetBreakpoints(runId: RunId, nodeIds: NodeId[]): Promise<DebuggerResponse> {\n    const bpManager = getBreakpointRegistry().getOrCreate(runId);\n    bpManager.setAll(nodeIds);\n\n    // Persist breakpoints to run record\n    await this.persistBreakpoints(runId, bpManager);\n\n    const state = await this.getState(runId);\n    this.notifyStateChange(runId, state);\n    return { ok: true, state };\n  }\n\n  private async handleAddBreakpoint(runId: RunId, nodeId: NodeId): Promise<DebuggerResponse> {\n    const bpManager = getBreakpointRegistry().getOrCreate(runId);\n    bpManager.add(nodeId);\n\n    await this.persistBreakpoints(runId, bpManager);\n\n    const state = await this.getState(runId);\n    this.notifyStateChange(runId, state);\n    return { ok: true, state };\n  }\n\n  private async handleRemoveBreakpoint(runId: RunId, nodeId: NodeId): Promise<DebuggerResponse> {\n    const bpManager = getBreakpointRegistry().getOrCreate(runId);\n    bpManager.remove(nodeId);\n\n    await this.persistBreakpoints(runId, bpManager);\n\n    const state = await this.getState(runId);\n    this.notifyStateChange(runId, state);\n    return { ok: true, state };\n  }\n\n  private async handleGetState(runId: RunId): Promise<DebuggerResponse> {\n    const state = await this.getState(runId);\n    return { ok: true, state };\n  }\n\n  private async handleGetVar(runId: RunId, name: string): Promise<DebuggerResponse> {\n    // Try to get from active runner first\n    const runner = this.runners.get(runId);\n    if (runner) {\n      const value = runner.getVar(name);\n      return { ok: true, value: value ?? null };\n    }\n\n    // Fallback: reconstruct from events\n    const value = await this.reconstructVar(runId, name);\n    return { ok: true, value: value ?? null };\n  }\n\n  private async handleSetVar(\n    runId: RunId,\n    name: string,\n    value: JsonValue,\n  ): Promise<DebuggerResponse> {\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      return {\n        ok: false,\n        error: `Runner for \"${runId}\" not found - cannot set variable on inactive run`,\n      };\n    }\n\n    runner.setVar(name, value);\n    return { ok: true };\n  }\n\n  // ==================== Event Handling ====================\n\n  private handleEvent(event: RunEvent): void {\n    const { runId } = event;\n    let session = this.sessions.get(runId);\n\n    // Track pause/resume state\n    if (event.type === 'run.paused') {\n      if (!session) {\n        session = {\n          runId,\n          attached: false,\n          lastKnownExecution: 'paused',\n        };\n        this.sessions.set(runId, session);\n      }\n      session.lastKnownExecution = 'paused';\n      session.lastPauseReason = event.reason;\n      session.lastKnownNodeId = event.nodeId;\n    } else if (event.type === 'run.resumed') {\n      if (session) {\n        session.lastKnownExecution = 'running';\n        session.lastPauseReason = undefined;\n      }\n    } else if (event.type === 'run.started') {\n      if (!session) {\n        session = {\n          runId,\n          attached: false,\n          lastKnownExecution: 'running',\n        };\n        this.sessions.set(runId, session);\n      }\n    } else if (\n      event.type === 'run.succeeded' ||\n      event.type === 'run.failed' ||\n      event.type === 'run.canceled'\n    ) {\n      // Run ended - keep session for querying but mark as not running\n      if (session) {\n        session.lastKnownExecution = 'running'; // Technically ended, but not paused\n      }\n    } else if (event.type === 'node.started') {\n      if (session) {\n        session.lastKnownNodeId = event.nodeId;\n      }\n    }\n\n    // Notify listeners if session is attached\n    if (session?.attached) {\n      void this.getState(runId).then((state) => {\n        this.notifyStateChange(runId, state);\n      });\n    }\n  }\n\n  // ==================== Helpers ====================\n\n  private async persistBreakpoints(runId: RunId, bpManager: BreakpointManager): Promise<void> {\n    const breakpoints = bpManager.getEnabled().map((bp) => bp.nodeId);\n    try {\n      await this.storage.runs.patch(runId, {\n        debug: { breakpoints },\n      });\n    } catch {\n      // Run may not exist yet - ignore persistence error\n    }\n  }\n\n  private async reconstructVar(runId: RunId, name: string): Promise<JsonValue | undefined> {\n    // Get flow and run to reconstruct initial vars\n    const run = await this.storage.runs.get(runId);\n    if (!run) return undefined;\n\n    const flow = await this.storage.flows.get(run.flowId);\n    if (!flow) return undefined;\n\n    // Build initial vars\n    const vars: Record<string, JsonValue> = { ...(run.args ?? {}) };\n    for (const def of flow.variables ?? []) {\n      if (vars[def.name] === undefined && def.default !== undefined) {\n        vars[def.name] = def.default;\n      }\n    }\n\n    // Apply all vars.patch events\n    const events = await this.storage.events.list(runId);\n    for (const event of events) {\n      if (event.type === 'vars.patch') {\n        for (const op of event.patch) {\n          if (op.op === 'set') {\n            vars[op.name] = op.value ?? null;\n          } else {\n            delete vars[op.name];\n          }\n        }\n      }\n    }\n\n    return vars[name];\n  }\n\n  private notifyStateChange(runId: RunId, state: DebuggerState): void {\n    // Notify specific run listeners\n    const runListeners = this.listeners.get(runId);\n    if (runListeners) {\n      for (const listener of runListeners) {\n        try {\n          listener(state);\n        } catch (e) {\n          console.error('[DebugController] Listener error:', e);\n        }\n      }\n    }\n\n    // Notify global listeners\n    const globalListeners = this.listeners.get(null);\n    if (globalListeners) {\n      for (const listener of globalListeners) {\n        try {\n          listener(state);\n        } catch (e) {\n          console.error('[DebugController] Listener error:', e);\n        }\n      }\n    }\n  }\n}\n\n/**\n * Create and start a debug controller\n */\nexport function createDebugController(config: DebugControllerConfig): DebugController {\n  const controller = new DebugController(config);\n  controller.start();\n  return controller;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/index.ts",
    "content": "/**\n * @fileoverview Kernel 模块导出入口\n */\n\nexport * from './kernel';\nexport * from './runner';\nexport * from './traversal';\nexport * from './breakpoints';\nexport * from './artifacts';\nexport * from './debug-controller';\nexport * from './recovery-kernel';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/kernel.ts",
    "content": "/**\n * @fileoverview ExecutionKernel 接口定义\n * @description 定义 Record-Replay V3 的核心执行引擎接口\n */\n\nimport type { JsonObject } from '../../domain/json';\nimport type { FlowId, NodeId, RunId } from '../../domain/ids';\nimport type { RRError } from '../../domain/errors';\nimport type { FlowV3 } from '../../domain/flow';\nimport type { DebuggerCommand, DebuggerState } from '../../domain/debug';\nimport type { RunEvent, RunStatus, Unsubscribe } from '../../domain/events';\n\n/**\n * Run 启动请求\n */\nexport interface RunStartRequest {\n  /** Run ID（由调用方生成） */\n  runId: RunId;\n  /** Flow ID */\n  flowId: FlowId;\n  /** Flow 快照（执行时使用的完整 Flow 定义） */\n  flowSnapshot: FlowV3;\n  /** 运行参数 */\n  args?: JsonObject;\n  /** 起始节点 ID（默认为 Flow 的 entryNodeId） */\n  startNodeId?: NodeId;\n  /** Tab ID（必须由调用方分配，每 Run 独占） */\n  tabId: number;\n  /** 调试配置 */\n  debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };\n}\n\n/**\n * Run 执行结果\n */\nexport interface RunResult {\n  /** Run ID */\n  runId: RunId;\n  /** 最终状态 */\n  status: Extract<RunStatus, 'succeeded' | 'failed' | 'canceled'>;\n  /** 总耗时（毫秒） */\n  tookMs: number;\n  /** 错误信息（如果失败） */\n  error?: RRError;\n  /** 输出结果 */\n  outputs?: JsonObject;\n}\n\n/**\n * Run 状态查询结果\n */\nexport interface RunStatusInfo {\n  /** 当前状态 */\n  status: RunStatus;\n  /** 当前节点 ID */\n  currentNodeId?: NodeId;\n  /** 开始时间 */\n  startedAt?: number;\n  /** 最后更新时间 */\n  updatedAt: number;\n  /** Tab ID */\n  tabId?: number;\n}\n\n/**\n * ExecutionKernel 接口\n * @description Record-Replay V3 的核心执行引擎\n */\nexport interface ExecutionKernel {\n  /**\n   * 订阅事件流\n   * @param listener 事件监听器\n   * @returns 取消订阅函数\n   */\n  onEvent(listener: (event: RunEvent) => void): Unsubscribe;\n\n  /**\n   * 启动 Run\n   * @description 将 Run 加入队列并开始执行\n   */\n  startRun(req: RunStartRequest): Promise<void>;\n\n  /**\n   * 暂停 Run\n   * @param runId Run ID\n   * @param reason 暂停原因\n   */\n  pauseRun(runId: RunId, reason?: { kind: 'command' }): Promise<void>;\n\n  /**\n   * 恢复 Run\n   * @param runId Run ID\n   */\n  resumeRun(runId: RunId): Promise<void>;\n\n  /**\n   * 取消 Run\n   * @param runId Run ID\n   * @param reason 取消原因\n   */\n  cancelRun(runId: RunId, reason?: string): Promise<void>;\n\n  /**\n   * 执行调试命令\n   * @param runId Run ID\n   * @param cmd 调试命令\n   */\n  debug(\n    runId: RunId,\n    cmd: DebuggerCommand,\n  ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }>;\n\n  /**\n   * 获取 Run 状态\n   * @param runId Run ID\n   * @returns Run 状态信息或 null（如果不存在）\n   */\n  getRunStatus(runId: RunId): Promise<RunStatusInfo | null>;\n\n  /**\n   * 恢复执行\n   * @description 在 Service Worker 重启后调用，恢复中断的 Run\n   */\n  recover(): Promise<void>;\n}\n\n/**\n * 创建 NotImplemented 的 ExecutionKernel\n * @description Phase 0 占位实现\n */\nexport function createNotImplementedKernel(): ExecutionKernel {\n  const notImplemented = () => {\n    throw new Error('ExecutionKernel not implemented');\n  };\n\n  return {\n    onEvent: () => {\n      notImplemented();\n      return () => {};\n    },\n    startRun: async () => notImplemented(),\n    pauseRun: async () => notImplemented(),\n    resumeRun: async () => notImplemented(),\n    cancelRun: async () => notImplemented(),\n    debug: async () => notImplemented(),\n    getRunStatus: async () => notImplemented(),\n    recover: async () => notImplemented(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/recovery-kernel.ts",
    "content": "/**\n * @fileoverview 支持崩溃恢复的 ExecutionKernel 实现 (P3-06)\n * @description\n * 提供 ExecutionKernel 的恢复增强实现，支持 `recover()` 方法。\n * 通过委托给 RecoveryCoordinator 实现崩溃恢复。\n *\n * 其他执行方法（startRun, pauseRun 等）暂未实现，将在后续阶段完成。\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { RunId } from '../../domain/ids';\nimport type { DebuggerCommand, DebuggerState } from '../../domain/debug';\n\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from '../transport/events-bus';\nimport { recoverFromCrash } from '../recovery/recovery-coordinator';\n\nimport type { ExecutionKernel, RunStartRequest, RunStatusInfo } from './kernel';\n\n// ==================== Types ====================\n\n/**\n * 支持恢复的 Kernel 依赖\n */\nexport interface RecoveryEnabledKernelDeps {\n  /** 存储层 */\n  storage: StoragePort;\n  /** 事件总线 */\n  events: EventsBus;\n  /** 当前 Service Worker 的 ownerId */\n  ownerId: string;\n  /** 时间源 */\n  now?: () => UnixMillis;\n  /** 日志器 */\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\n// ==================== Factory ====================\n\n/**\n * 创建支持恢复的 ExecutionKernel\n * @description\n * 此实现仅支持 `recover()` 和 `getRunStatus()` 方法。\n * 其他执行方法暂未实现，将在后续阶段完成。\n */\nexport function createRecoveryEnabledKernel(deps: RecoveryEnabledKernelDeps): ExecutionKernel {\n  const logger = deps.logger ?? console;\n  const now = deps.now ?? (() => Date.now());\n\n  if (!deps.ownerId) {\n    throw new Error('ownerId is required');\n  }\n\n  const notImplemented = (name: string): never => {\n    throw new Error(`ExecutionKernel.${name} not implemented`);\n  };\n\n  return {\n    onEvent: (listener) => deps.events.subscribe(listener),\n\n    startRun: async (_req: RunStartRequest) => notImplemented('startRun'),\n    pauseRun: async (_runId: RunId) => notImplemented('pauseRun'),\n    resumeRun: async (_runId: RunId) => notImplemented('resumeRun'),\n    cancelRun: async (_runId: RunId) => notImplemented('cancelRun'),\n\n    debug: async (\n      _runId: RunId,\n      _cmd: DebuggerCommand,\n    ): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }> => {\n      return { ok: false, error: 'ExecutionKernel.debug not configured' };\n    },\n\n    getRunStatus: async (runId: RunId): Promise<RunStatusInfo | null> => {\n      const run = await deps.storage.runs.get(runId);\n      if (!run) return null;\n      return {\n        status: run.status,\n        currentNodeId: run.currentNodeId,\n        startedAt: run.startedAt,\n        updatedAt: run.updatedAt,\n        tabId: run.tabId,\n      };\n    },\n\n    recover: async (): Promise<void> => {\n      logger.info('[RecoveryKernel] Starting crash recovery...');\n      const result = await recoverFromCrash({\n        storage: deps.storage,\n        events: deps.events,\n        ownerId: deps.ownerId,\n        now,\n        logger,\n      });\n      logger.info('[RecoveryKernel] Recovery complete:', result);\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/runner.ts",
    "content": "/**\n * @fileoverview RunRunner 接口和实现\n * @description 定义和实现单个 Run 的顺序执行器\n */\n\nimport type { NodeId, RunId } from '../../domain/ids';\nimport { EDGE_LABELS } from '../../domain/ids';\nimport type { FlowV3, NodeV3 } from '../../domain/flow';\nimport { findNodeById } from '../../domain/flow';\nimport type {\n  PauseReason,\n  RunEvent,\n  RunEventInput,\n  RunRecordV3,\n  Unsubscribe,\n} from '../../domain/events';\nimport { RUN_SCHEMA_VERSION } from '../../domain/events';\nimport type { JsonObject, JsonValue } from '../../domain/json';\nimport { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';\nimport type { NodePolicy, RetryPolicy } from '../../domain/policy';\nimport { mergeNodePolicy } from '../../domain/policy';\n\nimport type { EventsBus } from '../transport/events-bus';\nimport type { StoragePort } from '../storage/storage-port';\nimport type { PluginRegistry } from '../plugins/registry';\nimport { getPluginRegistry } from '../plugins/registry';\nimport type { NodeExecutionContext, NodeExecutionResult, VarsPatchOp } from '../plugins/types';\n\nimport type { ArtifactService } from './artifacts';\nimport { createNotImplementedArtifactService } from './artifacts';\nimport { getBreakpointRegistry, type BreakpointManager } from './breakpoints';\nimport { findEdgeByLabel, findNextNode, validateFlowDAG } from './traversal';\nimport type { RunResult } from './kernel';\n\n// ==================== Types ====================\n\n/**\n * RunRunner 运行时状态\n */\nexport interface RunnerRuntimeState {\n  /** Run ID */\n  runId: RunId;\n  /** 当前节点 ID */\n  currentNodeId: NodeId | null;\n  /** 当前尝试次数 */\n  attempt: number;\n  /** 变量表 */\n  vars: Record<string, JsonValue>;\n  /** 是否暂停 */\n  paused: boolean;\n  /** 是否取消 */\n  canceled: boolean;\n}\n\n/**\n * RunRunner 配置\n */\nexport interface RunnerConfig {\n  /** Flow 快照 */\n  flow: FlowV3;\n  /** Tab ID */\n  tabId: number;\n  /** 初始参数 */\n  args?: JsonObject;\n  /** 起始节点 ID */\n  startNodeId?: NodeId;\n  /** 调试配置 */\n  debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };\n}\n\n/**\n * RunRunner 接口\n */\nexport interface RunRunner {\n  /** Run ID */\n  readonly runId: RunId;\n  /** 当前状态 */\n  readonly state: RunnerRuntimeState;\n  /** 订阅事件 */\n  onEvent(listener: (event: RunEvent) => void): Unsubscribe;\n  /** 开始执行 */\n  start(): Promise<RunResult>;\n  /** 暂停执行 */\n  pause(): void;\n  /** 恢复执行 */\n  resume(): void;\n  /** 取消执行 */\n  cancel(reason?: string): void;\n  /** 获取变量值 */\n  getVar(name: string): JsonValue | undefined;\n  /** 设置变量值 */\n  setVar(name: string, value: JsonValue): void;\n}\n\n/**\n * RunRunner 工厂接口\n */\nexport interface RunRunnerFactory {\n  create(runId: RunId, config: RunnerConfig): RunRunner;\n}\n\n/**\n * RunRunner 工厂依赖\n */\nexport interface RunRunnerFactoryDeps {\n  storage: StoragePort;\n  events: EventsBus;\n  plugins?: PluginRegistry;\n  artifactService?: ArtifactService;\n  now?: () => number;\n}\n\n// ==================== Helpers ====================\n\ninterface Deferred<T> {\n  promise: Promise<T>;\n  resolve: (value: T) => void;\n  reject: (reason?: unknown) => void;\n}\n\nfunction createDeferred<T>(): Deferred<T> {\n  let resolve!: (value: T) => void;\n  let reject!: (reason?: unknown) => void;\n  const promise = new Promise<T>((res, rej) => {\n    resolve = res;\n    reject = rej;\n  });\n  return { promise, resolve, reject };\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction errorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message;\n  if (err && typeof err === 'object' && 'message' in err)\n    return String((err as { message: unknown }).message);\n  return String(err);\n}\n\nasync function withTimeout<T>(\n  p: Promise<T>,\n  ms: number | undefined,\n  onTimeout: () => RRError,\n): Promise<T> {\n  if (ms === undefined || !Number.isFinite(ms) || ms <= 0) {\n    return p;\n  }\n\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  try {\n    return await Promise.race([\n      p,\n      new Promise<T>((_resolve, reject) => {\n        timer = setTimeout(() => reject(onTimeout()), ms);\n      }),\n    ]);\n  } finally {\n    if (timer !== undefined) {\n      clearTimeout(timer);\n    }\n  }\n}\n\nfunction computeRetryDelayMs(policy: RetryPolicy, attempt: number): number {\n  const base = Math.max(0, policy.intervalMs);\n  let delay = base;\n  const backoff = policy.backoff ?? 'none';\n\n  if (backoff === 'linear') {\n    delay = base * attempt;\n  } else if (backoff === 'exp') {\n    delay = base * Math.pow(2, Math.max(0, attempt - 1));\n  }\n\n  if (policy.maxIntervalMs !== undefined) {\n    delay = Math.min(delay, Math.max(0, policy.maxIntervalMs));\n  }\n\n  if (policy.jitter === 'full') {\n    delay = Math.floor(Math.random() * (delay + 1));\n  }\n\n  return Math.max(0, Math.floor(delay));\n}\n\nfunction applyVarsPatch(vars: Record<string, JsonValue>, patch: VarsPatchOp[]): void {\n  for (const op of patch) {\n    if (op.op === 'set') {\n      vars[op.name] = op.value ?? null;\n    } else {\n      delete vars[op.name];\n    }\n  }\n}\n\nfunction toRRError(err: unknown, fallback: { code: string; message: string }): RRError {\n  if (err && typeof err === 'object' && 'code' in err && 'message' in err) {\n    return err as RRError;\n  }\n  return createRRError(\n    fallback.code as RRError['code'],\n    `${fallback.message}: ${errorMessage(err)}`,\n  );\n}\n\n/**\n * Serial queue for write operations\n * Ensures event ordering and reduces write races\n */\nclass SerialQueue {\n  private tail: Promise<void> = Promise.resolve();\n\n  run<T>(fn: () => Promise<T>): Promise<T> {\n    const next = this.tail.then(fn, fn);\n    this.tail = next.then(\n      () => undefined,\n      () => undefined,\n    );\n    return next;\n  }\n}\n\n// ==================== Factory ====================\n\n/**\n * 创建 NotImplemented 的 RunRunnerFactory\n */\nexport function createNotImplementedRunnerFactory(): RunRunnerFactory {\n  return {\n    create: () => {\n      throw new Error('RunRunnerFactory not implemented');\n    },\n  };\n}\n\n/**\n * 创建 RunRunner 工厂\n */\nexport function createRunRunnerFactory(deps: RunRunnerFactoryDeps): RunRunnerFactory {\n  const plugins = deps.plugins ?? getPluginRegistry();\n  const artifactService = deps.artifactService ?? createNotImplementedArtifactService();\n  const now = deps.now ?? Date.now;\n\n  return {\n    create: (runId, config) =>\n      new StorageBackedRunRunner(runId, config, {\n        storage: deps.storage,\n        events: deps.events,\n        plugins,\n        artifactService,\n        now,\n      }),\n  };\n}\n\n// ==================== Implementation ====================\n\ninterface RunnerEnv {\n  storage: StoragePort;\n  events: EventsBus;\n  plugins: PluginRegistry;\n  artifactService: ArtifactService;\n  now: () => number;\n}\n\ntype OnErrorDecision =\n  | { kind: 'stop' }\n  | { kind: 'continue' }\n  | {\n      kind: 'goto';\n      target: { kind: 'edgeLabel'; label: string } | { kind: 'node'; nodeId: NodeId };\n    }\n  | { kind: 'retry'; retryPolicy: RetryPolicy | null };\n\ntype NodeRunResult =\n  | { nextNodeId: NodeId | null }\n  | { terminal: 'failed'; error: RRError }\n  | { terminal: 'canceled' };\n\n/**\n * Storage-backed RunRunner implementation\n */\nclass StorageBackedRunRunner implements RunRunner {\n  readonly runId: RunId;\n  readonly state: RunnerRuntimeState;\n\n  private readonly config: RunnerConfig;\n  private readonly env: RunnerEnv;\n  private readonly queue = new SerialQueue();\n  private readonly breakpoints: BreakpointManager;\n\n  private startPromise: Promise<RunResult> | null = null;\n  private outputs: JsonObject = {};\n  private cancelReason: string | undefined;\n  private pauseWaiter: Deferred<void> | null = null;\n\n  constructor(runId: RunId, config: RunnerConfig, env: RunnerEnv) {\n    this.runId = runId;\n    this.config = config;\n    this.env = env;\n\n    this.state = {\n      runId,\n      currentNodeId: null,\n      attempt: 0,\n      vars: this.buildInitialVars(),\n      paused: false,\n      canceled: false,\n    };\n\n    this.breakpoints = getBreakpointRegistry().getOrCreate(runId, config.debug?.breakpoints);\n  }\n\n  onEvent(listener: (event: RunEvent) => void): Unsubscribe {\n    return this.env.events.subscribe(listener, { runId: this.runId });\n  }\n\n  start(): Promise<RunResult> {\n    if (!this.startPromise) {\n      this.startPromise = this.run();\n    }\n    return this.startPromise;\n  }\n\n  pause(): void {\n    this.requestPause({ kind: 'command' });\n  }\n\n  resume(): void {\n    if (!this.state.paused) return;\n    this.state.paused = false;\n    this.pauseWaiter?.resolve(undefined);\n    this.pauseWaiter = null;\n\n    void this.queue\n      .run(async () => {\n        await this.env.storage.runs.patch(this.runId, { status: 'running' });\n        await this.env.events.append({ runId: this.runId, type: 'run.resumed' } as RunEventInput);\n      })\n      .catch((e) => {\n        console.error('[RunRunner] resume persistence failed:', e);\n      });\n  }\n\n  cancel(reason?: string): void {\n    if (this.state.canceled) return;\n    this.state.canceled = true;\n    this.cancelReason = reason;\n\n    if (this.state.paused) {\n      this.state.paused = false;\n      this.pauseWaiter?.resolve(undefined);\n      this.pauseWaiter = null;\n    }\n  }\n\n  getVar(name: string): JsonValue | undefined {\n    return this.state.vars[name];\n  }\n\n  setVar(name: string, value: JsonValue): void {\n    this.state.vars[name] = value;\n\n    // Best-effort: emit vars.patch event\n    void this.queue\n      .run(() =>\n        this.env.events.append({\n          runId: this.runId,\n          type: 'vars.patch',\n          patch: [{ op: 'set', name, value }],\n        } as RunEventInput),\n      )\n      .catch(() => {});\n  }\n\n  // ==================== Private Methods ====================\n\n  private buildInitialVars(): Record<string, JsonValue> {\n    const vars: Record<string, JsonValue> = { ...(this.config.args ?? {}) };\n    for (const def of this.config.flow.variables ?? []) {\n      if (vars[def.name] === undefined && def.default !== undefined) {\n        vars[def.name] = def.default;\n      }\n    }\n    return vars;\n  }\n\n  private requestPause(reason: PauseReason): void {\n    if (this.state.canceled) return;\n    if (this.state.paused) return;\n\n    this.state.paused = true;\n    if (!this.pauseWaiter) {\n      this.pauseWaiter = createDeferred<void>();\n    }\n\n    const nodeId = this.state.currentNodeId ?? undefined;\n    void this.queue\n      .run(async () => {\n        await this.env.storage.runs.patch(this.runId, {\n          status: 'paused',\n          ...(nodeId ? { currentNodeId: nodeId } : {}),\n        });\n        await this.env.events.append({\n          runId: this.runId,\n          type: 'run.paused',\n          reason,\n          ...(nodeId ? { nodeId } : {}),\n        } as RunEventInput);\n      })\n      .catch((e) => {\n        console.error('[RunRunner] pause persistence failed:', e);\n      });\n  }\n\n  private async waitIfPaused(): Promise<void> {\n    while (this.state.paused && !this.state.canceled) {\n      if (!this.pauseWaiter) {\n        this.pauseWaiter = createDeferred<void>();\n      }\n      await this.pauseWaiter.promise;\n    }\n  }\n\n  private async ensureRunRecord(startNodeId: NodeId, startedAt: number): Promise<void> {\n    await this.queue.run(async () => {\n      const existing = await this.env.storage.runs.get(this.runId);\n      if (!existing) {\n        const record: RunRecordV3 = {\n          schemaVersion: RUN_SCHEMA_VERSION,\n          id: this.runId,\n          flowId: this.config.flow.id,\n          status: 'running',\n          createdAt: startedAt,\n          updatedAt: startedAt,\n          startedAt,\n          tabId: this.config.tabId,\n          startNodeId: this.config.startNodeId,\n          currentNodeId: startNodeId,\n          attempt: 0,\n          maxAttempts: 1,\n          args: this.config.args,\n          debug: this.config.debug,\n          nextSeq: 1,\n        };\n        await this.env.storage.runs.save(record);\n        return;\n      }\n\n      if (!Number.isSafeInteger(existing.nextSeq) || existing.nextSeq < 0) {\n        throw createRRError(\n          RR_ERROR_CODES.INVARIANT_VIOLATION,\n          `Invalid nextSeq for run \"${this.runId}\": ${String(existing.nextSeq)}`,\n        );\n      }\n\n      const patch: Partial<RunRecordV3> = {\n        status: 'running',\n        tabId: this.config.tabId,\n        currentNodeId: startNodeId,\n      };\n      if (existing.startedAt === undefined) patch.startedAt = startedAt;\n      if (this.config.startNodeId !== undefined) patch.startNodeId = this.config.startNodeId;\n      if (this.config.args !== undefined) patch.args = this.config.args;\n      if (this.config.debug !== undefined) patch.debug = this.config.debug;\n      await this.env.storage.runs.patch(this.runId, patch);\n    });\n  }\n\n  private async run(): Promise<RunResult> {\n    const startedAt = this.env.now();\n    const { flow } = this.config;\n\n    const startNodeId = (this.config.startNodeId ?? flow.entryNodeId) as NodeId;\n\n    // Ensure Run record exists FIRST (before DAG validation)\n    // so that finishFailed can safely patch the record\n    await this.ensureRunRecord(startNodeId, startedAt);\n\n    // Validate DAG\n    const validation = validateFlowDAG(flow);\n    if (!validation.ok) {\n      const error =\n        validation.errors[0] ?? createRRError(RR_ERROR_CODES.DAG_INVALID, 'Invalid DAG');\n      return this.finishFailed(startedAt, error, undefined);\n    }\n\n    if (this.state.canceled) {\n      return this.finishCanceled(startedAt);\n    }\n\n    // Emit run.started\n    await this.queue.run(() =>\n      this.env.events.append({\n        runId: this.runId,\n        type: 'run.started',\n        flowId: flow.id,\n        tabId: this.config.tabId,\n      } as RunEventInput),\n    );\n\n    // Handle pauseOnStart\n    if (this.config.debug?.pauseOnStart) {\n      this.requestPause({ kind: 'policy', nodeId: startNodeId, reason: 'pauseOnStart' });\n    }\n\n    // Main execution loop\n    let currentNodeId: NodeId | null = startNodeId;\n    while (currentNodeId) {\n      this.state.currentNodeId = currentNodeId;\n\n      // Only update currentNodeId, not status (to preserve paused state)\n      const nodeIdToUpdate = currentNodeId; // Capture for closure\n      await this.queue.run(() =>\n        this.env.storage.runs.patch(this.runId, { currentNodeId: nodeIdToUpdate }),\n      );\n\n      if (this.state.canceled) break;\n      await this.waitIfPaused();\n      if (this.state.canceled) break;\n\n      const node = findNodeById(flow, currentNodeId);\n      if (!node) {\n        const error = createRRError(\n          RR_ERROR_CODES.DAG_INVALID,\n          `Node \"${currentNodeId}\" not found in flow`,\n        );\n        return this.finishFailed(startedAt, error, currentNodeId);\n      }\n\n      // Skip disabled nodes\n      if (node.disabled) {\n        await this.queue.run(() =>\n          this.env.events.append({\n            runId: this.runId,\n            type: 'node.skipped',\n            nodeId: node.id,\n            reason: 'disabled',\n          } as RunEventInput),\n        );\n        currentNodeId = findNextNode(flow, node.id);\n        continue;\n      }\n\n      // Check breakpoints\n      if (this.breakpoints.shouldPauseAt(node.id)) {\n        const reason: PauseReason =\n          this.breakpoints.getStepMode() === 'stepOver'\n            ? { kind: 'step', nodeId: node.id }\n            : { kind: 'breakpoint', nodeId: node.id };\n\n        // Clear step mode after hitting (to avoid infinite pause loop)\n        if (this.breakpoints.getStepMode() === 'stepOver') {\n          this.breakpoints.setStepMode('none');\n        }\n\n        this.requestPause(reason);\n        await this.waitIfPaused();\n        // After resume, proceed to execute the node (don't continue loop)\n      }\n\n      // Emit node.queued\n      await this.queue.run(() =>\n        this.env.events.append({\n          runId: this.runId,\n          type: 'node.queued',\n          nodeId: node.id,\n        } as RunEventInput),\n      );\n\n      // Execute node\n      const nodeStartAt = this.env.now();\n      const next = await this.runNode(flow, node, nodeStartAt);\n      if ('terminal' in next) {\n        if (next.terminal === 'canceled') break;\n        if (next.terminal === 'failed') {\n          return this.finishFailed(startedAt, next.error, node.id);\n        }\n        break;\n      }\n\n      currentNodeId = next.nextNodeId;\n    }\n\n    if (this.state.canceled) {\n      return this.finishCanceled(startedAt);\n    }\n\n    return this.finishSucceeded(startedAt);\n  }\n\n  private async runNode(flow: FlowV3, node: NodeV3, nodeStartAt: number): Promise<NodeRunResult> {\n    let attempt = 1;\n\n    for (;;) {\n      if (this.state.canceled) return { terminal: 'canceled' };\n      await this.waitIfPaused();\n      if (this.state.canceled) return { terminal: 'canceled' };\n\n      this.state.attempt = attempt;\n\n      // Emit node.started\n      await this.queue.run(() =>\n        this.env.events.append({\n          runId: this.runId,\n          type: 'node.started',\n          nodeId: node.id,\n          attempt,\n        } as RunEventInput),\n      );\n\n      const exec = await this.executeNodeAttempt(flow, node);\n      if (exec.status === 'succeeded') {\n        const tookMs = this.env.now() - nodeStartAt;\n\n        // Apply vars patch\n        if (exec.varsPatch && exec.varsPatch.length > 0) {\n          applyVarsPatch(this.state.vars, exec.varsPatch);\n          await this.queue.run(() =>\n            this.env.events.append({\n              runId: this.runId,\n              type: 'vars.patch',\n              patch: exec.varsPatch,\n            } as RunEventInput),\n          );\n        }\n\n        // Merge outputs\n        if (exec.outputs) {\n          this.outputs = { ...this.outputs, ...exec.outputs };\n        }\n\n        // Emit node.succeeded\n        await this.queue.run(() =>\n          this.env.events.append({\n            runId: this.runId,\n            type: 'node.succeeded',\n            nodeId: node.id,\n            tookMs,\n            ...(exec.next ? { next: exec.next } : {}),\n          } as RunEventInput),\n        );\n\n        if (exec.next?.kind === 'end') {\n          return { nextNodeId: null };\n        }\n\n        const label = exec.next?.kind === 'edgeLabel' ? exec.next.label : undefined;\n        return { nextNodeId: findNextNode(flow, node.id, label) };\n      }\n\n      // Handle failure\n      const error = exec.error;\n      const policy = this.resolveNodePolicy(flow, node);\n      const decision = this.decideOnError(flow, node, policy, error);\n\n      // Emit node.failed\n      await this.queue.run(() =>\n        this.env.events.append({\n          runId: this.runId,\n          type: 'node.failed',\n          nodeId: node.id,\n          attempt,\n          error,\n          decision: decision.kind,\n        } as RunEventInput),\n      );\n\n      if (decision.kind === 'retry' && decision.retryPolicy) {\n        const maxAttempts = 1 + Math.max(0, decision.retryPolicy.retries);\n        const canRetry =\n          attempt < maxAttempts &&\n          (decision.retryPolicy.retryOn\n            ? decision.retryPolicy.retryOn.includes(\n                error.code as (typeof decision.retryPolicy.retryOn)[number],\n              )\n            : true);\n\n        if (!canRetry) {\n          return { terminal: 'failed', error };\n        }\n\n        const delay = computeRetryDelayMs(decision.retryPolicy, attempt);\n        if (delay > 0) {\n          await sleep(delay);\n        }\n        attempt++;\n        continue;\n      }\n\n      if (decision.kind === 'continue') {\n        return { nextNodeId: findNextNode(flow, node.id) };\n      }\n\n      if (decision.kind === 'goto') {\n        if (decision.target.kind === 'node') {\n          return { nextNodeId: decision.target.nodeId };\n        }\n        return { nextNodeId: findNextNode(flow, node.id, decision.target.label) };\n      }\n\n      return { terminal: 'failed', error };\n    }\n  }\n\n  private resolveNodePolicy(flow: FlowV3, node: NodeV3): NodePolicy {\n    const def = this.env.plugins.getNode(node.kind);\n    const flowDefault = flow.policy?.defaultNodePolicy;\n    const pluginDefault = def?.defaultPolicy;\n    const merged1 = mergeNodePolicy(flowDefault, pluginDefault);\n    return mergeNodePolicy(merged1, node.policy);\n  }\n\n  private decideOnError(\n    flow: FlowV3,\n    node: NodeV3,\n    policy: NodePolicy,\n    _error: RRError,\n  ): OnErrorDecision {\n    const configured = policy.onError;\n\n    // Default: if there's an ON_ERROR edge, use it\n    if (!configured) {\n      const onErrorEdge = findEdgeByLabel(flow, node.id, EDGE_LABELS.ON_ERROR);\n      if (onErrorEdge) {\n        return { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } };\n      }\n      return { kind: 'stop' };\n    }\n\n    if (configured.kind === 'stop') return { kind: 'stop' };\n    if (configured.kind === 'continue') return { kind: 'continue' };\n    if (configured.kind === 'goto') {\n      return {\n        kind: 'goto',\n        target: configured.target as\n          | { kind: 'edgeLabel'; label: string }\n          | { kind: 'node'; nodeId: NodeId },\n      };\n    }\n\n    // retry\n    const base: RetryPolicy = policy.retry ?? { retries: 1, intervalMs: 0 };\n    const retryPolicy: RetryPolicy = configured.override\n      ? { ...base, ...configured.override }\n      : base;\n    return { kind: 'retry', retryPolicy };\n  }\n\n  private async executeNodeAttempt(flow: FlowV3, node: NodeV3): Promise<NodeExecutionResult> {\n    const def = this.env.plugins.getNode(node.kind);\n    if (!def) {\n      return {\n        status: 'failed',\n        error: createRRError(\n          RR_ERROR_CODES.UNSUPPORTED_NODE,\n          `Node kind \"${node.kind}\" is not registered`,\n        ),\n      };\n    }\n\n    let parsedConfig: unknown = node.config;\n    try {\n      parsedConfig = def.schema.parse(node.config);\n    } catch (e) {\n      return {\n        status: 'failed',\n        error: createRRError(\n          RR_ERROR_CODES.VALIDATION_ERROR,\n          `Invalid node config: ${errorMessage(e)}`,\n        ),\n      };\n    }\n\n    const ctx: NodeExecutionContext = {\n      runId: this.runId,\n      flow,\n      nodeId: node.id,\n      tabId: this.config.tabId,\n      vars: this.state.vars,\n      log: (level, message, data) => {\n        void this.queue\n          .run(() =>\n            this.env.events.append({\n              runId: this.runId,\n              type: 'log',\n              level,\n              message,\n              ...(data !== undefined ? { data } : {}),\n            } as RunEventInput),\n          )\n          .catch(() => {});\n      },\n      chooseNext: (label) => ({ kind: 'edgeLabel', label }),\n      artifacts: {\n        screenshot: () => this.env.artifactService.screenshot(this.config.tabId),\n      },\n      persistent: {\n        get: async (name) => (await this.env.storage.persistentVars.get(name))?.value,\n        set: async (name, value) => {\n          await this.env.storage.persistentVars.set(name, value);\n        },\n        delete: async (name) => {\n          await this.env.storage.persistentVars.delete(name);\n        },\n      },\n    };\n\n    const policy = this.resolveNodePolicy(flow, node);\n    const timeoutMs = policy.timeout?.ms;\n    const scope = policy.timeout?.scope ?? 'attempt';\n    const attemptTimeoutMs = scope === 'attempt' && timeoutMs !== undefined ? timeoutMs : undefined;\n\n    try {\n      const nodeWithConfig = { ...node, config: parsedConfig } as Parameters<typeof def.execute>[1];\n      const execPromise = def.execute(ctx, nodeWithConfig);\n      const result = await withTimeout(execPromise, attemptTimeoutMs, () =>\n        createRRError(RR_ERROR_CODES.TIMEOUT, `Node \"${node.id}\" timed out`),\n      );\n      return result;\n    } catch (e) {\n      return {\n        status: 'failed',\n        error: toRRError(e, { code: RR_ERROR_CODES.INTERNAL, message: 'Node execution threw' }),\n      };\n    }\n  }\n\n  private async finishSucceeded(startedAt: number): Promise<RunResult> {\n    const tookMs = this.env.now() - startedAt;\n    await this.queue.run(async () => {\n      await this.env.storage.runs.patch(this.runId, {\n        status: 'succeeded',\n        finishedAt: this.env.now(),\n        tookMs,\n        outputs: this.outputs,\n      });\n      await this.env.events.append({\n        runId: this.runId,\n        type: 'run.succeeded',\n        tookMs,\n        outputs: this.outputs,\n      } as RunEventInput);\n    });\n\n    return { runId: this.runId, status: 'succeeded', tookMs, outputs: this.outputs };\n  }\n\n  private async finishFailed(\n    startedAt: number,\n    error: RRError,\n    nodeId?: NodeId,\n  ): Promise<RunResult> {\n    const tookMs = this.env.now() - startedAt;\n    await this.queue.run(async () => {\n      await this.env.storage.runs.patch(this.runId, {\n        status: 'failed',\n        finishedAt: this.env.now(),\n        tookMs,\n        error,\n        ...(nodeId ? { currentNodeId: nodeId } : {}),\n      });\n      await this.env.events.append({\n        runId: this.runId,\n        type: 'run.failed',\n        error,\n        ...(nodeId ? { nodeId } : {}),\n      } as RunEventInput);\n    });\n\n    return { runId: this.runId, status: 'failed', tookMs, error };\n  }\n\n  private async finishCanceled(startedAt: number): Promise<RunResult> {\n    const tookMs = this.env.now() - startedAt;\n    await this.queue.run(async () => {\n      await this.env.storage.runs.patch(this.runId, {\n        status: 'canceled',\n        finishedAt: this.env.now(),\n        tookMs,\n      });\n      await this.env.events.append({\n        runId: this.runId,\n        type: 'run.canceled',\n        ...(this.cancelReason ? { reason: this.cancelReason } : {}),\n      } as RunEventInput);\n    });\n\n    return { runId: this.runId, status: 'canceled', tookMs };\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/kernel/traversal.ts",
    "content": "/**\n * @fileoverview DAG 遍历和校验\n * @description 提供 Flow DAG 的校验、遍历和下一节点查找功能\n */\n\nimport type { NodeId, EdgeLabel } from '../../domain/ids';\nimport type { FlowV3, EdgeV3 } from '../../domain/flow';\nimport { EDGE_LABELS } from '../../domain/ids';\nimport { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';\n\n/**\n * DAG 校验结果\n */\nexport type ValidateFlowDAGResult = { ok: true } | { ok: false; errors: RRError[] };\n\n/**\n * 校验 Flow DAG 结构\n * @param flow Flow 定义\n * @returns 校验结果\n */\nexport function validateFlowDAG(flow: FlowV3): ValidateFlowDAGResult {\n  const errors: RRError[] = [];\n  const nodeIds = new Set(flow.nodes.map((n) => n.id));\n\n  // 检查 entryNodeId 是否存在\n  if (!nodeIds.has(flow.entryNodeId)) {\n    errors.push(\n      createRRError(\n        RR_ERROR_CODES.DAG_INVALID,\n        `Entry node \"${flow.entryNodeId}\" does not exist in flow`,\n      ),\n    );\n  }\n\n  // 检查边引用的节点是否存在\n  for (const edge of flow.edges) {\n    if (!nodeIds.has(edge.from)) {\n      errors.push(\n        createRRError(\n          RR_ERROR_CODES.DAG_INVALID,\n          `Edge \"${edge.id}\" references non-existent source node \"${edge.from}\"`,\n        ),\n      );\n    }\n    if (!nodeIds.has(edge.to)) {\n      errors.push(\n        createRRError(\n          RR_ERROR_CODES.DAG_INVALID,\n          `Edge \"${edge.id}\" references non-existent target node \"${edge.to}\"`,\n        ),\n      );\n    }\n  }\n\n  // 检查循环\n  const cycle = detectCycle(flow);\n  if (cycle) {\n    errors.push(\n      createRRError(RR_ERROR_CODES.DAG_CYCLE, `Cycle detected in flow: ${cycle.join(' -> ')}`),\n    );\n  }\n\n  return errors.length > 0 ? { ok: false, errors } : { ok: true };\n}\n\n/**\n * 检测 DAG 中的循环\n * @param flow Flow 定义\n * @returns 循环路径（如果存在）或 null\n */\nexport function detectCycle(flow: FlowV3): NodeId[] | null {\n  const adjacency = buildAdjacencyMap(flow);\n  const visited = new Set<NodeId>();\n  const recursionStack = new Set<NodeId>();\n  const path: NodeId[] = [];\n\n  function dfs(nodeId: NodeId): boolean {\n    visited.add(nodeId);\n    recursionStack.add(nodeId);\n    path.push(nodeId);\n\n    const neighbors = adjacency.get(nodeId) || [];\n    for (const neighbor of neighbors) {\n      if (!visited.has(neighbor)) {\n        if (dfs(neighbor)) {\n          return true;\n        }\n      } else if (recursionStack.has(neighbor)) {\n        // 找到循环\n        const cycleStart = path.indexOf(neighbor);\n        path.push(neighbor); // 闭合循环\n        path.splice(0, cycleStart); // 移除循环前的节点\n        return true;\n      }\n    }\n\n    path.pop();\n    recursionStack.delete(nodeId);\n    return false;\n  }\n\n  for (const node of flow.nodes) {\n    if (!visited.has(node.id)) {\n      if (dfs(node.id)) {\n        return path;\n      }\n    }\n  }\n\n  return null;\n}\n\n/**\n * 查找下一个节点\n * @param flow Flow 定义\n * @param currentNodeId 当前节点 ID\n * @param label 边标签（可选，默认使用 default）\n * @returns 下一个节点 ID 或 null（如果没有后续节点）\n */\nexport function findNextNode(\n  flow: FlowV3,\n  currentNodeId: NodeId,\n  label?: EdgeLabel,\n): NodeId | null {\n  const outEdges = flow.edges.filter((e) => e.from === currentNodeId);\n\n  if (outEdges.length === 0) {\n    return null;\n  }\n\n  // 如果指定了 label，优先匹配\n  if (label) {\n    const matchedEdge = outEdges.find((e) => e.label === label);\n    if (matchedEdge) {\n      return matchedEdge.to;\n    }\n  }\n\n  // 否则使用 default 边\n  const defaultEdge = outEdges.find(\n    (e) => e.label === EDGE_LABELS.DEFAULT || e.label === undefined,\n  );\n  if (defaultEdge) {\n    return defaultEdge.to;\n  }\n\n  // 如果只有一条边，使用它\n  if (outEdges.length === 1) {\n    return outEdges[0].to;\n  }\n\n  return null;\n}\n\n/**\n * 查找指定标签的边\n */\nexport function findEdgeByLabel(\n  flow: FlowV3,\n  fromNodeId: NodeId,\n  label: EdgeLabel,\n): EdgeV3 | undefined {\n  return flow.edges.find((e) => e.from === fromNodeId && e.label === label);\n}\n\n/**\n * 获取节点的所有出边\n */\nexport function getOutEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {\n  return flow.edges.filter((e) => e.from === nodeId);\n}\n\n/**\n * 获取节点的所有入边\n */\nexport function getInEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {\n  return flow.edges.filter((e) => e.to === nodeId);\n}\n\n/**\n * 构建邻接表\n */\nfunction buildAdjacencyMap(flow: FlowV3): Map<NodeId, NodeId[]> {\n  const map = new Map<NodeId, NodeId[]>();\n\n  for (const node of flow.nodes) {\n    map.set(node.id, []);\n  }\n\n  for (const edge of flow.edges) {\n    const neighbors = map.get(edge.from);\n    if (neighbors) {\n      neighbors.push(edge.to);\n    }\n  }\n\n  return map;\n}\n\n/**\n * 获取从入口节点可达的所有节点\n */\nexport function getReachableNodes(flow: FlowV3): Set<NodeId> {\n  const reachable = new Set<NodeId>();\n  const adjacency = buildAdjacencyMap(flow);\n\n  function dfs(nodeId: NodeId): void {\n    if (reachable.has(nodeId)) return;\n    reachable.add(nodeId);\n\n    const neighbors = adjacency.get(nodeId) || [];\n    for (const neighbor of neighbors) {\n      dfs(neighbor);\n    }\n  }\n\n  dfs(flow.entryNodeId);\n  return reachable;\n}\n\n/**\n * 检查节点是否可达\n */\nexport function isNodeReachable(flow: FlowV3, nodeId: NodeId): boolean {\n  return getReachableNodes(flow).has(nodeId);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/index.ts",
    "content": "/**\n * @fileoverview 插件系统导出入口\n */\n\nexport * from './types';\nexport * from './registry';\nexport * from './v2-action-adapter';\nexport * from './register-v2-replay-nodes';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/register-v2-replay-nodes.ts",
    "content": "/**\n * @fileoverview Register RR-V2 replay action handlers as RR-V3 nodes\n * @description\n * Batch registration of V2 action handlers into the V3 PluginRegistry.\n * This enables V3 to execute flows that use V2 action types.\n */\n\nimport { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers';\nimport type {\n  ActionHandler,\n  ExecutableActionType,\n} from '@/entrypoints/background/record-replay/actions/types';\n\nimport type { PluginRegistry } from './registry';\nimport {\n  adaptV2ActionHandlerToV3NodeDefinition,\n  type V2ActionNodeAdapterOptions,\n} from './v2-action-adapter';\n\nexport interface RegisterV2ReplayNodesOptions extends V2ActionNodeAdapterOptions {\n  /**\n   * Only include these action types. If not specified, all V2 handlers are included.\n   */\n  include?: ReadonlyArray<string>;\n\n  /**\n   * Exclude these action types. Applied after include filter.\n   */\n  exclude?: ReadonlyArray<string>;\n}\n\n/**\n * Register V2 replay action handlers as V3 node definitions.\n *\n * @param registry The V3 PluginRegistry to register nodes into\n * @param options Configuration options\n * @returns Array of registered node kinds\n *\n * @example\n * ```ts\n * const plugins = new PluginRegistry();\n * const registered = registerV2ReplayNodesAsV3Nodes(plugins, {\n *   // Exclude control flow handlers that V3 runner doesn't support\n *   exclude: ['foreach', 'while'],\n * });\n * console.log('Registered:', registered);\n * ```\n */\nexport function registerV2ReplayNodesAsV3Nodes(\n  registry: PluginRegistry,\n  options: RegisterV2ReplayNodesOptions = {},\n): string[] {\n  const actionRegistry = createReplayActionRegistry();\n  const handlers = actionRegistry.list();\n\n  const include = options.include ? new Set(options.include) : null;\n  const exclude = options.exclude ? new Set(options.exclude) : null;\n\n  const registered: string[] = [];\n\n  for (const handler of handlers) {\n    if (include && !include.has(handler.type)) continue;\n    if (exclude && exclude.has(handler.type)) continue;\n\n    // Cast needed because V2 handler types don't perfectly align with V3 NodeKind\n    const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(\n      handler as ActionHandler<ExecutableActionType>,\n      options,\n    );\n    registry.registerNode(nodeDef as unknown as Parameters<typeof registry.registerNode>[0]);\n    registered.push(handler.type);\n  }\n\n  return registered;\n}\n\n/**\n * Get list of V2 action types that can be registered.\n * Useful for debugging and documentation.\n */\nexport function listV2ActionTypes(): string[] {\n  const actionRegistry = createReplayActionRegistry();\n  return actionRegistry.list().map((h) => h.type);\n}\n\n/**\n * Default exclude list for V3 registration.\n * These handlers rely on V2 control directives that V3 runner doesn't support.\n */\nexport const DEFAULT_V2_EXCLUDE_LIST = ['foreach', 'while'] as const;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/registry.ts",
    "content": "/**\n * @fileoverview 插件注册表\n * @description 管理节点和触发器插件的注册和查询\n */\n\nimport type { NodeKind } from '../../domain/flow';\nimport type { TriggerKind } from '../../domain/triggers';\nimport { RR_ERROR_CODES, createRRError } from '../../domain/errors';\nimport type {\n  NodeDefinition,\n  TriggerDefinition,\n  PluginRegistrationContext,\n  RRPlugin,\n} from './types';\n\n/**\n * 插件注册表\n * @description 单例模式，管理所有已注册的节点和触发器\n */\nexport class PluginRegistry implements PluginRegistrationContext {\n  private nodes = new Map<NodeKind, NodeDefinition>();\n  private triggers = new Map<TriggerKind, TriggerDefinition>();\n\n  /**\n   * 注册节点定义\n   * @description 如果已存在同名节点，会覆盖\n   */\n  registerNode(def: NodeDefinition): void {\n    this.nodes.set(def.kind, def);\n  }\n\n  /**\n   * 注册触发器定义\n   * @description 如果已存在同名触发器，会覆盖\n   */\n  registerTrigger(def: TriggerDefinition): void {\n    this.triggers.set(def.kind, def);\n  }\n\n  /**\n   * 获取节点定义\n   * @returns 节点定义或 undefined\n   */\n  getNode(kind: NodeKind): NodeDefinition | undefined {\n    return this.nodes.get(kind);\n  }\n\n  /**\n   * 获取节点定义（必须存在）\n   * @throws RRError 如果节点未注册\n   */\n  getNodeOrThrow(kind: NodeKind): NodeDefinition {\n    const def = this.nodes.get(kind);\n    if (!def) {\n      throw createRRError(RR_ERROR_CODES.UNSUPPORTED_NODE, `Node kind \"${kind}\" is not registered`);\n    }\n    return def;\n  }\n\n  /**\n   * 获取触发器定义\n   * @returns 触发器定义或 undefined\n   */\n  getTrigger(kind: TriggerKind): TriggerDefinition | undefined {\n    return this.triggers.get(kind);\n  }\n\n  /**\n   * 获取触发器定义（必须存在）\n   * @throws RRError 如果触发器未注册\n   */\n  getTriggerOrThrow(kind: TriggerKind): TriggerDefinition {\n    const def = this.triggers.get(kind);\n    if (!def) {\n      throw createRRError(\n        RR_ERROR_CODES.UNSUPPORTED_NODE,\n        `Trigger kind \"${kind}\" is not registered`,\n      );\n    }\n    return def;\n  }\n\n  /**\n   * 检查节点是否已注册\n   */\n  hasNode(kind: NodeKind): boolean {\n    return this.nodes.has(kind);\n  }\n\n  /**\n   * 检查触发器是否已注册\n   */\n  hasTrigger(kind: TriggerKind): boolean {\n    return this.triggers.has(kind);\n  }\n\n  /**\n   * 获取所有已注册的节点类型\n   */\n  listNodeKinds(): NodeKind[] {\n    return Array.from(this.nodes.keys());\n  }\n\n  /**\n   * 获取所有已注册的触发器类型\n   */\n  listTriggerKinds(): TriggerKind[] {\n    return Array.from(this.triggers.keys());\n  }\n\n  /**\n   * 注册插件\n   * @description 调用插件的 register 方法\n   */\n  registerPlugin(plugin: RRPlugin): void {\n    plugin.register(this);\n  }\n\n  /**\n   * 批量注册插件\n   */\n  registerPlugins(plugins: RRPlugin[]): void {\n    for (const plugin of plugins) {\n      this.registerPlugin(plugin);\n    }\n  }\n\n  /**\n   * 清空所有注册\n   * @description 主要用于测试\n   */\n  clear(): void {\n    this.nodes.clear();\n    this.triggers.clear();\n  }\n}\n\n/** 全局插件注册表实例 */\nlet globalRegistry: PluginRegistry | null = null;\n\n/**\n * 获取全局插件注册表\n */\nexport function getPluginRegistry(): PluginRegistry {\n  if (!globalRegistry) {\n    globalRegistry = new PluginRegistry();\n  }\n  return globalRegistry;\n}\n\n/**\n * 重置全局插件注册表\n * @description 主要用于测试\n */\nexport function resetPluginRegistry(): void {\n  globalRegistry = null;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/types.ts",
    "content": "/**\n * @fileoverview 插件类型定义\n * @description 定义 Record-Replay V3 中的节点和触发器插件接口\n */\n\nimport { z } from 'zod';\n\nimport type { JsonObject, JsonValue } from '../../domain/json';\nimport type { FlowId, NodeId, RunId, TriggerId } from '../../domain/ids';\nimport type { NodeKind } from '../../domain/flow';\nimport type { RRError } from '../../domain/errors';\nimport type { NodePolicy } from '../../domain/policy';\nimport type { FlowV3, NodeV3 } from '../../domain/flow';\nimport type { TriggerKind } from '../../domain/triggers';\n\n/**\n * Schema 类型\n * @description 使用 Zod 进行配置校验\n */\nexport type Schema<T> = z.ZodType<T, z.ZodTypeDef, unknown>;\n\n/**\n * 节点执行上下文\n * @description 提供给节点执行器的运行时上下文\n */\nexport interface NodeExecutionContext {\n  /** Run ID */\n  runId: RunId;\n  /** Flow 定义（快照） */\n  flow: FlowV3;\n  /** 当前节点 ID */\n  nodeId: NodeId;\n\n  /** 绑定的 Tab ID（每 Run 独占） */\n  tabId: number;\n  /** Frame ID（默认 0 为主框架） */\n  frameId?: number;\n\n  /** 当前变量表 */\n  vars: Record<string, JsonValue>;\n\n  /**\n   * 日志记录\n   */\n  log: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: JsonValue) => void;\n\n  /**\n   * 选择下一个边\n   * @description 用于条件分支节点\n   */\n  chooseNext: (label: string) => { kind: 'edgeLabel'; label: string };\n\n  /**\n   * 工件操作\n   */\n  artifacts: {\n    /** 截取当前页面截图 */\n    screenshot: () => Promise<{ ok: true; base64: string } | { ok: false; error: RRError }>;\n  };\n\n  /**\n   * 持久化变量操作\n   */\n  persistent: {\n    /** 获取持久化变量 */\n    get: (name: `$${string}`) => Promise<JsonValue | undefined>;\n    /** 设置持久化变量 */\n    set: (name: `$${string}`, value: JsonValue) => Promise<void>;\n    /** 删除持久化变量 */\n    delete: (name: `$${string}`) => Promise<void>;\n  };\n}\n\n/**\n * 变量补丁操作\n */\nexport interface VarsPatchOp {\n  op: 'set' | 'delete';\n  name: string;\n  value?: JsonValue;\n}\n\n/**\n * 节点执行结果\n */\nexport type NodeExecutionResult =\n  | {\n      status: 'succeeded';\n      /** 下一步执行方向 */\n      next?: { kind: 'edgeLabel'; label: string } | { kind: 'end' };\n      /** 输出结果 */\n      outputs?: JsonObject;\n      /** 变量修改 */\n      varsPatch?: VarsPatchOp[];\n    }\n  | { status: 'failed'; error: RRError };\n\n/**\n * 节点定义\n * @description 定义一种节点类型的执行逻辑\n */\nexport interface NodeDefinition<\n  TKind extends NodeKind = NodeKind,\n  TConfig extends JsonObject = JsonObject,\n> {\n  /** 节点类型标识 */\n  kind: TKind;\n  /** 配置校验 Schema */\n  schema: Schema<TConfig>;\n  /** 默认策略 */\n  defaultPolicy?: NodePolicy;\n  /**\n   * 执行节点\n   * @param ctx 执行上下文\n   * @param node 节点定义（含配置）\n   */\n  execute(\n    ctx: NodeExecutionContext,\n    node: NodeV3 & { kind: TKind; config: TConfig },\n  ): Promise<NodeExecutionResult>;\n}\n\n/**\n * 触发器安装上下文\n */\nexport interface TriggerInstallContext<\n  TKind extends TriggerKind = TriggerKind,\n  TConfig extends JsonObject = JsonObject,\n> {\n  /** 触发器 ID */\n  triggerId: TriggerId;\n  /** 触发器类型 */\n  kind: TKind;\n  /** 是否启用 */\n  enabled: boolean;\n  /** 关联的 Flow ID */\n  flowId: FlowId;\n  /** 触发器配置 */\n  config: TConfig;\n  /** 传递给 Flow 的参数 */\n  args?: JsonObject;\n}\n\n/**\n * 触发器定义\n * @description 定义一种触发器类型的安装和卸载逻辑\n */\nexport interface TriggerDefinition<\n  TKind extends TriggerKind = TriggerKind,\n  TConfig extends JsonObject = JsonObject,\n> {\n  /** 触发器类型标识 */\n  kind: TKind;\n  /** 配置校验 Schema */\n  schema: Schema<TConfig>;\n  /** 安装触发器 */\n  install(ctx: TriggerInstallContext<TKind, TConfig>): Promise<void> | void;\n  /** 卸载触发器 */\n  uninstall(ctx: TriggerInstallContext<TKind, TConfig>): Promise<void> | void;\n}\n\n/**\n * 插件注册上下文\n */\nexport interface PluginRegistrationContext {\n  /** 注册节点定义 */\n  registerNode(def: NodeDefinition): void;\n  /** 注册触发器定义 */\n  registerTrigger(def: TriggerDefinition): void;\n}\n\n/**\n * 插件接口\n * @description Record-Replay 插件的标准接口\n */\nexport interface RRPlugin {\n  /** 插件名称 */\n  name: string;\n  /** 注册插件内容 */\n  register(ctx: PluginRegistrationContext): void;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter.ts",
    "content": "/**\n * @fileoverview V2 ActionHandler -> V3 NodeDefinition adapter\n * @description Bridges legacy RR-V2 action handlers into the RR-V3 PluginRegistry.\n *\n * Design notes:\n * - V3 requires variable mutations to be represented as varsPatch so they are auditable in the event log.\n * - V2 handlers mutate ctx.vars directly, so we run them against a cloned VariableStore and diff it.\n * - Cross-node state (tabId/frameId changes from switchFrame/openTab/switchTab) is persisted in internal vars.\n *\n * WARNING: This adapter accesses V2 handler internals and may need updates if V2 types change.\n */\n\nimport { z } from 'zod';\n\nimport type {\n  ActionExecutionContext,\n  ActionExecutionResult,\n  ActionHandler,\n  ActionError,\n  ActionErrorCode,\n  ActionPolicy,\n  ExecutableActionType,\n  ValidationResult,\n  Action,\n} from '@/entrypoints/background/record-replay/actions/types';\n\nimport type { JsonValue, JsonObject } from '../../domain/json';\nimport { RR_ERROR_CODES, createRRError, type RRError, type RRErrorCode } from '../../domain/errors';\nimport type { NodePolicy } from '../../domain/policy';\nimport { mergeNodePolicy } from '../../domain/policy';\n\nimport type {\n  NodeDefinition,\n  NodeExecutionContext,\n  NodeExecutionResult,\n  VarsPatchOp,\n} from './types';\n\n// Internal run-scoped state keys used to emulate V2 \"mutable context\" across nodes.\nconst DEFAULT_TAB_ID_VAR = '__rr_v2__tabId';\nconst DEFAULT_FRAME_ID_VAR = '__rr_v2__frameId';\n\nexport interface V2ActionNodeAdapterOptions {\n  /**\n   * Whether to emit v2 ActionExecutionResult.output into V3 NodeExecutionResult.outputs.\n   * Defaults to true.\n   */\n  includeOutput?: boolean;\n\n  /**\n   * Where to store cross-node \"mutable context\" state (tabId/frameId).\n   * Defaults are \"__rr_v2__tabId\" and \"__rr_v2__frameId\".\n   */\n  stateVars?: {\n    tabIdVar?: string;\n    frameIdVar?: string;\n  };\n\n  /**\n   * Execution flags forwarded into V2 ActionExecutionContext.execution.\n   * Keep default undefined to preserve V2 handler behavior.\n   */\n  executionFlags?: ActionExecutionContext['execution'];\n}\n\n// ==================== Utilities ====================\n\nfunction toErrorMessage(e: unknown): string {\n  if (e instanceof Error) return e.message;\n  if (e && typeof e === 'object' && 'message' in e)\n    return String((e as { message: unknown }).message);\n  return String(e);\n}\n\nfunction deepClone<T>(value: T): T {\n  const sc = (globalThis as unknown as { structuredClone?: <U>(v: U) => U }).structuredClone;\n  if (typeof sc === 'function') return sc(value);\n  return JSON.parse(JSON.stringify(value)) as T;\n}\n\nfunction safeJsonValue(value: unknown): JsonValue {\n  if (value === undefined) return null;\n  try {\n    const s = JSON.stringify(value);\n    if (s === undefined) return String(value);\n    return JSON.parse(s) as JsonValue;\n  } catch {\n    return String(value);\n  }\n}\n\nfunction mapLogLevel(level: 'info' | 'warn' | 'error' | undefined): 'info' | 'warn' | 'error' {\n  return level ?? 'info';\n}\n\nfunction mapV2ErrorCode(code: ActionErrorCode): RRErrorCode {\n  switch (code) {\n    case 'VALIDATION_ERROR':\n      return RR_ERROR_CODES.VALIDATION_ERROR;\n    case 'TIMEOUT':\n      return RR_ERROR_CODES.TIMEOUT;\n    case 'TAB_NOT_FOUND':\n      return RR_ERROR_CODES.TAB_NOT_FOUND;\n    case 'FRAME_NOT_FOUND':\n      return RR_ERROR_CODES.FRAME_NOT_FOUND;\n    case 'TARGET_NOT_FOUND':\n      return RR_ERROR_CODES.TARGET_NOT_FOUND;\n    case 'ELEMENT_NOT_VISIBLE':\n      return RR_ERROR_CODES.ELEMENT_NOT_VISIBLE;\n    case 'NAVIGATION_FAILED':\n      return RR_ERROR_CODES.NAVIGATION_FAILED;\n    case 'NETWORK_REQUEST_FAILED':\n      return RR_ERROR_CODES.NETWORK_REQUEST_FAILED;\n    case 'SCRIPT_FAILED':\n      return RR_ERROR_CODES.SCRIPT_FAILED;\n\n    // V3 doesn't currently have dedicated codes for these.\n    case 'DOWNLOAD_FAILED':\n    case 'ASSERTION_FAILED':\n      return RR_ERROR_CODES.TOOL_ERROR;\n\n    case 'UNKNOWN':\n    default:\n      return RR_ERROR_CODES.INTERNAL;\n  }\n}\n\nfunction toRRErrorFromV2(error: ActionError): RRError {\n  const data = error.data !== undefined ? safeJsonValue(error.data) : undefined;\n  return createRRError(\n    mapV2ErrorCode(error.code),\n    error.message,\n    data !== undefined ? { data } : undefined,\n  );\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\nfunction jsonEquals(a: JsonValue, b: JsonValue): boolean {\n  if (a === b) return true;\n\n  const aIsArray = Array.isArray(a);\n  const bIsArray = Array.isArray(b);\n  if (aIsArray || bIsArray) {\n    if (!aIsArray || !bIsArray) return false;\n    if (a.length !== b.length) return false;\n    for (let i = 0; i < a.length; i++) {\n      if (!jsonEquals(a[i] as JsonValue, b[i] as JsonValue)) return false;\n    }\n    return true;\n  }\n\n  const aIsObj = isRecord(a);\n  const bIsObj = isRecord(b);\n  if (aIsObj || bIsObj) {\n    if (!aIsObj || !bIsObj) return false;\n    const aKeys = Object.keys(a);\n    const bKeys = Object.keys(b);\n    if (aKeys.length !== bKeys.length) return false;\n    for (const k of aKeys) {\n      if (!Object.prototype.hasOwnProperty.call(b, k)) return false;\n      if (!jsonEquals(a[k] as JsonValue, (b as Record<string, unknown>)[k] as JsonValue))\n        return false;\n    }\n    return true;\n  }\n\n  return false;\n}\n\nfunction diffVars(\n  before: Record<string, JsonValue>,\n  after: Record<string, JsonValue>,\n): VarsPatchOp[] {\n  const patch: VarsPatchOp[] = [];\n  const keys = new Set<string>([...Object.keys(before), ...Object.keys(after)]);\n\n  for (const key of keys) {\n    const beforeHas = Object.prototype.hasOwnProperty.call(before, key);\n    const afterHas = Object.prototype.hasOwnProperty.call(after, key);\n\n    if (!afterHas) {\n      if (beforeHas) patch.push({ op: 'delete', name: key });\n      continue;\n    }\n\n    const afterVal = after[key];\n    if (!beforeHas) {\n      patch.push({ op: 'set', name: key, value: afterVal });\n      continue;\n    }\n\n    const beforeVal = before[key];\n    if (!jsonEquals(beforeVal, afterVal)) {\n      patch.push({ op: 'set', name: key, value: afterVal });\n    }\n  }\n\n  return patch;\n}\n\nfunction readNumberVar(vars: Record<string, JsonValue>, key: string): number | undefined {\n  const v = vars[key];\n  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;\n}\n\nfunction toV2ActionPolicy(policy: NodePolicy | undefined): ActionPolicy | undefined {\n  if (!policy) return undefined;\n\n  const timeout = policy.timeout\n    ? {\n        ms: policy.timeout.ms,\n        scope: policy.timeout.scope === 'node' ? ('action' as const) : ('attempt' as const),\n      }\n    : undefined;\n\n  // NodePolicy/ActionPolicy are structurally similar; we only normalize timeout.scope.\n  return {\n    ...(timeout ? { timeout } : {}),\n    ...(policy.retry ? { retry: policy.retry as unknown as ActionPolicy['retry'] } : {}),\n    ...(policy.artifacts\n      ? { artifacts: policy.artifacts as unknown as ActionPolicy['artifacts'] }\n      : {}),\n    ...(policy.onError\n      ? (() => {\n          // V2 only supports goto by edge label. Node-target goto can't be represented.\n          if (policy.onError.kind === 'goto' && policy.onError.target.kind === 'node') {\n            return { onError: { kind: 'stop' } as ActionPolicy['onError'] };\n          }\n          if (policy.onError.kind === 'continue') {\n            return {\n              onError: {\n                kind: 'continue',\n                level: policy.onError.as,\n              } as ActionPolicy['onError'],\n            };\n          }\n          if (policy.onError.kind === 'goto') {\n            const target = policy.onError.target;\n            if (target.kind === 'edgeLabel') {\n              return {\n                onError: {\n                  kind: 'goto',\n                  label: target.label,\n                } as ActionPolicy['onError'],\n              };\n            }\n            // Node target can't be represented in V2, fall through to stop\n            return { onError: { kind: 'stop' } as ActionPolicy['onError'] };\n          }\n          if (policy.onError.kind === 'retry') {\n            // V2 has retry policy on action.policy.retry; keep onError as stop to avoid double semantics.\n            return { onError: { kind: 'stop' } as ActionPolicy['onError'] };\n          }\n          return { onError: policy.onError as unknown as ActionPolicy['onError'] };\n        })()\n      : {}),\n  };\n}\n\nfunction toJsonRecord(value: unknown): Record<string, JsonValue> {\n  const out: Record<string, JsonValue> = {};\n  if (!isRecord(value)) return out;\n\n  for (const [k, v] of Object.entries(value)) {\n    // Treat undefined as deletion (omit).\n    if (v === undefined) continue;\n    out[k] = safeJsonValue(v);\n  }\n\n  return out;\n}\n\n// ==================== Main Adapter ====================\n\n/**\n * Adapt a single V2 ActionHandler into a V3 NodeDefinition.\n */\nexport function adaptV2ActionHandlerToV3NodeDefinition<T extends ExecutableActionType>(\n  handler: ActionHandler<T>,\n  options: V2ActionNodeAdapterOptions = {},\n): NodeDefinition<T, JsonObject> {\n  const tabIdVar = options.stateVars?.tabIdVar ?? DEFAULT_TAB_ID_VAR;\n  const frameIdVar = options.stateVars?.frameIdVar ?? DEFAULT_FRAME_ID_VAR;\n\n  return {\n    kind: handler.type,\n    schema: z.record(z.any()) as unknown as NodeDefinition<T, JsonObject>['schema'],\n    execute: async (ctx: NodeExecutionContext, node): Promise<NodeExecutionResult> => {\n      const beforeVars = ctx.vars;\n\n      const effectiveTabId = readNumberVar(beforeVars, tabIdVar) ?? ctx.tabId;\n      const effectiveFrameId = readNumberVar(beforeVars, frameIdVar);\n\n      // Run against a cloned variable store to prevent bypassing vars.patch event stream.\n      const v2Vars = deepClone(beforeVars) as unknown as Record<string, unknown>;\n\n      const v2Ctx: ActionExecutionContext = {\n        vars: v2Vars as unknown as ActionExecutionContext['vars'],\n        tabId: effectiveTabId,\n        frameId: effectiveFrameId,\n        runId: ctx.runId,\n        log: (message, level) => ctx.log(mapLogLevel(level), message),\n        pushLog: (entry) => {\n          try {\n            ctx.log('debug', 'v2.pushLog', safeJsonValue(entry));\n          } catch {\n            // ignore\n          }\n        },\n        captureScreenshot: async () => {\n          const r = await ctx.artifacts.screenshot();\n          if (r.ok) return r.base64;\n          throw new Error(r.error.message);\n        },\n        ...(options.executionFlags ? { execution: options.executionFlags } : {}),\n      };\n\n      const effectivePolicy = mergeNodePolicy(ctx.flow.policy?.defaultNodePolicy, node.policy);\n      const v2Policy = toV2ActionPolicy(effectivePolicy);\n\n      const action: Action<T> = {\n        id: node.id as Action<T>['id'],\n        type: handler.type,\n        ...(node.name ? { name: node.name } : {}),\n        ...(node.disabled ? { disabled: true } : {}),\n        ...(v2Policy ? { policy: v2Policy } : {}),\n        params: node.config as unknown as Action<T>['params'],\n        ...(node.ui ? { ui: node.ui as Action<T>['ui'] } : {}),\n      };\n\n      // V2 handler-level validation\n      if (handler.validate) {\n        const v: ValidationResult = handler.validate(action);\n        if (!v.ok) {\n          return {\n            status: 'failed',\n            error: createRRError(RR_ERROR_CODES.VALIDATION_ERROR, v.errors.join(', ')),\n          };\n        }\n      }\n\n      let result: ActionExecutionResult<T>;\n      try {\n        result = await handler.run(v2Ctx, action);\n      } catch (e) {\n        return {\n          status: 'failed',\n          error: createRRError(\n            RR_ERROR_CODES.INTERNAL,\n            `V2 handler \"${handler.type}\" threw: ${toErrorMessage(e)}`,\n          ),\n        };\n      }\n\n      if (result.status === 'failed') {\n        const err = result.error\n          ? toRRErrorFromV2(result.error)\n          : createRRError(RR_ERROR_CODES.INTERNAL, `V2 handler \"${handler.type}\" failed`);\n        return { status: 'failed', error: err };\n      }\n\n      if (result.status === 'paused') {\n        return {\n          status: 'failed',\n          error: createRRError(\n            RR_ERROR_CODES.RUN_PAUSED,\n            `V2 handler \"${handler.type}\" returned paused (not supported in V3 NodeExecutionResult)`,\n          ),\n        };\n      }\n\n      // V3 does not support V2 scheduler control directives (foreach/while).\n      if (result.control) {\n        return {\n          status: 'failed',\n          error: createRRError(\n            RR_ERROR_CODES.UNSUPPORTED_NODE,\n            `V2 control directive \"${result.control.kind}\" is not supported by the V3 runner`,\n            { data: safeJsonValue(result.control) },\n          ),\n        };\n      }\n\n      // Persist cross-node context changes via internal vars.\n      if (typeof v2Ctx.frameId === 'number' && Number.isFinite(v2Ctx.frameId)) {\n        v2Vars[frameIdVar] = v2Ctx.frameId;\n      } else {\n        delete v2Vars[frameIdVar];\n      }\n\n      if (typeof result.newTabId === 'number' && Number.isFinite(result.newTabId)) {\n        v2Vars[tabIdVar] = result.newTabId;\n      }\n\n      const afterVars = toJsonRecord(v2Vars);\n      const varsPatch = diffVars(beforeVars, afterVars);\n\n      const outputs: Record<string, JsonValue> | undefined =\n        options.includeOutput === false || result.output === undefined\n          ? undefined\n          : { [node.id]: safeJsonValue(result.output) };\n\n      return {\n        status: 'succeeded',\n        ...(result.nextLabel ? { next: ctx.chooseNext(result.nextLabel) } : {}),\n        ...(outputs ? { outputs } : {}),\n        ...(varsPatch.length > 0 ? { varsPatch } : {}),\n      };\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/enqueue-run.ts",
    "content": "/**\n * @fileoverview 共享入队服务\n * @description\n * 提供统一的 Run 入队逻辑，供 RPC Server 和 TriggerManager 共用。\n *\n * 设计理由：\n * - 将原本位于 RpcServer 的入队逻辑抽离为独立服务\n * - 避免 RPC 和 TriggerManager 之间的行为漂移\n * - 统一参数校验、Run 创建、队列入队、事件发布流程\n */\n\nimport type { JsonObject, UnixMillis } from '../../domain/json';\nimport type { FlowId, NodeId, RunId } from '../../domain/ids';\nimport type { TriggerFireContext } from '../../domain/triggers';\nimport { RUN_SCHEMA_VERSION, type RunRecordV3 } from '../../domain/events';\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from '../transport/events-bus';\nimport type { RunScheduler } from './scheduler';\n\n// ==================== Types ====================\n\n/**\n * 入队服务依赖\n */\nexport interface EnqueueRunDeps {\n  /** 存储层 (仅需 flows/runs/queue) */\n  storage: Pick<StoragePort, 'flows' | 'runs' | 'queue'>;\n  /** 事件总线 */\n  events: Pick<EventsBus, 'append'>;\n  /** 调度器 (可选) */\n  scheduler?: Pick<RunScheduler, 'kick'>;\n  /** RunId 生成器 (用于测试注入) */\n  generateRunId?: () => RunId;\n  /** 时间源 (用于测试注入) */\n  now?: () => UnixMillis;\n}\n\n/**\n * 入队请求参数\n */\nexport interface EnqueueRunInput {\n  /** Flow ID (必选) */\n  flowId: FlowId;\n  /** 起始节点 ID (可选，默认使用 Flow 的 entryNodeId) */\n  startNodeId?: NodeId;\n  /** 优先级 (默认 0) */\n  priority?: number;\n  /** 最大尝试次数 (默认 1) */\n  maxAttempts?: number;\n  /** 传递给 Flow 的参数 */\n  args?: JsonObject;\n  /** 触发上下文 (由 TriggerManager 设置) */\n  trigger?: TriggerFireContext;\n  /** 调试选项 */\n  debug?: {\n    breakpoints?: NodeId[];\n    pauseOnStart?: boolean;\n  };\n}\n\n/**\n * 入队结果\n */\nexport interface EnqueueRunResult {\n  /** 新创建的 Run ID */\n  runId: RunId;\n  /** 在队列中的位置 (1-based) */\n  position: number;\n}\n\n// ==================== Utilities ====================\n\n/**\n * 默认 RunId 生成器\n */\nfunction defaultGenerateRunId(): RunId {\n  return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\n/**\n * 校验整数参数\n */\nfunction validateInt(\n  value: unknown,\n  defaultValue: number,\n  fieldName: string,\n  opts?: { min?: number; max?: number },\n): number {\n  if (value === undefined || value === null) {\n    return defaultValue;\n  }\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    throw new Error(`${fieldName} must be a finite number`);\n  }\n  const intValue = Math.floor(value);\n  if (opts?.min !== undefined && intValue < opts.min) {\n    throw new Error(`${fieldName} must be >= ${opts.min}`);\n  }\n  if (opts?.max !== undefined && intValue > opts.max) {\n    throw new Error(`${fieldName} must be <= ${opts.max}`);\n  }\n  return intValue;\n}\n\n/**\n * 计算 Run 在队列中的位置\n * @description 按调度顺序: priority DESC + createdAt ASC\n * @returns 1-based position, or -1 if run not found in queued items\n *\n * Note: Due to race conditions (scheduler may claim the run before this is called),\n * position may be -1. Callers should handle this gracefully.\n */\nasync function computeQueuePosition(\n  storage: Pick<StoragePort, 'queue'>,\n  runId: RunId,\n): Promise<number> {\n  const queueItems = await storage.queue.list('queued');\n  queueItems.sort((a, b) => {\n    if (a.priority !== b.priority) return b.priority - a.priority;\n    return a.createdAt - b.createdAt;\n  });\n  const index = queueItems.findIndex((item) => item.id === runId);\n  // Return -1 if not found (run may have been claimed already)\n  return index === -1 ? -1 : index + 1;\n}\n\n// ==================== Main Function ====================\n\n/**\n * 入队执行一个 Run\n * @description\n * 执行步骤：\n * 1. 参数校验\n * 2. 验证 Flow 存在\n * 3. 创建 RunRecordV3 (status=queued)\n * 4. 入队到 RunQueue\n * 5. 发布 run.queued 事件\n * 6. 触发调度 (best-effort)\n * 7. 计算队列位置\n */\nexport async function enqueueRun(\n  deps: EnqueueRunDeps,\n  input: EnqueueRunInput,\n): Promise<EnqueueRunResult> {\n  const { flowId } = input;\n  if (!flowId) {\n    throw new Error('flowId is required');\n  }\n\n  const now = deps.now ?? (() => Date.now());\n  const generateRunId = deps.generateRunId ?? defaultGenerateRunId;\n\n  // 参数校验\n  const priority = validateInt(input.priority, 0, 'priority');\n  const maxAttempts = validateInt(input.maxAttempts, 1, 'maxAttempts', { min: 1 });\n\n  // 验证 Flow 存在\n  const flow = await deps.storage.flows.get(flowId);\n  if (!flow) {\n    throw new Error(`Flow \"${flowId}\" not found`);\n  }\n\n  // 验证 startNodeId 存在于 Flow 中\n  if (input.startNodeId) {\n    const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId);\n    if (!nodeExists) {\n      throw new Error(`startNodeId \"${input.startNodeId}\" not found in flow \"${flowId}\"`);\n    }\n  }\n\n  const ts = now();\n  const runId = generateRunId();\n\n  // 1. 创建 RunRecordV3\n  const runRecord: RunRecordV3 = {\n    schemaVersion: RUN_SCHEMA_VERSION,\n    id: runId,\n    flowId,\n    status: 'queued',\n    createdAt: ts,\n    updatedAt: ts,\n    attempt: 0,\n    maxAttempts,\n    args: input.args,\n    trigger: input.trigger,\n    debug: input.debug,\n    startNodeId: input.startNodeId,\n    nextSeq: 0,\n  };\n  await deps.storage.runs.save(runRecord);\n\n  // 2. 入队\n  await deps.storage.queue.enqueue({\n    id: runId,\n    flowId,\n    priority,\n    maxAttempts,\n    args: input.args,\n    trigger: input.trigger,\n    debug: input.debug,\n  });\n\n  // 3. 发布 run.queued 事件\n  await deps.events.append({\n    runId,\n    type: 'run.queued',\n    flowId,\n  });\n\n  // 4. 计算队列位置 (在 kick 之前计算，减少竞态条件导致 position=-1 的概率)\n  const position = await computeQueuePosition(deps.storage, runId);\n\n  // 5. 触发调度 (best-effort, 不阻塞返回)\n  if (deps.scheduler) {\n    void deps.scheduler.kick();\n  }\n\n  return { runId, position };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/index.ts",
    "content": "/**\n * @fileoverview Queue 模块导出入口\n */\n\nexport * from './queue';\nexport * from './leasing';\nexport * from './scheduler';\nexport * from './enqueue-run';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/leasing.ts",
    "content": "/**\n * @fileoverview 租约管理\n * @description 管理 Run 的租约续约和过期回收\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { RunId } from '../../domain/ids';\nimport type { RunQueue, RunQueueConfig, Lease } from './queue';\n\n/**\n * 租约管理器\n * @description 管理租约续约和过期检测\n */\nexport interface LeaseManager {\n  /**\n   * 开始心跳\n   * @param ownerId 持有者 ID\n   */\n  startHeartbeat(ownerId: string): void;\n\n  /**\n   * 停止心跳\n   * @param ownerId 持有者 ID\n   */\n  stopHeartbeat(ownerId: string): void;\n\n  /**\n   * 检查并回收过期租约\n   * @param now 当前时间\n   * @returns 被回收的 Run ID 列表\n   */\n  reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]>;\n\n  /**\n   * 判断租约是否过期\n   */\n  isLeaseExpired(lease: Lease, now: UnixMillis): boolean;\n\n  /**\n   * 创建新租约\n   */\n  createLease(ownerId: string, now: UnixMillis): Lease;\n\n  /**\n   * 停止所有心跳\n   */\n  dispose(): void;\n}\n\n/**\n * 创建租约管理器\n */\nexport function createLeaseManager(queue: RunQueue, config: RunQueueConfig): LeaseManager {\n  const heartbeatTimers = new Map<string, ReturnType<typeof setInterval>>();\n\n  return {\n    startHeartbeat(ownerId: string): void {\n      // 如果已有定时器，先停止\n      this.stopHeartbeat(ownerId);\n\n      // 创建新的心跳定时器\n      const timer = setInterval(async () => {\n        try {\n          await queue.heartbeat(ownerId, Date.now());\n        } catch (error) {\n          console.error(`[LeaseManager] Heartbeat failed for ${ownerId}:`, error);\n        }\n      }, config.heartbeatIntervalMs);\n\n      heartbeatTimers.set(ownerId, timer);\n    },\n\n    stopHeartbeat(ownerId: string): void {\n      const timer = heartbeatTimers.get(ownerId);\n      if (timer) {\n        clearInterval(timer);\n        heartbeatTimers.delete(ownerId);\n      }\n    },\n\n    async reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]> {\n      // Delegate to the queue implementation which uses the lease_expiresAt index\n      // for efficient scanning and updates storage atomically.\n      return queue.reclaimExpiredLeases(now);\n    },\n\n    isLeaseExpired(lease: Lease, now: UnixMillis): boolean {\n      return lease.expiresAt < now;\n    },\n\n    createLease(ownerId: string, now: UnixMillis): Lease {\n      return {\n        ownerId,\n        expiresAt: now + config.leaseTtlMs,\n      };\n    },\n\n    dispose(): void {\n      for (const timer of heartbeatTimers.values()) {\n        clearInterval(timer);\n      }\n      heartbeatTimers.clear();\n    },\n  };\n}\n\n/**\n * 生成唯一的 owner ID\n * @description 用于标识当前 Service Worker 实例\n */\nexport function generateOwnerId(): string {\n  return `sw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/queue.ts",
    "content": "/**\n * @fileoverview RunQueue 接口定义\n * @description 定义 Run 队列的管理接口\n */\n\nimport type { JsonObject, UnixMillis } from '../../domain/json';\nimport type { FlowId, NodeId, RunId } from '../../domain/ids';\nimport type { TriggerFireContext } from '../../domain/triggers';\n\n/**\n * RunQueue 配置\n */\nexport interface RunQueueConfig {\n  /** 最大并行 Run 数量 */\n  maxParallelRuns: number;\n  /** 租约 TTL（毫秒） */\n  leaseTtlMs: number;\n  /** 心跳间隔（毫秒） */\n  heartbeatIntervalMs: number;\n}\n\n/**\n * 默认队列配置\n */\nexport const DEFAULT_QUEUE_CONFIG: RunQueueConfig = {\n  maxParallelRuns: 3,\n  leaseTtlMs: 15_000,\n  heartbeatIntervalMs: 5_000,\n};\n\n/**\n * 队列项状态\n */\nexport type QueueItemStatus = 'queued' | 'running' | 'paused';\n\n/**\n * 租约信息\n */\nexport interface Lease {\n  /** 持有者 ID */\n  ownerId: string;\n  /** 过期时间 */\n  expiresAt: UnixMillis;\n}\n\n/**\n * RunQueue 队列项\n */\nexport interface RunQueueItem {\n  /** Run ID */\n  id: RunId;\n  /** Flow ID */\n  flowId: FlowId;\n  /** 状态 */\n  status: QueueItemStatus;\n  /** 创建时间 */\n  createdAt: UnixMillis;\n  /** 更新时间 */\n  updatedAt: UnixMillis;\n  /** 优先级（数字越大优先级越高） */\n  priority: number;\n  /** 当前尝试次数 */\n  attempt: number;\n  /** 最大尝试次数 */\n  maxAttempts: number;\n  /** Tab ID */\n  tabId?: number;\n  /** 运行参数 */\n  args?: JsonObject;\n  /** 触发器上下文 */\n  trigger?: TriggerFireContext;\n  /** 租约信息 */\n  lease?: Lease;\n  /** 调试配置 */\n  debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };\n}\n\n/**\n * 入队请求（不含自动生成的字段）\n * - priority 默认为 0\n * - maxAttempts 默认为 1\n */\nexport type EnqueueInput = Omit<\n  RunQueueItem,\n  'status' | 'createdAt' | 'updatedAt' | 'attempt' | 'lease' | 'priority' | 'maxAttempts'\n> & {\n  id: RunId;\n  /** 优先级（数字越大优先级越高，默认 0） */\n  priority?: number;\n  /** 最大尝试次数（默认 1） */\n  maxAttempts?: number;\n};\n\n/**\n * RunQueue 接口\n * @description 管理 Run 的队列和调度\n */\nexport interface RunQueue {\n  /**\n   * 入队\n   * @param input 入队请求\n   * @returns 队列项\n   */\n  enqueue(input: EnqueueInput): Promise<RunQueueItem>;\n\n  /**\n   * 领取下一个可执行的 Run\n   * @param ownerId 领取者 ID\n   * @param now 当前时间\n   * @returns 队列项或 null\n   */\n  claimNext(ownerId: string, now: UnixMillis): Promise<RunQueueItem | null>;\n\n  /**\n   * 续约心跳\n   * @param ownerId 领取者 ID\n   * @param now 当前时间\n   */\n  heartbeat(ownerId: string, now: UnixMillis): Promise<void>;\n\n  /**\n   * 回收过期租约\n   * @description 将 lease.expiresAt < now 的 running/paused 项回收为 queued\n   * @param now 当前时间\n   * @returns 被回收的 Run ID 列表\n   */\n  reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]>;\n\n  /**\n   * 恢复孤儿租约（SW 重启后调用）\n   * @description\n   * - 将孤儿 running 项回收为 queued（status -> queued，租约清除）\n   * - 将孤儿 paused 项接管（保持 status=paused，租约 ownerId 更新为新 ownerId）\n   * @param ownerId 新的 ownerId（当前 Service Worker 实例）\n   * @param now 当前时间\n   * @returns 受影响的 runId 列表（含原 ownerId 用于审计）\n   */\n  recoverOrphanLeases(\n    ownerId: string,\n    now: UnixMillis,\n  ): Promise<{\n    requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>;\n    adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>;\n  }>;\n\n  /**\n   * 标记为 running\n   */\n  markRunning(runId: RunId, ownerId: string, now: UnixMillis): Promise<void>;\n\n  /**\n   * 标记为 paused\n   */\n  markPaused(runId: RunId, ownerId: string, now: UnixMillis): Promise<void>;\n\n  /**\n   * 标记为完成（从队列移除）\n   */\n  markDone(runId: RunId, now: UnixMillis): Promise<void>;\n\n  /**\n   * 取消 Run\n   */\n  cancel(runId: RunId, now: UnixMillis, reason?: string): Promise<void>;\n\n  /**\n   * 获取队列项\n   */\n  get(runId: RunId): Promise<RunQueueItem | null>;\n\n  /**\n   * 列出队列项\n   */\n  list(status?: QueueItemStatus): Promise<RunQueueItem[]>;\n}\n\n/**\n * 创建 NotImplemented 的 RunQueue\n * @description Phase 0 占位实现\n */\nexport function createNotImplementedQueue(): RunQueue {\n  const notImplemented = () => {\n    throw new Error('RunQueue not implemented');\n  };\n\n  return {\n    enqueue: async () => notImplemented(),\n    claimNext: async () => notImplemented(),\n    heartbeat: async () => notImplemented(),\n    reclaimExpiredLeases: async () => notImplemented(),\n    recoverOrphanLeases: async () => notImplemented(),\n    markRunning: async () => notImplemented(),\n    markPaused: async () => notImplemented(),\n    markDone: async () => notImplemented(),\n    cancel: async () => notImplemented(),\n    get: async () => notImplemented(),\n    list: async () => notImplemented(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/queue/scheduler.ts",
    "content": "/**\n * @fileoverview RunQueue scheduler (maxParallelRuns)\n * @description\n * Orchestrates atomic claims from RunQueue and launches execution with an injected executor.\n *\n * Responsibilities:\n * - Enforce maxParallelRuns (per scheduler instance)\n * - Backfill available slots when runs complete\n * - Periodically reclaim expired leases (best-effort)\n * - Start/stop lease heartbeats via LeaseManager\n * - Acquire/release keepalive to prevent MV3 SW termination (P3-05)\n *\n * Non-responsibilities:\n * - Run execution details (Flow loading, tab allocation, etc.) are injected via RunExecutor\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { RunId } from '../../domain/ids';\nimport type { LeaseManager } from './leasing';\nimport type { RunQueue, RunQueueConfig, RunQueueItem } from './queue';\nimport type { KeepaliveController } from '../keepalive/offscreen-keepalive';\n\n// ==================== Types ====================\n\n/**\n * Run executor contract:\n * - Resolve when the run reaches a terminal state (succeeded/failed/canceled).\n * - Throw/reject only for unexpected infrastructure errors.\n */\nexport type RunExecutor = (item: RunQueueItem) => Promise<void>;\n\n/**\n * Scheduler tuning parameters\n */\nexport interface RunSchedulerTuning {\n  /**\n   * Poll interval for queue consumption fallback.\n   * Set to 0 to disable polling (kick-only).\n   */\n  pollIntervalMs?: number;\n\n  /**\n   * Minimum interval between lease reclaim scans.\n   * Set to 0 to disable periodic reclaim (not recommended in production).\n   */\n  reclaimIntervalMs?: number;\n}\n\n/**\n * Scheduler dependencies (dependency injection)\n */\nexport interface RunSchedulerDeps {\n  queue: Pick<RunQueue, 'claimNext' | 'markDone'>;\n  leaseManager: Pick<LeaseManager, 'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'>;\n  keepalive: Pick<KeepaliveController, 'acquire'>;\n  config: RunQueueConfig;\n  ownerId: string;\n  execute: RunExecutor;\n  now?: () => UnixMillis;\n  tuning?: RunSchedulerTuning;\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\n/**\n * Scheduler state for inspection\n */\nexport interface RunSchedulerState {\n  started: boolean;\n  ownerId: string;\n  maxParallelRuns: number;\n  activeRunIds: RunId[];\n}\n\n/**\n * Scheduler interface\n */\nexport interface RunScheduler {\n  /** Start the scheduler */\n  start(): void;\n  /** Stop the scheduler */\n  stop(): void;\n  /**\n   * Trigger a scheduling pass.\n   * Safe to call frequently; re-entrancy is coalesced.\n   */\n  kick(): Promise<void>;\n  /** Get current state */\n  getState(): RunSchedulerState;\n  /** Dispose the scheduler */\n  dispose(): void;\n}\n\n// ==================== Constants ====================\n\nconst DEFAULT_POLL_INTERVAL_MS = 500;\n\n// ==================== Helpers ====================\n\nfunction clampNonNegativeInt(value: unknown, fallback: number): number {\n  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;\n  return Math.max(0, n);\n}\n\nfunction defaultReclaimIntervalMs(leaseTtlMs: number): number {\n  const ttl = clampNonNegativeInt(leaseTtlMs, 0);\n  // Reclaim at most every ~TTL/2, but never less than 1s to avoid tight loops.\n  return Math.max(1_000, Math.floor(ttl / 2));\n}\n\n// ==================== Factory ====================\n\n/**\n * Create a RunScheduler\n *\n * Scheduling model:\n * - Concurrency is enforced by an in-memory set of active runIds.\n * - Ordering is delegated to RunQueue.claimNext() (priority DESC, createdAt ASC).\n *\n * MV3 Service Worker may be suspended/restarted, so we use a \"kick + polling\" strategy:\n * - kick: Immediate scheduling trigger on enqueue/completion (low latency)\n * - polling: Fallback to ensure queue is consumed even if caller forgets to kick\n */\nexport function createRunScheduler(deps: RunSchedulerDeps): RunScheduler {\n  const logger = deps.logger ?? console;\n\n  if (!deps.ownerId) {\n    throw new Error('ownerId is required');\n  }\n\n  const now = deps.now ?? (() => Date.now());\n  const maxParallelRuns = clampNonNegativeInt(deps.config.maxParallelRuns, 0);\n  const pollIntervalMs = clampNonNegativeInt(\n    deps.tuning?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,\n    DEFAULT_POLL_INTERVAL_MS,\n  );\n  const reclaimIntervalMs = clampNonNegativeInt(\n    deps.tuning?.reclaimIntervalMs ?? defaultReclaimIntervalMs(deps.config.leaseTtlMs),\n    defaultReclaimIntervalMs(deps.config.leaseTtlMs),\n  );\n\n  let started = false;\n  let pollTimer: ReturnType<typeof setInterval> | null = null;\n  let releaseKeepalive: (() => void) | null = null;\n\n  const activeRunIds = new Set<RunId>();\n\n  // Coalesced re-entrancy control for tick()\n  let pendingKick = false;\n  let pumpPromise: Promise<void> | null = null;\n\n  let lastReclaimAt: UnixMillis | null = null;\n\n  /**\n   * Single scheduling tick:\n   * 1. Reclaim expired leases (if interval elapsed)\n   * 2. Fill available slots up to maxParallelRuns\n   */\n  async function tick(): Promise<void> {\n    const t = now();\n\n    // Best-effort lease reclaim (disabled when reclaimIntervalMs === 0)\n    if (reclaimIntervalMs > 0) {\n      const shouldReclaim = lastReclaimAt === null || t - lastReclaimAt >= reclaimIntervalMs;\n      if (shouldReclaim) {\n        lastReclaimAt = t;\n        try {\n          await deps.leaseManager.reclaimExpiredLeases(t);\n        } catch (e) {\n          logger.warn('[RunScheduler] reclaimExpiredLeases failed:', e);\n        }\n      }\n    }\n\n    // Fill available slots up to maxParallelRuns\n    //\n    // Note: `stop()` can be called while an async claim is in-flight. Guard the loop\n    // with `started` to prevent claiming additional items after stop is requested.\n    while (started && activeRunIds.size < maxParallelRuns) {\n      let claimed: RunQueueItem | null = null;\n      try {\n        claimed = await deps.queue.claimNext(deps.ownerId, t);\n      } catch (e) {\n        logger.error('[RunScheduler] claimNext failed:', e);\n        return;\n      }\n\n      if (!claimed) return;\n\n      // Guard against double-launch within the same scheduler instance\n      if (activeRunIds.has(claimed.id)) {\n        logger.error(\n          `[RunScheduler] Invariant violation: run \"${claimed.id}\" was claimed twice in the same scheduler instance`,\n        );\n        // Best-effort cleanup: avoid a stuck running entry\n        void deps.queue\n          .markDone(claimed.id, now())\n          .catch((err) =>\n            logger.warn('[RunScheduler] markDone after duplicate claim failed:', err),\n          );\n        continue;\n      }\n\n      activeRunIds.add(claimed.id);\n\n      // Capture claimed item for the closure\n      const claimedItem = claimed;\n\n      const runPromise = Promise.resolve()\n        .then(() => deps.execute(claimedItem))\n        .catch((e) => {\n          // If execution failed unexpectedly, log but still cleanup\n          logger.error(`[RunScheduler] execute failed for run \"${claimedItem.id}\":`, e);\n        })\n        .finally(async () => {\n          activeRunIds.delete(claimedItem.id);\n          try {\n            await deps.queue.markDone(claimedItem.id, now());\n          } catch (e) {\n            logger.warn(`[RunScheduler] markDone failed for run \"${claimedItem.id}\":`, e);\n          }\n\n          // Backfill immediately when a slot frees up\n          if (started) {\n            void kick();\n          }\n        });\n\n      // Ensure no floating promise warnings\n      void runPromise;\n    }\n  }\n\n  /**\n   * Pump loop: keeps running while pendingKick is set\n   */\n  async function pump(): Promise<void> {\n    try {\n      while (started && pendingKick) {\n        pendingKick = false;\n        try {\n          await tick();\n        } catch (e) {\n          logger.error('[RunScheduler] tick failed:', e);\n        }\n      }\n    } finally {\n      pumpPromise = null;\n    }\n  }\n\n  function start(): void {\n    if (started) return;\n    started = true;\n\n    // Acquire keepalive to prevent MV3 SW termination\n    try {\n      releaseKeepalive = deps.keepalive.acquire('scheduler');\n    } catch (e) {\n      logger.warn('[RunScheduler] keepalive.acquire failed:', e);\n      releaseKeepalive = null;\n    }\n\n    try {\n      deps.leaseManager.startHeartbeat(deps.ownerId);\n    } catch (e) {\n      logger.warn('[RunScheduler] startHeartbeat failed:', e);\n    }\n\n    if (pollIntervalMs > 0) {\n      pollTimer = setInterval(() => {\n        void kick();\n      }, pollIntervalMs);\n    }\n\n    void kick();\n  }\n\n  function stop(): void {\n    if (!started) return;\n\n    if (activeRunIds.size > 0) {\n      logger.warn(\n        `[RunScheduler] stop() called with ${activeRunIds.size} active runs; heartbeats will stop and leases may expire/reclaim concurrently`,\n      );\n    }\n\n    started = false;\n\n    if (pollTimer) {\n      clearInterval(pollTimer);\n      pollTimer = null;\n    }\n\n    try {\n      deps.leaseManager.stopHeartbeat(deps.ownerId);\n    } catch (e) {\n      logger.warn('[RunScheduler] stopHeartbeat failed:', e);\n    }\n\n    // Release keepalive\n    if (releaseKeepalive) {\n      try {\n        releaseKeepalive();\n      } catch (e) {\n        logger.warn('[RunScheduler] keepalive release failed:', e);\n      }\n      releaseKeepalive = null;\n    }\n  }\n\n  function kick(): Promise<void> {\n    if (!started) return Promise.resolve();\n\n    pendingKick = true;\n    if (!pumpPromise) {\n      pumpPromise = pump();\n    }\n    return pumpPromise;\n  }\n\n  function getState(): RunSchedulerState {\n    return {\n      started,\n      ownerId: deps.ownerId,\n      maxParallelRuns,\n      activeRunIds: Array.from(activeRunIds),\n    };\n  }\n\n  function dispose(): void {\n    stop();\n    activeRunIds.clear();\n  }\n\n  return { start, stop, kick, getState, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/index.ts",
    "content": "/**\n * @fileoverview Recovery module exports\n * @description 崩溃恢复模块导出\n */\n\nexport * from './recovery-coordinator';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator.ts",
    "content": "/**\n * @fileoverview 崩溃恢复协调器 (P3-06)\n * @description\n * MV3 Service Worker 可能随时被终止。此协调器在 SW 启动时协调队列状态和 Run 记录，\n * 使中断的 Run 能够被恢复执行。\n *\n * 恢复策略：\n * - 孤儿 running 项：回收为 queued，等待重新调度（从头重跑）\n * - 孤儿 paused 项：接管 lease，保持 paused 状态\n * - 已终态 Run 的队列残留：清理\n *\n * 调用时机：\n * - 必须在 scheduler.start() 之前调用\n * - 通常在 SW 启动时调用一次\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { RunId } from '../../domain/ids';\nimport { isTerminalStatus, type RunStatus } from '../../domain/events';\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from '../transport/events-bus';\n\n// ==================== Types ====================\n\n/**\n * 恢复结果\n */\nexport interface RecoveryResult {\n  /** 被回收为 queued 的 running Run ID */\n  requeuedRunning: RunId[];\n  /** 被接管的 paused Run ID */\n  adoptedPaused: RunId[];\n  /** 被清理的已终态 Run ID */\n  cleanedTerminal: RunId[];\n}\n\n/**\n * 恢复协调器依赖\n */\nexport interface RecoveryCoordinatorDeps {\n  /** 存储层 */\n  storage: StoragePort;\n  /** 事件总线 */\n  events: EventsBus;\n  /** 当前 Service Worker 的 ownerId */\n  ownerId: string;\n  /** 时间源 */\n  now: () => UnixMillis;\n  /** 日志器 */\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\n// ==================== Main Function ====================\n\n/**\n * 执行崩溃恢复\n * @description\n * 在 SW 启动时调用，协调队列状态和 Run 记录。\n *\n * 执行顺序：\n * 1. 预清理：检查队列中的所有项，清理已终态或无对应 RunRecord 的残留\n * 2. 恢复孤儿租约：回收 running，接管 paused\n * 3. 同步 RunRecord 状态：确保 RunRecord 与队列状态一致\n * 4. 发送恢复事件：为 requeued running 项发送 run.recovered 事件\n */\nexport async function recoverFromCrash(deps: RecoveryCoordinatorDeps): Promise<RecoveryResult> {\n  const logger = deps.logger ?? console;\n\n  if (!deps.ownerId) {\n    throw new Error('ownerId is required');\n  }\n\n  const now = deps.now();\n\n  // 设计理由：恢复过程必须\"先清理后接管/回收\"，否则可能把已经终态的 Run 重新排队执行\n  const cleanedTerminalSet = new Set<RunId>();\n\n  // ==================== Step 1: 预清理 ====================\n  // 检查队列中的所有项，清理已终态或无对应 RunRecord 的残留\n  try {\n    const items = await deps.storage.queue.list();\n    for (const item of items) {\n      const runId = item.id;\n      const run = await deps.storage.runs.get(runId);\n\n      // 防御性清理：无 RunRecord 的队列项无法执行\n      if (!run) {\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n          logger.debug(`[Recovery] Cleaned orphan queue item without RunRecord: ${runId}`);\n        } catch (e) {\n          logger.warn('[Recovery] markDone for missing RunRecord failed:', runId, e);\n        }\n        continue;\n      }\n\n      // 清理已终态的 Run（SW 可能在 runner 完成后、scheduler markDone 前崩溃）\n      if (isTerminalStatus(run.status)) {\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n          logger.debug(`[Recovery] Cleaned terminal queue item: ${runId} (status=${run.status})`);\n        } catch (e) {\n          logger.warn('[Recovery] markDone for terminal run failed:', runId, e);\n        }\n      }\n    }\n  } catch (e) {\n    logger.warn('[Recovery] Pre-clean failed:', e);\n  }\n\n  // ==================== Step 2: 恢复孤儿租约 ====================\n  // Best-effort：即使失败也不应该阻止启动\n  let requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = [];\n  let adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = [];\n  try {\n    const result = await deps.storage.queue.recoverOrphanLeases(deps.ownerId, now);\n    requeuedRunning = result.requeuedRunning;\n    adoptedPaused = result.adoptedPaused;\n  } catch (e) {\n    logger.error('[Recovery] recoverOrphanLeases failed:', e);\n    // 继续执行，不阻止启动\n  }\n\n  // ==================== Step 3: 同步 RunRecord 状态 ====================\n  const requeuedRunningIds: RunId[] = [];\n  for (const entry of requeuedRunning) {\n    const runId = entry.runId;\n    requeuedRunningIds.push(runId);\n\n    // 跳过在 Step 1 中已清理的项\n    if (cleanedTerminalSet.has(runId)) {\n      continue;\n    }\n\n    try {\n      const run = await deps.storage.runs.get(runId);\n      if (!run) {\n        // RunRecord 不存在，清理队列项（防御性）\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n        } catch (markDoneErr) {\n          logger.warn(\n            '[Recovery] markDone for missing RunRecord in Step3 failed:',\n            runId,\n            markDoneErr,\n          );\n        }\n        continue;\n      }\n\n      // 跳过已终态的 Run（可能在恢复过程中被其他逻辑更新）\n      // 同时清理队列项，防止残留\n      if (isTerminalStatus(run.status)) {\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n          logger.debug(\n            `[Recovery] Cleaned terminal queue item in Step3: ${runId} (status=${run.status})`,\n          );\n        } catch (markDoneErr) {\n          logger.warn('[Recovery] markDone for terminal run in Step3 failed:', runId, markDoneErr);\n        }\n        continue;\n      }\n\n      // 更新 RunRecord 状态为 queued\n      await deps.storage.runs.patch(runId, { status: 'queued', updatedAt: now });\n\n      // 发送恢复事件（best-effort，失败不影响恢复流程）\n      try {\n        const fromStatus: 'running' | 'paused' = run.status === 'paused' ? 'paused' : 'running';\n        await deps.events.append({\n          runId,\n          type: 'run.recovered',\n          reason: 'sw_restart',\n          fromStatus,\n          toStatus: 'queued',\n          prevOwnerId: entry.prevOwnerId,\n          ts: now,\n        });\n        logger.info(`[Recovery] Requeued orphan running run: ${runId} (from=${fromStatus})`);\n      } catch (eventErr) {\n        logger.warn('[Recovery] Failed to emit run.recovered event:', runId, eventErr);\n        // 继续执行，不影响恢复流程\n      }\n    } catch (e) {\n      logger.warn('[Recovery] Reconcile requeued running failed:', runId, e);\n    }\n  }\n\n  // ==================== Step 4: 同步 adopted paused 的 RunRecord ====================\n  const adoptedPausedIds: RunId[] = [];\n  for (const entry of adoptedPaused) {\n    const runId = entry.runId;\n    adoptedPausedIds.push(runId);\n\n    // 跳过在 Step 1 中已清理的项\n    if (cleanedTerminalSet.has(runId)) {\n      continue;\n    }\n\n    try {\n      const run = await deps.storage.runs.get(runId);\n      if (!run) {\n        // RunRecord 不存在，清理队列项（防御性）\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n        } catch (markDoneErr) {\n          logger.warn(\n            '[Recovery] markDone for missing RunRecord in Step4 failed:',\n            runId,\n            markDoneErr,\n          );\n        }\n        continue;\n      }\n\n      // 跳过已终态的 Run，同时清理队列项\n      if (isTerminalStatus(run.status)) {\n        try {\n          await deps.storage.queue.markDone(runId, now);\n          cleanedTerminalSet.add(runId);\n          logger.debug(\n            `[Recovery] Cleaned terminal queue item in Step4: ${runId} (status=${run.status})`,\n          );\n        } catch (markDoneErr) {\n          logger.warn('[Recovery] markDone for terminal run in Step4 failed:', runId, markDoneErr);\n        }\n        continue;\n      }\n\n      // 如果 RunRecord 状态不是 paused，同步更新\n      if (run.status !== 'paused') {\n        await deps.storage.runs.patch(runId, { status: 'paused' as RunStatus, updatedAt: now });\n      }\n\n      logger.info(`[Recovery] Adopted orphan paused run: ${runId}`);\n    } catch (e) {\n      logger.warn('[Recovery] Reconcile adopted paused failed:', runId, e);\n    }\n  }\n\n  const result: RecoveryResult = {\n    requeuedRunning: requeuedRunningIds,\n    adoptedPaused: adoptedPausedIds,\n    cleanedTerminal: Array.from(cleanedTerminalSet),\n  };\n\n  logger.info('[Recovery] Complete:', {\n    requeuedRunning: result.requeuedRunning.length,\n    adoptedPaused: result.adoptedPaused.length,\n    cleanedTerminal: result.cleanedTerminal.length,\n  });\n\n  return result;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/index.ts",
    "content": "/**\n * @fileoverview Engine Storage 模块导出入口\n */\n\nexport * from './storage-port';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/storage/storage-port.ts",
    "content": "/**\n * @fileoverview StoragePort 接口定义\n * @description 定义 Storage 层的抽象接口，用于依赖注入\n */\n\nimport type { FlowId, RunId, TriggerId } from '../../domain/ids';\nimport type { FlowV3 } from '../../domain/flow';\nimport type { RunEvent, RunEventInput, RunRecordV3 } from '../../domain/events';\nimport type { PersistentVarRecord, PersistentVariableName } from '../../domain/variables';\nimport type { TriggerSpec } from '../../domain/triggers';\nimport type { RunQueue } from '../queue/queue';\n\n/**\n * FlowsStore 接口\n */\nexport interface FlowsStore {\n  /** 列出所有 Flow */\n  list(): Promise<FlowV3[]>;\n  /** 获取单个 Flow */\n  get(id: FlowId): Promise<FlowV3 | null>;\n  /** 保存 Flow */\n  save(flow: FlowV3): Promise<void>;\n  /** 删除 Flow */\n  delete(id: FlowId): Promise<void>;\n}\n\n/**\n * RunsStore 接口\n */\nexport interface RunsStore {\n  /** 列出所有 Run 记录 */\n  list(): Promise<RunRecordV3[]>;\n  /** 获取单个 Run 记录 */\n  get(id: RunId): Promise<RunRecordV3 | null>;\n  /** 保存 Run 记录 */\n  save(record: RunRecordV3): Promise<void>;\n  /** 部分更新 Run 记录 */\n  patch(id: RunId, patch: Partial<RunRecordV3>): Promise<void>;\n}\n\n/**\n * EventsStore 接口\n * @description seq 分配必须由 append() 内部原子完成\n */\nexport interface EventsStore {\n  /**\n   * 追加事件并原子分配 seq\n   * @description 在单个事务中：读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq\n   * @param event 事件输入（不含 seq）\n   * @returns 完整事件（含分配的 seq 和 ts）\n   */\n  append(event: RunEventInput): Promise<RunEvent>;\n\n  /**\n   * 列出事件\n   * @param runId Run ID\n   * @param opts 查询选项\n   */\n  list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]>;\n}\n\n/**\n * PersistentVarsStore 接口\n */\nexport interface PersistentVarsStore {\n  /** 获取持久化变量 */\n  get(key: PersistentVariableName): Promise<PersistentVarRecord | undefined>;\n  /** 设置持久化变量 */\n  set(\n    key: PersistentVariableName,\n    value: PersistentVarRecord['value'],\n  ): Promise<PersistentVarRecord>;\n  /** 删除持久化变量 */\n  delete(key: PersistentVariableName): Promise<void>;\n  /** 列出持久化变量 */\n  list(prefix?: PersistentVariableName): Promise<PersistentVarRecord[]>;\n}\n\n/**\n * TriggersStore 接口\n */\nexport interface TriggersStore {\n  /** 列出所有触发器 */\n  list(): Promise<TriggerSpec[]>;\n  /** 获取单个触发器 */\n  get(id: TriggerId): Promise<TriggerSpec | null>;\n  /** 保存触发器 */\n  save(spec: TriggerSpec): Promise<void>;\n  /** 删除触发器 */\n  delete(id: TriggerId): Promise<void>;\n}\n\n/**\n * StoragePort 接口\n * @description 聚合所有存储接口，用于依赖注入\n */\nexport interface StoragePort {\n  /** Flows 存储 */\n  flows: FlowsStore;\n  /** Runs 存储 */\n  runs: RunsStore;\n  /** Events 存储 */\n  events: EventsStore;\n  /** Queue 存储 */\n  queue: RunQueue;\n  /** 持久化变量存储 */\n  persistentVars: PersistentVarsStore;\n  /** 触发器存储 */\n  triggers: TriggersStore;\n}\n\n/**\n * 创建 NotImplemented 的 Store\n * @description 避免 Proxy 生成 'then' 导致 thenable 行为\n */\nfunction createNotImplementedStore<T extends object>(name: string): T {\n  const target = {} as T;\n  return new Proxy(target, {\n    get(_, prop) {\n      // Avoid thenable behavior by returning undefined for 'then'\n      if (prop === 'then') {\n        return undefined;\n      }\n      return async () => {\n        throw new Error(`${name}.${String(prop)} not implemented`);\n      };\n    },\n  });\n}\n\n/**\n * 创建 NotImplemented 的 StoragePort\n * @description Phase 0 占位实现\n */\nexport function createNotImplementedStoragePort(): StoragePort {\n  return {\n    flows: createNotImplementedStore<FlowsStore>('FlowsStore'),\n    runs: createNotImplementedStore<RunsStore>('RunsStore'),\n    events: createNotImplementedStore<EventsStore>('EventsStore'),\n    queue: createNotImplementedStore<RunQueue>('RunQueue'),\n    persistentVars: createNotImplementedStore<PersistentVarsStore>('PersistentVarsStore'),\n    triggers: createNotImplementedStore<TriggersStore>('TriggersStore'),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/events-bus.ts",
    "content": "/**\n * @fileoverview EventsBus Interface and Implementation\n * @description Event subscription, publishing, and persistence\n */\n\nimport type { RunId } from '../../domain/ids';\nimport type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events';\nimport type { EventsStore } from '../storage/storage-port';\n\n/**\n * Event query parameters\n */\nexport interface EventsQuery {\n  /** Run ID */\n  runId: RunId;\n  /** Starting sequence number (inclusive) */\n  fromSeq?: number;\n  /** Maximum number of results */\n  limit?: number;\n}\n\n/**\n * Subscription filter\n */\nexport interface EventsFilter {\n  /** Only receive events for this Run */\n  runId?: RunId;\n}\n\n/**\n * EventsBus Interface\n * @description Responsible for event subscription, publishing, and persistence\n */\nexport interface EventsBus {\n  /**\n   * Subscribe to events\n   * @param listener Event listener\n   * @param filter Optional filter\n   * @returns Unsubscribe function\n   */\n  subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe;\n\n  /**\n   * Append event\n   * @description Delegates to EventsStore for atomic seq allocation, then broadcasts\n   * @param event Event input (without seq)\n   * @returns Complete event (with seq and ts)\n   */\n  append(event: RunEventInput): Promise<RunEvent>;\n\n  /**\n   * Query historical events\n   * @param query Query parameters\n   * @returns Events sorted by seq ascending\n   */\n  list(query: EventsQuery): Promise<RunEvent[]>;\n}\n\n/**\n * Create NotImplemented EventsBus\n * @description Phase 0 placeholder\n */\nexport function createNotImplementedEventsBus(): EventsBus {\n  const notImplemented = () => {\n    throw new Error('EventsBus not implemented');\n  };\n\n  return {\n    subscribe: () => {\n      notImplemented();\n      return () => {};\n    },\n    append: async () => notImplemented(),\n    list: async () => notImplemented(),\n  };\n}\n\n/**\n * Listener entry for subscription management\n */\ninterface ListenerEntry {\n  listener: (event: RunEvent) => void;\n  filter?: EventsFilter;\n}\n\n/**\n * Storage-backed EventsBus Implementation\n * @description\n * - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq)\n * - broadcast happens only after append resolves (i.e. after commit)\n */\nexport class StorageBackedEventsBus implements EventsBus {\n  private listeners = new Set<ListenerEntry>();\n\n  constructor(private readonly store: EventsStore) {}\n\n  subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {\n    const entry: ListenerEntry = { listener, filter };\n    this.listeners.add(entry);\n    return () => {\n      this.listeners.delete(entry);\n    };\n  }\n\n  async append(input: RunEventInput): Promise<RunEvent> {\n    // Delegate to storage for atomic seq allocation\n    const event = await this.store.append(input);\n\n    // Broadcast after successful commit\n    this.broadcast(event);\n\n    return event;\n  }\n\n  async list(query: EventsQuery): Promise<RunEvent[]> {\n    return this.store.list(query.runId, {\n      fromSeq: query.fromSeq,\n      limit: query.limit,\n    });\n  }\n\n  /**\n   * Broadcast event to all matching listeners\n   */\n  private broadcast(event: RunEvent): void {\n    const { runId } = event;\n    for (const { listener, filter } of this.listeners) {\n      if (!filter || !filter.runId || filter.runId === runId) {\n        try {\n          listener(event);\n        } catch (error) {\n          console.error('[StorageBackedEventsBus] Listener error:', error);\n        }\n      }\n    }\n  }\n}\n\n/**\n * In-memory EventsBus for testing\n * @description Uses internal seq counter, NOT suitable for production\n * @deprecated Use StorageBackedEventsBus with mock EventsStore for testing\n */\nexport class InMemoryEventsBus implements EventsBus {\n  private events = new Map<RunId, RunEvent[]>();\n  private seqCounters = new Map<RunId, number>();\n  private listeners = new Set<ListenerEntry>();\n\n  subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {\n    const entry: ListenerEntry = { listener, filter };\n    this.listeners.add(entry);\n    return () => {\n      this.listeners.delete(entry);\n    };\n  }\n\n  async append(input: RunEventInput): Promise<RunEvent> {\n    const { runId } = input;\n\n    // Allocate seq (NOT atomic, for testing only)\n    const currentSeq = this.seqCounters.get(runId) ?? 0;\n    const seq = currentSeq + 1;\n    this.seqCounters.set(runId, seq);\n\n    // Create complete event\n    const event: RunEvent = {\n      ...input,\n      seq,\n      ts: input.ts ?? Date.now(),\n    } as RunEvent;\n\n    // Store\n    const runEvents = this.events.get(runId) ?? [];\n    runEvents.push(event);\n    this.events.set(runId, runEvents);\n\n    // Broadcast\n    for (const { listener, filter } of this.listeners) {\n      if (!filter || !filter.runId || filter.runId === runId) {\n        try {\n          listener(event);\n        } catch (error) {\n          console.error('[InMemoryEventsBus] Listener error:', error);\n        }\n      }\n    }\n\n    return event;\n  }\n\n  async list(query: EventsQuery): Promise<RunEvent[]> {\n    const runEvents = this.events.get(query.runId) ?? [];\n\n    let result = runEvents;\n\n    if (query.fromSeq !== undefined) {\n      result = result.filter((e) => e.seq >= query.fromSeq!);\n    }\n\n    if (query.limit !== undefined) {\n      result = result.slice(0, query.limit);\n    }\n\n    return result;\n  }\n\n  /**\n   * Clear all data (for testing)\n   */\n  clear(): void {\n    this.events.clear();\n    this.seqCounters.clear();\n    this.listeners.clear();\n  }\n\n  /**\n   * Get current seq for a run (for testing)\n   */\n  getSeq(runId: RunId): number {\n    return this.seqCounters.get(runId) ?? 0;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/index.ts",
    "content": "/**\n * @fileoverview Transport 模块导出入口\n */\n\nexport * from './rpc';\nexport * from './rpc-server';\nexport * from './events-bus';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc-server.ts",
    "content": "/**\n * @fileoverview RPC Server Implementation\n * @description Handles RPC requests from UI via chrome.runtime.Port\n */\n\nimport type { ISODateTimeString, JsonObject, JsonValue } from '../../domain/json';\nimport type { EdgeId, FlowId, NodeId, RunId, TriggerId } from '../../domain/ids';\nimport type { DebuggerCommand } from '../../domain/debug';\nimport type { RunEvent } from '../../domain/events';\nimport type { FlowV3, NodeV3, EdgeV3 } from '../../domain/flow';\nimport { FLOW_SCHEMA_VERSION as CURRENT_FLOW_SCHEMA_VERSION } from '../../domain/flow';\nimport type { VariableDefinition } from '../../domain/variables';\nimport type { TriggerKind, TriggerSpec } from '../../domain/triggers';\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from './events-bus';\nimport type { DebugController, RunnerRegistry } from '../kernel/debug-controller';\nimport type { RunScheduler } from '../queue/scheduler';\nimport type { QueueItemStatus } from '../queue/queue';\nimport { enqueueRun } from '../queue/enqueue-run';\nimport type { TriggerManager } from '../triggers/trigger-manager';\nimport {\n  RR_V3_PORT_NAME,\n  isRpcRequest,\n  createRpcResponseOk,\n  createRpcResponseErr,\n  createRpcEventMessage,\n  type RpcRequest,\n} from './rpc';\n\n/**\n * RPC Server 配置\n */\nexport interface RpcServerConfig {\n  storage: StoragePort;\n  events: EventsBus;\n  debugController?: DebugController;\n  runners?: RunnerRegistry;\n  scheduler?: RunScheduler;\n  triggerManager?: TriggerManager;\n  /** ID 生成器（用于测试注入） */\n  generateRunId?: () => RunId;\n  /** 时间源（用于测试注入） */\n  now?: () => number;\n}\n\n/**\n * 活跃的 Port 连接\n */\ninterface PortConnection {\n  port: chrome.runtime.Port;\n  subscriptions: Set<RunId | null>; // null means subscribe to all\n}\n\n/**\n * 默认 RunId 生成器\n */\nfunction defaultGenerateRunId(): RunId {\n  return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\n/**\n * RPC Server\n * @description 处理来自 UI 的 RPC 请求\n */\nexport class RpcServer {\n  private readonly storage: StoragePort;\n  private readonly events: EventsBus;\n  private readonly debugController?: DebugController;\n  private readonly runners?: RunnerRegistry;\n  private readonly scheduler?: RunScheduler;\n  private readonly triggerManager?: TriggerManager;\n  private readonly generateRunId: () => RunId;\n  private readonly now: () => number;\n  private readonly connections = new Map<string, PortConnection>();\n  private eventUnsubscribe: (() => void) | null = null;\n\n  constructor(config: RpcServerConfig) {\n    this.storage = config.storage;\n    this.events = config.events;\n    this.debugController = config.debugController;\n    this.runners = config.runners;\n    this.scheduler = config.scheduler;\n    this.triggerManager = config.triggerManager;\n    this.generateRunId = config.generateRunId ?? defaultGenerateRunId;\n    this.now = config.now ?? Date.now;\n  }\n\n  /**\n   * 启动 RPC Server\n   */\n  start(): void {\n    chrome.runtime.onConnect.addListener(this.handleConnect);\n\n    // Subscribe to all events and broadcast to connected ports\n    this.eventUnsubscribe = this.events.subscribe((event) => {\n      this.broadcastEvent(event);\n    });\n  }\n\n  /**\n   * 停止 RPC Server\n   */\n  stop(): void {\n    chrome.runtime.onConnect.removeListener(this.handleConnect);\n\n    if (this.eventUnsubscribe) {\n      this.eventUnsubscribe();\n      this.eventUnsubscribe = null;\n    }\n\n    // Disconnect all ports\n    for (const conn of this.connections.values()) {\n      conn.port.disconnect();\n    }\n    this.connections.clear();\n  }\n\n  /**\n   * 处理新连接\n   */\n  private handleConnect = (port: chrome.runtime.Port): void => {\n    if (port.name !== RR_V3_PORT_NAME) return;\n\n    const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n    const connection: PortConnection = {\n      port,\n      subscriptions: new Set(),\n    };\n\n    this.connections.set(connId, connection);\n\n    port.onMessage.addListener((msg) => this.handleMessage(connId, msg));\n    port.onDisconnect.addListener(() => this.handleDisconnect(connId));\n  };\n\n  /**\n   * 处理消息\n   */\n  private handleMessage = async (connId: string, msg: unknown): Promise<void> => {\n    if (!isRpcRequest(msg)) return;\n\n    const conn = this.connections.get(connId);\n    if (!conn) return;\n\n    try {\n      const result = await this.handleRequest(msg, conn);\n      conn.port.postMessage(createRpcResponseOk(msg.requestId, result));\n    } catch (e) {\n      const error = e instanceof Error ? e.message : String(e);\n      conn.port.postMessage(createRpcResponseErr(msg.requestId, error));\n    }\n  };\n\n  /**\n   * 处理断开连接\n   */\n  private handleDisconnect = (connId: string): void => {\n    this.connections.delete(connId);\n  };\n\n  /**\n   * 广播事件\n   */\n  private broadcastEvent(event: RunEvent): void {\n    const message = createRpcEventMessage(event);\n\n    for (const conn of this.connections.values()) {\n      // Check if this connection subscribed to this event\n      const subs = conn.subscriptions;\n      if (subs.size === 0) continue; // No subscriptions\n      if (subs.has(null) || subs.has(event.runId)) {\n        try {\n          conn.port.postMessage(message);\n        } catch {\n          // Port may be disconnected\n        }\n      }\n    }\n  }\n\n  // ===== Queue Management Handlers =====\n\n  /**\n   * 处理 enqueueRun 请求\n   * @description 委托给共享的 enqueueRun 服务\n   */\n  private async handleEnqueueRun(params: JsonObject | undefined): Promise<JsonValue> {\n    const result = await enqueueRun(\n      {\n        storage: this.storage,\n        events: this.events,\n        scheduler: this.scheduler,\n        generateRunId: this.generateRunId,\n        now: this.now,\n      },\n      {\n        flowId: params?.flowId as FlowId,\n        startNodeId: params?.startNodeId as NodeId | undefined,\n        priority: params?.priority as number | undefined,\n        maxAttempts: params?.maxAttempts as number | undefined,\n        args: params?.args as JsonObject | undefined,\n        debug: params?.debug as { breakpoints?: string[]; pauseOnStart?: boolean } | undefined,\n      },\n    );\n\n    return result as unknown as JsonValue;\n  }\n\n  /**\n   * 处理 listQueue 请求\n   * @description 列出队列项，按 priority DESC + createdAt ASC 排序\n   */\n  private async handleListQueue(params: JsonObject | undefined): Promise<JsonValue> {\n    const rawStatus = params?.status;\n\n    // 校验 status 白名单\n    let status: QueueItemStatus | undefined;\n    if (rawStatus !== undefined) {\n      if (rawStatus !== 'queued' && rawStatus !== 'running' && rawStatus !== 'paused') {\n        throw new Error('status must be one of: queued, running, paused');\n      }\n      status = rawStatus;\n    }\n\n    const items = await this.storage.queue.list(status);\n\n    // 按 priority DESC + createdAt ASC 排序\n    items.sort((a, b) => {\n      if (a.priority !== b.priority) {\n        return b.priority - a.priority; // DESC\n      }\n      return a.createdAt - b.createdAt; // ASC (FIFO)\n    });\n\n    return items as unknown as JsonValue;\n  }\n\n  /**\n   * 处理 cancelQueueItem 请求\n   * @description 取消排队中的队列项，更新 Run 状态，发布 run.canceled 事件\n   * @note 仅允许取消 status=queued 的项；running/paused 需使用 rr_v3.cancelRun\n   */\n  private async handleCancelQueueItem(params: JsonObject | undefined): Promise<JsonValue> {\n    const runId = params?.runId as RunId | undefined;\n    if (!runId) throw new Error('runId is required');\n\n    const reason = params?.reason as string | undefined;\n    const now = this.now();\n\n    // 1. 检查队列项存在\n    const queueItem = await this.storage.queue.get(runId);\n    if (!queueItem) {\n      throw new Error(`Queue item \"${runId}\" not found`);\n    }\n\n    // 2. 仅允许取消 queued 状态（running/paused 需使用 rr_v3.cancelRun）\n    if (queueItem.status !== 'queued') {\n      throw new Error(\n        `Cannot cancel queue item \"${runId}\" with status \"${queueItem.status}\"; use rr_v3.cancelRun for running/paused runs`,\n      );\n    }\n\n    // 3. 从队列移除\n    await this.storage.queue.cancel(runId, now, reason);\n\n    // 4. 更新 Run 记录状态\n    await this.storage.runs.patch(runId, {\n      status: 'canceled',\n      updatedAt: now,\n      finishedAt: now,\n    });\n\n    // 5. 发布 run.canceled 事件（通过 EventsBus 以确保广播）\n    await this.events.append({\n      runId,\n      type: 'run.canceled',\n      reason,\n    });\n\n    return { ok: true, runId };\n  }\n\n  /**\n   * 处理 RPC 请求\n   */\n  private async handleRequest(request: RpcRequest, conn: PortConnection): Promise<JsonValue> {\n    const { method, params } = request;\n\n    switch (method) {\n      case 'rr_v3.listRuns': {\n        const runs = await this.storage.runs.list();\n        return runs as unknown as JsonValue;\n      }\n\n      case 'rr_v3.getRun': {\n        const runId = params?.runId as RunId | undefined;\n        if (!runId) throw new Error('runId is required');\n        const run = await this.storage.runs.get(runId);\n        return run as unknown as JsonValue;\n      }\n\n      case 'rr_v3.getEvents': {\n        const runId = params?.runId as RunId | undefined;\n        if (!runId) throw new Error('runId is required');\n        const fromSeq = params?.fromSeq as number | undefined;\n        const limit = params?.limit as number | undefined;\n        const events = await this.storage.events.list(runId, { fromSeq, limit });\n        return events as unknown as JsonValue;\n      }\n\n      case 'rr_v3.getFlow': {\n        const flowId = params?.flowId as FlowId | undefined;\n        if (!flowId) throw new Error('flowId is required');\n        const flow = await this.storage.flows.get(flowId);\n        return flow as unknown as JsonValue;\n      }\n\n      case 'rr_v3.listFlows': {\n        const flows = await this.storage.flows.list();\n        return flows as unknown as JsonValue;\n      }\n\n      case 'rr_v3.saveFlow': {\n        return this.handleSaveFlow(params);\n      }\n\n      case 'rr_v3.deleteFlow': {\n        return this.handleDeleteFlow(params);\n      }\n\n      // ===== Trigger APIs =====\n\n      case 'rr_v3.createTrigger':\n        return this.handleCreateTrigger(params);\n\n      case 'rr_v3.updateTrigger':\n        return this.handleUpdateTrigger(params);\n\n      case 'rr_v3.deleteTrigger':\n        return this.handleDeleteTrigger(params);\n\n      case 'rr_v3.getTrigger':\n        return this.handleGetTrigger(params);\n\n      case 'rr_v3.listTriggers':\n        return this.handleListTriggers(params);\n\n      case 'rr_v3.enableTrigger':\n        return this.handleEnableTrigger(params);\n\n      case 'rr_v3.disableTrigger':\n        return this.handleDisableTrigger(params);\n\n      case 'rr_v3.fireTrigger':\n        return this.handleFireTrigger(params);\n\n      // ===== Queue Management APIs =====\n\n      case 'rr_v3.enqueueRun': {\n        return this.handleEnqueueRun(params);\n      }\n\n      case 'rr_v3.listQueue': {\n        return this.handleListQueue(params);\n      }\n\n      case 'rr_v3.cancelQueueItem': {\n        return this.handleCancelQueueItem(params);\n      }\n\n      case 'rr_v3.subscribe': {\n        const runId = (params?.runId as RunId | undefined) ?? null;\n        conn.subscriptions.add(runId);\n        return { subscribed: true, runId };\n      }\n\n      case 'rr_v3.unsubscribe': {\n        const runId = (params?.runId as RunId | undefined) ?? null;\n        conn.subscriptions.delete(runId);\n        return { unsubscribed: true, runId };\n      }\n\n      // Debug method - route to DebugController\n      case 'rr_v3.debug': {\n        if (!this.debugController) {\n          throw new Error('DebugController not configured');\n        }\n        const cmd = params as unknown as DebuggerCommand;\n        if (!cmd || !cmd.type) {\n          throw new Error('Invalid debug command');\n        }\n        const response = await this.debugController.handle(cmd);\n        return response as unknown as JsonValue;\n      }\n\n      // Control methods\n      case 'rr_v3.startRun':\n        // startRun is essentially enqueueRun - the run starts when claimed by scheduler\n        return this.handleEnqueueRun(params);\n\n      case 'rr_v3.pauseRun':\n        return this.handlePauseRun(params);\n\n      case 'rr_v3.resumeRun':\n        return this.handleResumeRun(params);\n\n      case 'rr_v3.cancelRun':\n        return this.handleCancelRun(params);\n\n      default:\n        throw new Error(`Unknown method: ${method}`);\n    }\n  }\n\n  // ===== Flow Management Handlers =====\n\n  /**\n   * 处理 saveFlow 请求\n   * @description 保存或更新 Flow，执行完整的结构验证\n   */\n  private async handleSaveFlow(params: JsonObject | undefined): Promise<JsonValue> {\n    const rawFlow = params?.flow;\n    if (!rawFlow || typeof rawFlow !== 'object' || Array.isArray(rawFlow)) {\n      throw new Error('flow is required');\n    }\n\n    // 检查是否为更新现有 flow（使用 trim 后的 ID 查询）\n    const rawId = (rawFlow as JsonObject).id;\n    let existingFlow: FlowV3 | null = null;\n    if (typeof rawId === 'string' && rawId.trim()) {\n      existingFlow = await this.storage.flows.get(rawId.trim() as FlowId);\n    }\n\n    // 规范化 flow，传入 existingFlow 以继承 createdAt\n    const flow = this.normalizeFlowSpec(rawFlow, existingFlow);\n\n    // 保存到存储（存储层会执行二次验证）\n    await this.storage.flows.save(flow);\n\n    return flow as unknown as JsonValue;\n  }\n\n  /**\n   * 处理 deleteFlow 请求\n   * @description 删除 Flow，先检查是否有关联的 Trigger 和 queued runs\n   */\n  private async handleDeleteFlow(params: JsonObject | undefined): Promise<JsonValue> {\n    const flowId = params?.flowId as FlowId | undefined;\n    if (!flowId) throw new Error('flowId is required');\n\n    // 检查 Flow 是否存在\n    const existing = await this.storage.flows.get(flowId);\n    if (!existing) {\n      throw new Error(`Flow \"${flowId}\" not found`);\n    }\n\n    // 检查是否有关联的 Trigger\n    const triggers = await this.storage.triggers.list();\n    const linkedTriggers = triggers.filter((t) => t.flowId === flowId);\n    if (linkedTriggers.length > 0) {\n      const triggerIds = linkedTriggers.map((t) => t.id).join(', ');\n      throw new Error(\n        `Cannot delete flow \"${flowId}\": it has ${linkedTriggers.length} linked trigger(s): ${triggerIds}. ` +\n          `Delete the trigger(s) first.`,\n      );\n    }\n\n    // 检查是否有 queued runs（未执行的 runs 删除后会失败）\n    const queuedItems = await this.storage.queue.list('queued');\n    const linkedQueuedRuns = queuedItems.filter((item) => item.flowId === flowId);\n    if (linkedQueuedRuns.length > 0) {\n      const runIds = linkedQueuedRuns.map((r) => r.id).join(', ');\n      throw new Error(\n        `Cannot delete flow \"${flowId}\": it has ${linkedQueuedRuns.length} queued run(s): ${runIds}. ` +\n          `Cancel the run(s) first or wait for them to complete.`,\n      );\n    }\n\n    // 删除 Flow\n    await this.storage.flows.delete(flowId);\n\n    return { ok: true, flowId };\n  }\n\n  /**\n   * 规范化 FlowV3 输入\n   * @description 验证并转换输入为完整的 FlowV3 结构\n   * @param value 原始输入\n   * @param existingFlow 已存在的 flow（用于继承 createdAt）\n   */\n  private normalizeFlowSpec(value: unknown, existingFlow: FlowV3 | null = null): FlowV3 {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) {\n      throw new Error('flow is required');\n    }\n    const raw = value as JsonObject;\n\n    // id 校验与生成\n    let id: FlowId;\n    if (raw.id === undefined || raw.id === null) {\n      id = `flow_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` as FlowId;\n    } else {\n      if (typeof raw.id !== 'string' || !raw.id.trim()) {\n        throw new Error('flow.id must be a non-empty string');\n      }\n      id = raw.id.trim() as FlowId;\n    }\n\n    // name 校验\n    if (!raw.name || typeof raw.name !== 'string' || !raw.name.trim()) {\n      throw new Error('flow.name is required');\n    }\n    const name = raw.name.trim();\n\n    // description 校验\n    let description: string | undefined;\n    if (raw.description !== undefined && raw.description !== null) {\n      if (typeof raw.description !== 'string') {\n        throw new Error('flow.description must be a string');\n      }\n      description = raw.description;\n    }\n\n    // entryNodeId 校验\n    if (!raw.entryNodeId || typeof raw.entryNodeId !== 'string' || !raw.entryNodeId.trim()) {\n      throw new Error('flow.entryNodeId is required');\n    }\n    const entryNodeId = raw.entryNodeId.trim() as NodeId;\n\n    // nodes 校验\n    if (!Array.isArray(raw.nodes)) {\n      throw new Error('flow.nodes must be an array');\n    }\n    const nodes = raw.nodes.map((n, i) => this.normalizeNode(n, i));\n\n    // 验证 node ID 唯一性\n    const nodeIdSet = new Set<string>();\n    for (const node of nodes) {\n      if (nodeIdSet.has(node.id)) {\n        throw new Error(`Duplicate node ID: \"${node.id}\"`);\n      }\n      nodeIdSet.add(node.id);\n    }\n\n    // edges 校验\n    let edges: EdgeV3[] = [];\n    if (raw.edges !== undefined && raw.edges !== null) {\n      if (!Array.isArray(raw.edges)) {\n        throw new Error('flow.edges must be an array');\n      }\n      edges = raw.edges.map((e, i) => this.normalizeEdge(e, i));\n    }\n\n    // 验证 edge ID 唯一性\n    const edgeIdSet = new Set<string>();\n    for (const edge of edges) {\n      if (edgeIdSet.has(edge.id)) {\n        throw new Error(`Duplicate edge ID: \"${edge.id}\"`);\n      }\n      edgeIdSet.add(edge.id);\n    }\n\n    // 验证 entryNodeId 存在\n    if (!nodeIdSet.has(entryNodeId)) {\n      throw new Error(`Entry node \"${entryNodeId}\" does not exist in flow`);\n    }\n\n    // 验证边引用\n    for (const edge of edges) {\n      if (!nodeIdSet.has(edge.from)) {\n        throw new Error(`Edge \"${edge.id}\" references non-existent source node \"${edge.from}\"`);\n      }\n      if (!nodeIdSet.has(edge.to)) {\n        throw new Error(`Edge \"${edge.id}\" references non-existent target node \"${edge.to}\"`);\n      }\n    }\n\n    // 时间戳：更新时继承 existingFlow.createdAt，新建时用当前时间\n    const now = new Date(this.now()).toISOString() as ISODateTimeString;\n    const createdAt = existingFlow?.createdAt ?? now;\n    const updatedAt = now;\n\n    // 构建完整的 FlowV3\n    const flow: FlowV3 = {\n      schemaVersion: CURRENT_FLOW_SCHEMA_VERSION,\n      id,\n      name,\n      createdAt,\n      updatedAt,\n      entryNodeId,\n      nodes,\n      edges,\n    };\n\n    // 可选字段\n    if (description !== undefined) {\n      flow.description = description;\n    }\n\n    // variables 验证：每项必须是 object 且有 name 字段\n    if (raw.variables !== undefined && raw.variables !== null) {\n      if (!Array.isArray(raw.variables)) {\n        throw new Error('flow.variables must be an array');\n      }\n      const variables: VariableDefinition[] = [];\n      const varNameSet = new Set<string>();\n      for (let i = 0; i < raw.variables.length; i++) {\n        const v = raw.variables[i];\n        if (!v || typeof v !== 'object' || Array.isArray(v)) {\n          throw new Error(`flow.variables[${i}] must be an object`);\n        }\n        const varObj = v as JsonObject;\n        if (!varObj.name || typeof varObj.name !== 'string' || !varObj.name.trim()) {\n          throw new Error(`flow.variables[${i}].name is required`);\n        }\n        const varName = varObj.name.trim();\n        if (varNameSet.has(varName)) {\n          throw new Error(`Duplicate variable name: \"${varName}\"`);\n        }\n        varNameSet.add(varName);\n        // 使用 trim 后的 name\n        variables.push({ ...varObj, name: varName } as unknown as VariableDefinition);\n      }\n      if (variables.length > 0) {\n        flow.variables = variables;\n      }\n    }\n\n    if (raw.policy !== undefined && raw.policy !== null) {\n      if (typeof raw.policy !== 'object' || Array.isArray(raw.policy)) {\n        throw new Error('flow.policy must be an object');\n      }\n      flow.policy = raw.policy as FlowV3['policy'];\n    }\n    if (raw.meta !== undefined && raw.meta !== null) {\n      if (typeof raw.meta !== 'object' || Array.isArray(raw.meta)) {\n        throw new Error('flow.meta must be an object');\n      }\n      flow.meta = raw.meta as FlowV3['meta'];\n    }\n\n    return flow;\n  }\n\n  /**\n   * 规范化 Node 输入\n   */\n  private normalizeNode(value: unknown, index: number): NodeV3 {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) {\n      throw new Error(`flow.nodes[${index}] must be an object`);\n    }\n    const raw = value as JsonObject;\n\n    // id 校验（非空 + trim）\n    if (!raw.id || typeof raw.id !== 'string' || !raw.id.trim()) {\n      throw new Error(`flow.nodes[${index}].id is required`);\n    }\n    const nodeId = raw.id.trim() as NodeId;\n\n    // kind 校验（非空 + trim）\n    if (!raw.kind || typeof raw.kind !== 'string' || !raw.kind.trim()) {\n      throw new Error(`flow.nodes[${index}].kind is required`);\n    }\n    const kind = raw.kind.trim();\n\n    // config 校验\n    if (raw.config !== undefined && raw.config !== null) {\n      if (typeof raw.config !== 'object' || Array.isArray(raw.config)) {\n        throw new Error(`flow.nodes[${index}].config must be an object`);\n      }\n    }\n\n    const node: NodeV3 = {\n      id: nodeId,\n      kind,\n      config: (raw.config as JsonObject) ?? {},\n    };\n\n    // 可选字段\n    if (raw.name !== undefined && raw.name !== null) {\n      if (typeof raw.name !== 'string') {\n        throw new Error(`flow.nodes[${index}].name must be a string`);\n      }\n      node.name = raw.name;\n    }\n    if (raw.disabled !== undefined && raw.disabled !== null) {\n      if (typeof raw.disabled !== 'boolean') {\n        throw new Error(`flow.nodes[${index}].disabled must be a boolean`);\n      }\n      node.disabled = raw.disabled;\n    }\n    if (raw.policy !== undefined && raw.policy !== null) {\n      if (typeof raw.policy !== 'object' || Array.isArray(raw.policy)) {\n        throw new Error(`flow.nodes[${index}].policy must be an object`);\n      }\n      node.policy = raw.policy as NodeV3['policy'];\n    }\n    if (raw.ui !== undefined && raw.ui !== null) {\n      if (typeof raw.ui !== 'object' || Array.isArray(raw.ui)) {\n        throw new Error(`flow.nodes[${index}].ui must be an object`);\n      }\n      node.ui = raw.ui as NodeV3['ui'];\n    }\n\n    return node;\n  }\n\n  /**\n   * 规范化 Edge 输入\n   */\n  private normalizeEdge(value: unknown, index: number): EdgeV3 {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) {\n      throw new Error(`flow.edges[${index}] must be an object`);\n    }\n    const raw = value as JsonObject;\n\n    // id 校验或生成（非空 + trim）\n    let id: EdgeId;\n    if (raw.id === undefined || raw.id === null) {\n      id = `edge_${index}_${Math.random().toString(36).slice(2, 8)}` as EdgeId;\n    } else {\n      if (typeof raw.id !== 'string' || !raw.id.trim()) {\n        throw new Error(`flow.edges[${index}].id must be a non-empty string`);\n      }\n      id = raw.id.trim() as EdgeId;\n    }\n\n    // from 校验（非空 + trim）\n    if (!raw.from || typeof raw.from !== 'string' || !raw.from.trim()) {\n      throw new Error(`flow.edges[${index}].from is required`);\n    }\n    const from = raw.from.trim() as NodeId;\n\n    // to 校验（非空 + trim）\n    if (!raw.to || typeof raw.to !== 'string' || !raw.to.trim()) {\n      throw new Error(`flow.edges[${index}].to is required`);\n    }\n    const to = raw.to.trim() as NodeId;\n\n    const edge: EdgeV3 = {\n      id,\n      from,\n      to,\n    };\n\n    // label 可选\n    if (raw.label !== undefined && raw.label !== null) {\n      if (typeof raw.label !== 'string') {\n        throw new Error(`flow.edges[${index}].label must be a string`);\n      }\n      edge.label = raw.label as EdgeV3['label'];\n    }\n\n    return edge;\n  }\n\n  // ===== Trigger Management Handlers =====\n\n  private requireTriggerManager(): TriggerManager {\n    if (!this.triggerManager) {\n      throw new Error('TriggerManager not configured');\n    }\n    return this.triggerManager;\n  }\n\n  private async handleCreateTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const trigger = this.normalizeTriggerSpec(params?.trigger, { requireId: false });\n\n    const existing = await this.storage.triggers.get(trigger.id);\n    if (existing) {\n      throw new Error(`Trigger \"${trigger.id}\" already exists`);\n    }\n\n    const flow = await this.storage.flows.get(trigger.flowId);\n    if (!flow) {\n      throw new Error(`Flow \"${trigger.flowId}\" not found`);\n    }\n\n    await this.storage.triggers.save(trigger);\n    await this.requireTriggerManager().refresh();\n    return trigger as unknown as JsonValue;\n  }\n\n  private async handleUpdateTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const trigger = this.normalizeTriggerSpec(params?.trigger, { requireId: true });\n\n    const existing = await this.storage.triggers.get(trigger.id);\n    if (!existing) {\n      throw new Error(`Trigger \"${trigger.id}\" not found`);\n    }\n\n    const flow = await this.storage.flows.get(trigger.flowId);\n    if (!flow) {\n      throw new Error(`Flow \"${trigger.flowId}\" not found`);\n    }\n\n    await this.storage.triggers.save(trigger);\n    await this.requireTriggerManager().refresh();\n    return trigger as unknown as JsonValue;\n  }\n\n  private async handleDeleteTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const triggerId = params?.triggerId as TriggerId | undefined;\n    if (!triggerId) throw new Error('triggerId is required');\n\n    await this.storage.triggers.delete(triggerId);\n    await this.requireTriggerManager().refresh();\n    return { ok: true, triggerId };\n  }\n\n  private async handleGetTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const triggerId = params?.triggerId as TriggerId | undefined;\n    if (!triggerId) throw new Error('triggerId is required');\n    const trigger = await this.storage.triggers.get(triggerId);\n    return trigger as unknown as JsonValue;\n  }\n\n  private async handleListTriggers(params: JsonObject | undefined): Promise<JsonValue> {\n    const flowIdValue = params?.flowId;\n    let flowId: FlowId | undefined;\n    if (flowIdValue !== undefined && flowIdValue !== null) {\n      if (typeof flowIdValue !== 'string') {\n        throw new Error('flowId must be a string');\n      }\n      flowId = flowIdValue as FlowId;\n    }\n\n    const triggers = await this.storage.triggers.list();\n    const filtered = flowId ? triggers.filter((t) => t.flowId === flowId) : triggers;\n    return filtered as unknown as JsonValue;\n  }\n\n  private async handleEnableTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const triggerId = params?.triggerId as TriggerId | undefined;\n    if (!triggerId) throw new Error('triggerId is required');\n\n    const trigger = await this.storage.triggers.get(triggerId);\n    if (!trigger) {\n      throw new Error(`Trigger \"${triggerId}\" not found`);\n    }\n\n    const updated: TriggerSpec = { ...trigger, enabled: true };\n    await this.storage.triggers.save(updated);\n    await this.requireTriggerManager().refresh();\n    return updated as unknown as JsonValue;\n  }\n\n  private async handleDisableTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const triggerId = params?.triggerId as TriggerId | undefined;\n    if (!triggerId) throw new Error('triggerId is required');\n\n    const trigger = await this.storage.triggers.get(triggerId);\n    if (!trigger) {\n      throw new Error(`Trigger \"${triggerId}\" not found`);\n    }\n\n    const updated: TriggerSpec = { ...trigger, enabled: false };\n    await this.storage.triggers.save(updated);\n    await this.requireTriggerManager().refresh();\n    return updated as unknown as JsonValue;\n  }\n\n  private async handleFireTrigger(params: JsonObject | undefined): Promise<JsonValue> {\n    const triggerId = params?.triggerId as TriggerId | undefined;\n    if (!triggerId) throw new Error('triggerId is required');\n\n    const trigger = await this.storage.triggers.get(triggerId);\n    if (!trigger) {\n      throw new Error(`Trigger \"${triggerId}\" not found`);\n    }\n    if (trigger.kind !== 'manual') {\n      throw new Error(`fireTrigger only supports manual triggers (got kind=\"${trigger.kind}\")`);\n    }\n    if (!trigger.enabled) {\n      throw new Error(`Trigger \"${triggerId}\" is disabled`);\n    }\n\n    let sourceTabId: number | undefined;\n    if (params?.sourceTabId !== undefined && params?.sourceTabId !== null) {\n      if (typeof params.sourceTabId !== 'number' || !Number.isFinite(params.sourceTabId)) {\n        throw new Error('sourceTabId must be a finite number');\n      }\n      sourceTabId = Math.floor(params.sourceTabId);\n    }\n\n    let sourceUrl: string | undefined;\n    if (params?.sourceUrl !== undefined && params?.sourceUrl !== null) {\n      if (typeof params.sourceUrl !== 'string') {\n        throw new Error('sourceUrl must be a string');\n      }\n      sourceUrl = params.sourceUrl;\n    }\n\n    const result = await this.requireTriggerManager().fire(triggerId, {\n      sourceTabId,\n      sourceUrl,\n    });\n    return result as unknown as JsonValue;\n  }\n\n  /**\n   * 规范化 TriggerSpec 输入\n   */\n  private normalizeTriggerSpec(value: unknown, opts: { requireId: boolean }): TriggerSpec {\n    if (!value || typeof value !== 'object' || Array.isArray(value)) {\n      throw new Error('trigger is required');\n    }\n    const raw = value as JsonObject;\n\n    // kind 校验\n    const kind = raw.kind;\n    if (!kind || typeof kind !== 'string') {\n      throw new Error('trigger.kind is required');\n    }\n\n    // flowId 校验\n    const flowId = raw.flowId;\n    if (!flowId || typeof flowId !== 'string') {\n      throw new Error('trigger.flowId is required');\n    }\n\n    // id 校验\n    let id: TriggerId;\n    if (raw.id === undefined || raw.id === null) {\n      if (opts.requireId) {\n        throw new Error('trigger.id is required');\n      }\n      id = `trg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` as TriggerId;\n    } else {\n      if (typeof raw.id !== 'string' || !raw.id.trim()) {\n        throw new Error('trigger.id must be a non-empty string');\n      }\n      id = raw.id as TriggerId;\n    }\n\n    // enabled 校验\n    let enabled = true;\n    if (raw.enabled !== undefined && raw.enabled !== null) {\n      if (typeof raw.enabled !== 'boolean') {\n        throw new Error('trigger.enabled must be a boolean');\n      }\n      enabled = raw.enabled;\n    }\n\n    // args 校验\n    let args: JsonObject | undefined;\n    if (raw.args !== undefined && raw.args !== null) {\n      if (typeof raw.args !== 'object' || Array.isArray(raw.args)) {\n        throw new Error('trigger.args must be an object');\n      }\n      args = raw.args as JsonObject;\n    }\n\n    // 基础字段\n    const base = { id, kind: kind as TriggerKind, enabled, flowId: flowId as FlowId, args };\n\n    // 根据 kind 添加特定字段\n    switch (kind) {\n      case 'manual':\n        return base as TriggerSpec;\n\n      case 'url': {\n        let match: unknown[] = [];\n        if (raw.match !== undefined && raw.match !== null) {\n          if (!Array.isArray(raw.match)) {\n            throw new Error('trigger.match must be an array');\n          }\n          match = raw.match;\n        }\n        return { ...base, match } as TriggerSpec;\n      }\n\n      case 'cron': {\n        if (!raw.cron || typeof raw.cron !== 'string') {\n          throw new Error('trigger.cron is required for cron triggers');\n        }\n        let timezone: string | undefined;\n        if (raw.timezone !== undefined && raw.timezone !== null) {\n          if (typeof raw.timezone !== 'string') {\n            throw new Error('trigger.timezone must be a string');\n          }\n          timezone = raw.timezone.trim() || undefined;\n        }\n        return { ...base, cron: raw.cron, timezone } as TriggerSpec;\n      }\n\n      case 'interval': {\n        if (raw.periodMinutes === undefined || raw.periodMinutes === null) {\n          throw new Error('trigger.periodMinutes is required for interval triggers');\n        }\n        if (typeof raw.periodMinutes !== 'number' || !Number.isFinite(raw.periodMinutes)) {\n          throw new Error('trigger.periodMinutes must be a finite number');\n        }\n        if (raw.periodMinutes < 1) {\n          throw new Error('trigger.periodMinutes must be >= 1');\n        }\n        return { ...base, periodMinutes: raw.periodMinutes } as TriggerSpec;\n      }\n\n      case 'once': {\n        if (raw.whenMs === undefined || raw.whenMs === null) {\n          throw new Error('trigger.whenMs is required for once triggers');\n        }\n        if (typeof raw.whenMs !== 'number' || !Number.isFinite(raw.whenMs)) {\n          throw new Error('trigger.whenMs must be a finite number');\n        }\n        return { ...base, whenMs: Math.floor(raw.whenMs) } as TriggerSpec;\n      }\n\n      case 'command': {\n        if (!raw.commandKey || typeof raw.commandKey !== 'string') {\n          throw new Error('trigger.commandKey is required for command triggers');\n        }\n        return { ...base, commandKey: raw.commandKey } as TriggerSpec;\n      }\n\n      case 'contextMenu': {\n        if (!raw.title || typeof raw.title !== 'string') {\n          throw new Error('trigger.title is required for contextMenu triggers');\n        }\n        let contexts: string[] | undefined;\n        if (raw.contexts !== undefined && raw.contexts !== null) {\n          if (!Array.isArray(raw.contexts) || !raw.contexts.every((c) => typeof c === 'string')) {\n            throw new Error('trigger.contexts must be an array of strings');\n          }\n          contexts = raw.contexts as string[];\n        }\n        return { ...base, title: raw.title, contexts } as TriggerSpec;\n      }\n\n      case 'dom': {\n        if (!raw.selector || typeof raw.selector !== 'string') {\n          throw new Error('trigger.selector is required for dom triggers');\n        }\n        let appear: boolean | undefined;\n        if (raw.appear !== undefined && raw.appear !== null) {\n          if (typeof raw.appear !== 'boolean') {\n            throw new Error('trigger.appear must be a boolean');\n          }\n          appear = raw.appear;\n        }\n        let once: boolean | undefined;\n        if (raw.once !== undefined && raw.once !== null) {\n          if (typeof raw.once !== 'boolean') {\n            throw new Error('trigger.once must be a boolean');\n          }\n          once = raw.once;\n        }\n        let debounceMs: number | undefined;\n        if (raw.debounceMs !== undefined && raw.debounceMs !== null) {\n          if (typeof raw.debounceMs !== 'number' || !Number.isFinite(raw.debounceMs)) {\n            throw new Error('trigger.debounceMs must be a finite number');\n          }\n          debounceMs = raw.debounceMs;\n        }\n        return { ...base, selector: raw.selector, appear, once, debounceMs } as TriggerSpec;\n      }\n\n      default:\n        throw new Error(\n          `trigger.kind must be one of: manual, url, cron, interval, once, command, contextMenu, dom`,\n        );\n    }\n  }\n\n  // ===== Run Control Handlers =====\n\n  private async handlePauseRun(params: JsonObject | undefined): Promise<JsonValue> {\n    const runId = params?.runId as RunId | undefined;\n    if (!runId) throw new Error('runId is required');\n\n    if (!this.runners) {\n      throw new Error('RunnerRegistry not configured');\n    }\n\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      throw new Error(`Runner for \"${runId}\" not found (run may not be executing)`);\n    }\n\n    const queueItem = await this.storage.queue.get(runId);\n    if (!queueItem) {\n      throw new Error(`Queue item \"${runId}\" not found`);\n    }\n    if (queueItem.status === 'queued') {\n      throw new Error(`Cannot pause run \"${runId}\" while status=queued`);\n    }\n\n    const ownerId = queueItem.lease?.ownerId;\n    if (!ownerId) {\n      throw new Error(`Queue item \"${runId}\" has no lease ownerId`);\n    }\n\n    const now = this.now();\n    await this.storage.queue.markPaused(runId, ownerId, now);\n    runner.pause();\n\n    return { ok: true, runId };\n  }\n\n  private async handleResumeRun(params: JsonObject | undefined): Promise<JsonValue> {\n    const runId = params?.runId as RunId | undefined;\n    if (!runId) throw new Error('runId is required');\n\n    if (!this.runners) {\n      throw new Error('RunnerRegistry not configured');\n    }\n\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      throw new Error(`Runner for \"${runId}\" not found (run may not be executing)`);\n    }\n\n    const queueItem = await this.storage.queue.get(runId);\n    if (!queueItem) {\n      throw new Error(`Queue item \"${runId}\" not found`);\n    }\n    if (queueItem.status !== 'paused') {\n      throw new Error(`Cannot resume run \"${runId}\" with status=${queueItem.status}`);\n    }\n\n    const ownerId = queueItem.lease?.ownerId;\n    if (!ownerId) {\n      throw new Error(`Queue item \"${runId}\" has no lease ownerId`);\n    }\n\n    const now = this.now();\n    await this.storage.queue.markRunning(runId, ownerId, now);\n    runner.resume();\n\n    return { ok: true, runId };\n  }\n\n  private async handleCancelRun(params: JsonObject | undefined): Promise<JsonValue> {\n    const runId = params?.runId as RunId | undefined;\n    if (!runId) throw new Error('runId is required');\n\n    const reason = (params?.reason as string) ?? 'Canceled by user';\n    const queueItem = await this.storage.queue.get(runId);\n\n    // If still queued (not yet claimed), cancel via queue\n    if (queueItem?.status === 'queued') {\n      return this.handleCancelQueueItem({ runId, reason } as unknown as JsonObject);\n    }\n\n    // If running/paused, cancel via runner\n    if (!this.runners) {\n      throw new Error('RunnerRegistry not configured');\n    }\n\n    const runner = this.runners.get(runId);\n    if (!runner) {\n      // Run may have already finished\n      throw new Error(`Runner for \"${runId}\" not found (run may have already finished)`);\n    }\n\n    runner.cancel(reason);\n    return { ok: true, runId };\n  }\n}\n\n/**\n * 创建并启动 RPC Server\n */\nexport function createRpcServer(config: RpcServerConfig): RpcServer {\n  const server = new RpcServer(config);\n  server.start();\n  return server;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/transport/rpc.ts",
    "content": "/**\n * @fileoverview Port RPC 协议定义\n * @description 定义通过 chrome.runtime.Port 进行通信的协议类型\n */\n\nimport type { JsonObject, JsonValue } from '../../domain/json';\nimport type { RunId } from '../../domain/ids';\nimport type { RunEvent } from '../../domain/events';\n\n/** Port 名称 */\nexport const RR_V3_PORT_NAME = 'rr_v3' as const;\n\n/**\n * RPC 方法名称\n */\nexport type RpcMethod =\n  // 查询方法\n  | 'rr_v3.listRuns'\n  | 'rr_v3.getRun'\n  | 'rr_v3.getEvents'\n  // Flow 管理方法\n  | 'rr_v3.getFlow'\n  | 'rr_v3.listFlows'\n  | 'rr_v3.saveFlow'\n  | 'rr_v3.deleteFlow'\n  // 触发器管理方法\n  | 'rr_v3.createTrigger'\n  | 'rr_v3.updateTrigger'\n  | 'rr_v3.deleteTrigger'\n  | 'rr_v3.getTrigger'\n  | 'rr_v3.listTriggers'\n  | 'rr_v3.enableTrigger'\n  | 'rr_v3.disableTrigger'\n  | 'rr_v3.fireTrigger'\n  // 队列管理方法\n  | 'rr_v3.enqueueRun'\n  | 'rr_v3.listQueue'\n  | 'rr_v3.cancelQueueItem'\n  // 控制方法\n  | 'rr_v3.startRun'\n  | 'rr_v3.cancelRun'\n  | 'rr_v3.pauseRun'\n  | 'rr_v3.resumeRun'\n  // 调试方法\n  | 'rr_v3.debug'\n  // 订阅方法\n  | 'rr_v3.subscribe'\n  | 'rr_v3.unsubscribe';\n\n/**\n * RPC 请求消息\n */\nexport interface RpcRequest {\n  type: 'rr_v3.request';\n  /** 请求 ID（用于匹配响应） */\n  requestId: string;\n  /** 方法名 */\n  method: RpcMethod;\n  /** 参数 */\n  params?: JsonObject;\n}\n\n/**\n * RPC 成功响应\n */\nexport interface RpcResponseOk {\n  type: 'rr_v3.response';\n  /** 对应的请求 ID */\n  requestId: string;\n  ok: true;\n  /** 返回结果 */\n  result: JsonValue;\n}\n\n/**\n * RPC 错误响应\n */\nexport interface RpcResponseErr {\n  type: 'rr_v3.response';\n  /** 对应的请求 ID */\n  requestId: string;\n  ok: false;\n  /** 错误信息 */\n  error: string;\n}\n\n/**\n * RPC 响应\n */\nexport type RpcResponse = RpcResponseOk | RpcResponseErr;\n\n/**\n * RPC 事件推送\n */\nexport interface RpcEventMessage {\n  type: 'rr_v3.event';\n  /** 事件数据 */\n  event: RunEvent;\n}\n\n/**\n * RPC 订阅确认\n */\nexport interface RpcSubscribeAck {\n  type: 'rr_v3.subscribeAck';\n  /** 订阅的 Run ID（可选，null 表示订阅所有） */\n  runId: RunId | null;\n}\n\n/**\n * 所有 RPC 消息类型\n */\nexport type RpcMessage =\n  | RpcRequest\n  | RpcResponseOk\n  | RpcResponseErr\n  | RpcEventMessage\n  | RpcSubscribeAck;\n\n/**\n * 生成唯一的请求 ID\n */\nexport function generateRequestId(): string {\n  return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\n/**\n * 判断消息是否为 RPC 请求\n */\nexport function isRpcRequest(msg: unknown): msg is RpcRequest {\n  return typeof msg === 'object' && msg !== null && (msg as RpcRequest).type === 'rr_v3.request';\n}\n\n/**\n * 判断消息是否为 RPC 响应\n */\nexport function isRpcResponse(msg: unknown): msg is RpcResponse {\n  return typeof msg === 'object' && msg !== null && (msg as RpcResponse).type === 'rr_v3.response';\n}\n\n/**\n * 判断消息是否为 RPC 事件\n */\nexport function isRpcEvent(msg: unknown): msg is RpcEventMessage {\n  return typeof msg === 'object' && msg !== null && (msg as RpcEventMessage).type === 'rr_v3.event';\n}\n\n/**\n * 创建 RPC 请求\n */\nexport function createRpcRequest(method: RpcMethod, params?: JsonObject): RpcRequest {\n  return {\n    type: 'rr_v3.request',\n    requestId: generateRequestId(),\n    method,\n    params,\n  };\n}\n\n/**\n * 创建成功响应\n */\nexport function createRpcResponseOk(requestId: string, result: JsonValue): RpcResponseOk {\n  return {\n    type: 'rr_v3.response',\n    requestId,\n    ok: true,\n    result,\n  };\n}\n\n/**\n * 创建错误响应\n */\nexport function createRpcResponseErr(requestId: string, error: string): RpcResponseErr {\n  return {\n    type: 'rr_v3.response',\n    requestId,\n    ok: false,\n    error,\n  };\n}\n\n/**\n * 创建事件消息\n */\nexport function createRpcEventMessage(event: RunEvent): RpcEventMessage {\n  return {\n    type: 'rr_v3.event',\n    event,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/command-trigger.ts",
    "content": "/**\n * @fileoverview Command Trigger Handler (P4-04)\n * @description\n * Listens to `chrome.commands.onCommand` and fires installed command triggers.\n *\n * Command triggers allow users to execute flows via keyboard shortcuts\n * defined in the extension's manifest.\n *\n * Design notes:\n * - Commands must be registered in manifest.json under the \"commands\" key\n * - Each command is identified by its commandKey (e.g., \"run-flow-1\")\n * - Active tab info is captured when available\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\nexport interface CommandTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ntype CommandTriggerSpec = TriggerSpecByKind<'command'>;\n\ninterface InstalledCommandTrigger {\n  spec: CommandTriggerSpec;\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create command trigger handler factory\n */\nexport function createCommandTriggerHandlerFactory(\n  deps?: CommandTriggerHandlerDeps,\n): TriggerHandlerFactory<'command'> {\n  return (fireCallback) => createCommandTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create command trigger handler\n */\nexport function createCommandTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: CommandTriggerHandlerDeps,\n): TriggerHandler<'command'> {\n  const logger = deps?.logger ?? console;\n\n  // Map commandKey -> triggerId for fast lookup\n  const commandKeyToTriggerId = new Map<string, TriggerId>();\n  const installed = new Map<TriggerId, InstalledCommandTrigger>();\n  let listening = false;\n\n  /**\n   * Handle chrome.commands.onCommand event\n   */\n  const onCommand = (command: string, tab?: chrome.tabs.Tab): void => {\n    const triggerId = commandKeyToTriggerId.get(command);\n    if (!triggerId) return;\n\n    const trigger = installed.get(triggerId);\n    if (!trigger) return;\n\n    // Fire and forget: chrome event listeners should not block\n    Promise.resolve(\n      fireCallback.onFire(triggerId, {\n        sourceTabId: tab?.id,\n        sourceUrl: tab?.url,\n      }),\n    ).catch((e) => {\n      logger.error(`[CommandTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n    });\n  };\n\n  /**\n   * Ensure listener is registered\n   */\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.commands?.onCommand?.addListener) {\n      logger.warn('[CommandTriggerHandler] chrome.commands.onCommand is unavailable');\n      return;\n    }\n    chrome.commands.onCommand.addListener(onCommand);\n    listening = true;\n  }\n\n  /**\n   * Stop listening\n   */\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.commands.onCommand.removeListener(onCommand);\n    } catch (e) {\n      logger.debug('[CommandTriggerHandler] removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  return {\n    kind: 'command',\n\n    async install(trigger: CommandTriggerSpec): Promise<void> {\n      const { id, commandKey } = trigger;\n\n      // Warn if commandKey already used by another trigger\n      const existingTriggerId = commandKeyToTriggerId.get(commandKey);\n      if (existingTriggerId && existingTriggerId !== id) {\n        logger.warn(\n          `[CommandTriggerHandler] Command \"${commandKey}\" already used by trigger \"${existingTriggerId}\", overwriting with \"${id}\"`,\n        );\n        // Remove old mapping\n        installed.delete(existingTriggerId);\n      }\n\n      installed.set(id, { spec: trigger });\n      commandKeyToTriggerId.set(commandKey, id);\n      ensureListening();\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      const trigger = installed.get(triggerId as TriggerId);\n      if (trigger) {\n        commandKeyToTriggerId.delete(trigger.spec.commandKey);\n        installed.delete(triggerId as TriggerId);\n      }\n\n      if (installed.size === 0) {\n        stopListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      installed.clear();\n      commandKeyToTriggerId.clear();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger.ts",
    "content": "/**\n * @fileoverview ContextMenu Trigger Handler (P4-05)\n * @description\n * Uses `chrome.contextMenus` API to create right-click menu items that fire triggers.\n *\n * Design notes:\n * - Each trigger creates a separate menu item with unique ID\n * - Menu item ID is prefixed with 'rr_v3_' to avoid conflicts\n * - Context types: 'page', 'selection', 'link', 'image', 'video', 'audio', etc.\n * - Captures click info and tab info for trigger context\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\nexport interface ContextMenuTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ntype ContextMenuTriggerSpec = TriggerSpecByKind<'contextMenu'>;\n\ninterface InstalledContextMenuTrigger {\n  spec: ContextMenuTriggerSpec;\n  menuItemId: string;\n}\n\n// ==================== Constants ====================\n\nconst MENU_ITEM_PREFIX = 'rr_v3_';\n\n// Default context types if not specified\nconst DEFAULT_CONTEXTS: chrome.contextMenus.ContextType[] = ['page'];\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create context menu trigger handler factory\n */\nexport function createContextMenuTriggerHandlerFactory(\n  deps?: ContextMenuTriggerHandlerDeps,\n): TriggerHandlerFactory<'contextMenu'> {\n  return (fireCallback) => createContextMenuTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create context menu trigger handler\n */\nexport function createContextMenuTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: ContextMenuTriggerHandlerDeps,\n): TriggerHandler<'contextMenu'> {\n  const logger = deps?.logger ?? console;\n\n  // Map menuItemId -> triggerId for fast lookup\n  const menuItemIdToTriggerId = new Map<string, TriggerId>();\n  const installed = new Map<TriggerId, InstalledContextMenuTrigger>();\n  let listening = false;\n\n  /**\n   * Generate unique menu item ID for a trigger\n   */\n  function generateMenuItemId(triggerId: TriggerId): string {\n    return `${MENU_ITEM_PREFIX}${triggerId}`;\n  }\n\n  /**\n   * Handle chrome.contextMenus.onClicked event\n   */\n  const onClicked = (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): void => {\n    const menuItemId = String(info.menuItemId);\n    const triggerId = menuItemIdToTriggerId.get(menuItemId);\n    if (!triggerId) return;\n\n    const trigger = installed.get(triggerId);\n    if (!trigger) return;\n\n    // Fire and forget: chrome event listeners should not block\n    Promise.resolve(\n      fireCallback.onFire(triggerId, {\n        sourceTabId: tab?.id,\n        sourceUrl: info.pageUrl ?? tab?.url,\n      }),\n    ).catch((e) => {\n      logger.error(`[ContextMenuTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n    });\n  };\n\n  /**\n   * Ensure listener is registered\n   */\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.contextMenus?.onClicked?.addListener) {\n      logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.onClicked is unavailable');\n      return;\n    }\n    chrome.contextMenus.onClicked.addListener(onClicked);\n    listening = true;\n  }\n\n  /**\n   * Stop listening\n   */\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.contextMenus.onClicked.removeListener(onClicked);\n    } catch (e) {\n      logger.debug('[ContextMenuTriggerHandler] removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  /**\n   * Convert context types from spec to chrome API format\n   */\n  function normalizeContexts(\n    contexts: ReadonlyArray<string> | undefined,\n  ): chrome.contextMenus.ContextType[] {\n    if (!contexts || contexts.length === 0) {\n      return DEFAULT_CONTEXTS;\n    }\n    return contexts as chrome.contextMenus.ContextType[];\n  }\n\n  return {\n    kind: 'contextMenu',\n\n    async install(trigger: ContextMenuTriggerSpec): Promise<void> {\n      const { id, title, contexts } = trigger;\n      const menuItemId = generateMenuItemId(id);\n\n      // Check if chrome.contextMenus.create is available\n      if (!chrome.contextMenus?.create) {\n        logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.create is unavailable');\n        return;\n      }\n\n      // Create menu item\n      await new Promise<void>((resolve, reject) => {\n        chrome.contextMenus.create(\n          {\n            id: menuItemId,\n            title: title,\n            contexts: normalizeContexts(contexts),\n          },\n          () => {\n            if (chrome.runtime.lastError) {\n              reject(new Error(chrome.runtime.lastError.message));\n            } else {\n              resolve();\n            }\n          },\n        );\n      });\n\n      installed.set(id, { spec: trigger, menuItemId });\n      menuItemIdToTriggerId.set(menuItemId, id);\n      ensureListening();\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      const trigger = installed.get(triggerId as TriggerId);\n      if (!trigger) return;\n\n      // Remove menu item\n      if (chrome.contextMenus?.remove) {\n        await new Promise<void>((resolve) => {\n          chrome.contextMenus.remove(trigger.menuItemId, () => {\n            // Ignore errors (item may not exist)\n            if (chrome.runtime.lastError) {\n              logger.debug(\n                `[ContextMenuTriggerHandler] Failed to remove menu item: ${chrome.runtime.lastError.message}`,\n              );\n            }\n            resolve();\n          });\n        });\n      }\n\n      menuItemIdToTriggerId.delete(trigger.menuItemId);\n      installed.delete(triggerId as TriggerId);\n\n      if (installed.size === 0) {\n        stopListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      // Remove all menu items created by this handler\n      if (chrome.contextMenus?.remove) {\n        const removePromises = Array.from(installed.values()).map(\n          (trigger) =>\n            new Promise<void>((resolve) => {\n              chrome.contextMenus.remove(trigger.menuItemId, () => {\n                // Ignore errors\n                resolve();\n              });\n            }),\n        );\n        await Promise.all(removePromises);\n      }\n\n      installed.clear();\n      menuItemIdToTriggerId.clear();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger.ts",
    "content": "/**\n * @fileoverview Cron Trigger Handler (P4-07)\n * @description\n * Schedules cron triggers via `chrome.alarms` (MV3).\n *\n * Strategy:\n * - One alarm per trigger (one-shot `when` alarm).\n * - When fired: call `fireCallback.onFire(triggerId)` then compute and schedule next.\n *\n * Timezone:\n * - Accepts IANA timezones (e.g. \"UTC\", \"Asia/Shanghai\").\n * - Validated via `Intl.DateTimeFormat(..., { timeZone })`.\n *\n * Cron parsing:\n * - Delegated to an external library (recommended: `cron-parser`) to avoid DST edge cases.\n * - Falls back to a minimal built-in parser if library not available.\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\ntype CronTriggerSpec = TriggerSpecByKind<'cron'>;\n\n/**\n * Function to compute next fire time from cron expression\n */\nexport type ComputeNextFireAtMs = (input: {\n  cron: string;\n  timezone?: string;\n  fromMs: UnixMillis;\n}) => UnixMillis | Promise<UnixMillis>;\n\nexport interface CronTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n  now?: () => UnixMillis;\n  computeNextFireAtMs?: ComputeNextFireAtMs;\n}\n\ninterface InstalledCronTrigger {\n  spec: CronTriggerSpec;\n  timezone?: string;\n  version: number;\n}\n\n// ==================== Constants ====================\n\nconst ALARM_PREFIX = 'rr_v3_cron_';\n\n// ==================== Utilities ====================\n\n/**\n * Normalize cron expression\n */\nfunction normalizeCronExpression(value: unknown): string {\n  const raw = typeof value === 'string' ? value : String(value ?? '');\n  const normalized = raw.trim().replace(/\\s+/g, ' ');\n  if (!normalized) {\n    throw new Error('cron must be a non-empty string');\n  }\n  return normalized;\n}\n\n/**\n * Validate and normalize timezone\n */\nfunction normalizeTimezone(value: unknown): string | undefined {\n  if (value === undefined || value === null) return undefined;\n  if (typeof value !== 'string') {\n    throw new Error('timezone must be a string');\n  }\n  const trimmed = value.trim();\n  if (!trimmed) return undefined;\n\n  try {\n    // Throws RangeError for invalid IANA timezones\n    new Intl.DateTimeFormat('en-US', { timeZone: trimmed }).format(new Date(0));\n  } catch {\n    throw new Error(`Invalid timezone: \"${trimmed}\"`);\n  }\n\n  return trimmed;\n}\n\n/**\n * Generate alarm name for trigger\n */\nfunction alarmNameForTrigger(triggerId: TriggerId): string {\n  return `${ALARM_PREFIX}${triggerId}`;\n}\n\n/**\n * Parse trigger ID from alarm name\n */\nfunction parseTriggerIdFromAlarmName(name: string): TriggerId | null {\n  if (!name.startsWith(ALARM_PREFIX)) return null;\n  const id = name.slice(ALARM_PREFIX.length);\n  return id ? (id as TriggerId) : null;\n}\n\n/**\n * Simple cron expression parser (minimal subset)\n * Supports: minute hour day-of-month month day-of-week\n * Values: numbers, * (any), intervals (e.g., * /5)\n *\n * For production use with complex cron expressions, install 'cron-parser'.\n */\nfunction parseSimpleCron(cron: string): {\n  minute: number[];\n  hour: number[];\n  dayOfMonth: number[];\n  month: number[];\n  dayOfWeek: number[];\n} {\n  const parts = cron.split(' ');\n  if (parts.length !== 5) {\n    throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);\n  }\n\n  function parseField(field: string, min: number, max: number): number[] {\n    const values: number[] = [];\n\n    for (const part of field.split(',')) {\n      if (part === '*') {\n        for (let i = min; i <= max; i++) values.push(i);\n      } else if (part.includes('/')) {\n        const [range, stepStr] = part.split('/');\n        const step = parseInt(stepStr, 10);\n        // Guard against infinite loop: step must be positive\n        if (!Number.isFinite(step) || step < 1) {\n          throw new Error(`Invalid step in cron field: \"${part}\" (step must be >= 1)`);\n        }\n        const start = range === '*' ? min : parseInt(range, 10);\n        if (!Number.isFinite(start) || start < min || start > max) {\n          throw new Error(`Invalid range start in cron field: \"${part}\"`);\n        }\n        for (let i = start; i <= max; i += step) values.push(i);\n      } else if (part.includes('-')) {\n        const [startStr, endStr] = part.split('-');\n        const start = parseInt(startStr, 10);\n        const end = parseInt(endStr, 10);\n        if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {\n          throw new Error(`Invalid range in cron field: \"${part}\"`);\n        }\n        for (let i = start; i <= end; i++) values.push(i);\n      } else {\n        const num = parseInt(part, 10);\n        if (!Number.isFinite(num)) {\n          throw new Error(`Invalid number in cron field: \"${part}\"`);\n        }\n        values.push(num);\n      }\n    }\n\n    // Validate all values are within bounds\n    for (const v of values) {\n      if (v < min || v > max) {\n        throw new Error(`Cron field value ${v} out of range [${min}, ${max}]`);\n      }\n    }\n\n    return [...new Set(values)].sort((a, b) => a - b);\n  }\n\n  return {\n    minute: parseField(parts[0], 0, 59),\n    hour: parseField(parts[1], 0, 23),\n    dayOfMonth: parseField(parts[2], 1, 31),\n    month: parseField(parts[3], 1, 12),\n    dayOfWeek: parseField(parts[4], 0, 6),\n  };\n}\n\n// ==================== Timezone Utilities ====================\n\ninterface ZonedTimeParts {\n  year: number;\n  month: number;\n  day: number;\n  hour: number;\n  minute: number;\n  dayOfWeek: number;\n}\n\n// Cache DateTimeFormat instances per timezone for performance\nconst dtfCache = new Map<string, Intl.DateTimeFormat>();\n\n/**\n * Get or create cached DateTimeFormat for a timezone\n */\nfunction getDateTimeFormat(timezone: string): Intl.DateTimeFormat {\n  let dtf = dtfCache.get(timezone);\n  if (!dtf) {\n    dtf = new Intl.DateTimeFormat('en-US', {\n      timeZone: timezone,\n      hourCycle: 'h23',\n      year: 'numeric',\n      month: '2-digit',\n      day: '2-digit',\n      hour: '2-digit',\n      minute: '2-digit',\n      weekday: 'short',\n    });\n    dtfCache.set(timezone, dtf);\n  }\n  return dtf;\n}\n\n// Map weekday string to number (0=Sunday)\nconst WEEKDAY_MAP: Record<string, number> = {\n  Sun: 0,\n  Mon: 1,\n  Tue: 2,\n  Wed: 3,\n  Thu: 4,\n  Fri: 5,\n  Sat: 6,\n};\n\n/**\n * Get time parts in a specific timezone using Intl.DateTimeFormat\n */\nfunction getZonedTimeParts(utcMs: UnixMillis, timezone: string): ZonedTimeParts {\n  const dtf = getDateTimeFormat(timezone);\n  const parts = dtf.formatToParts(new Date(utcMs));\n  const map: Record<string, string> = Object.create(null);\n  for (const p of parts) {\n    if (p.type !== 'literal') map[p.type] = p.value;\n  }\n\n  // Handle edge case: some environments emit \"24\" for midnight\n  const rawHour = Number(map.hour);\n\n  return {\n    year: Number(map.year),\n    month: Number(map.month),\n    day: Number(map.day),\n    hour: rawHour === 24 ? 0 : rawHour,\n    minute: Number(map.minute),\n    dayOfWeek: WEEKDAY_MAP[map.weekday] ?? 0,\n  };\n}\n\n/**\n * Calculate timezone offset in milliseconds at a given UTC timestamp\n * Positive offset means timezone is ahead of UTC (e.g., Asia/Shanghai = +8h = +28800000ms)\n */\nfunction getTimezoneOffsetMs(utcMs: UnixMillis, timezone: string): number {\n  const z = getZonedTimeParts(utcMs, timezone);\n  const asUtc = Date.UTC(z.year, z.month - 1, z.day, z.hour, z.minute, 0);\n  return asUtc - utcMs;\n}\n\n/**\n * Convert zoned datetime to UTC milliseconds\n * Uses iterative refinement to handle DST transitions\n */\nfunction zonedToUtcMs(\n  zoned: { year: number; month: number; day: number; hour: number; minute: number },\n  timezone: string,\n): UnixMillis {\n  // Start with the zoned time interpreted as UTC\n  const baseUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0);\n\n  // Iteratively solve: utcMs = baseUtc - offset(utcMs)\n  let utcMs = baseUtc;\n  for (let i = 0; i < 3; i++) {\n    const offsetMs = getTimezoneOffsetMs(utcMs, timezone);\n    const next = baseUtc - offsetMs;\n    if (next === utcMs) break;\n    utcMs = next;\n  }\n  return utcMs;\n}\n\n// ==================== Cron Computation ====================\n\n/**\n * Compute next fire time using built-in simple parser (local timezone)\n */\nfunction computeNextFireAtMsLocal(\n  parsed: ReturnType<typeof parseSimpleCron>,\n  fromMs: UnixMillis,\n): UnixMillis {\n  const baseDate = new Date(fromMs + 1000); // Add 1 second to ensure next occurrence\n\n  for (let dayOffset = 0; dayOffset < 366; dayOffset++) {\n    for (const hour of parsed.hour) {\n      for (const minute of parsed.minute) {\n        const candidate = new Date(baseDate);\n        candidate.setDate(candidate.getDate() + dayOffset);\n        candidate.setHours(hour, minute, 0, 0);\n\n        if (candidate.getTime() <= fromMs) continue;\n\n        const month = candidate.getMonth() + 1;\n        const dayOfMonth = candidate.getDate();\n        const dayOfWeek = candidate.getDay();\n\n        if (!parsed.month.includes(month)) continue;\n        if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek))\n          continue;\n\n        return candidate.getTime();\n      }\n    }\n  }\n\n  throw new Error('Failed to compute next cron fire time within 1 year');\n}\n\n/**\n * Compute next fire time in a specific timezone\n */\nfunction computeNextFireAtMsZoned(\n  parsed: ReturnType<typeof parseSimpleCron>,\n  fromMs: UnixMillis,\n  timezone: string,\n): UnixMillis {\n  const baseZoned = getZonedTimeParts(fromMs + 1000, timezone);\n  const dayCursor = new Date(Date.UTC(baseZoned.year, baseZoned.month - 1, baseZoned.day));\n\n  for (let dayOffset = 0; dayOffset < 366; dayOffset++) {\n    if (dayOffset > 0) dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);\n\n    const year = dayCursor.getUTCFullYear();\n    const month = dayCursor.getUTCMonth() + 1;\n    const dayOfMonth = dayCursor.getUTCDate();\n    const dayOfWeek = dayCursor.getUTCDay();\n\n    if (!parsed.month.includes(month)) continue;\n    if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek)) continue;\n\n    for (const hour of parsed.hour) {\n      for (const minute of parsed.minute) {\n        const candidateUtcMs = zonedToUtcMs(\n          { year, month, day: dayOfMonth, hour, minute },\n          timezone,\n        );\n\n        if (candidateUtcMs <= fromMs) continue;\n\n        // Validate conversion didn't drift (DST gaps/ambiguity can cause skipped times)\n        const candidateZoned = getZonedTimeParts(candidateUtcMs, timezone);\n        if (\n          candidateZoned.year !== year ||\n          candidateZoned.month !== month ||\n          candidateZoned.day !== dayOfMonth ||\n          candidateZoned.hour !== hour ||\n          candidateZoned.minute !== minute\n        ) {\n          continue; // Skip DST gap times\n        }\n\n        return candidateUtcMs;\n      }\n    }\n  }\n\n  throw new Error('Failed to compute next cron fire time within 1 year');\n}\n\n/**\n * Compute next fire time using built-in simple parser\n */\nfunction computeNextFireAtMsSimple(input: {\n  cron: string;\n  timezone?: string;\n  fromMs: UnixMillis;\n}): UnixMillis {\n  const parsed = parseSimpleCron(input.cron);\n\n  if (input.timezone) {\n    return computeNextFireAtMsZoned(parsed, input.fromMs, input.timezone);\n  }\n\n  return computeNextFireAtMsLocal(parsed, input.fromMs);\n}\n\n/**\n * Default compute next fire time function\n * Uses simple built-in parser\n */\nfunction defaultComputeNextFireAtMs(input: {\n  cron: string;\n  timezone?: string;\n  fromMs: UnixMillis;\n}): UnixMillis {\n  return computeNextFireAtMsSimple(input);\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create cron trigger handler factory\n */\nexport function createCronTriggerHandlerFactory(\n  deps?: CronTriggerHandlerDeps,\n): TriggerHandlerFactory<'cron'> {\n  return (fireCallback) => createCronTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create cron trigger handler\n */\nexport function createCronTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: CronTriggerHandlerDeps,\n): TriggerHandler<'cron'> {\n  const logger = deps?.logger ?? console;\n  const now = deps?.now ?? (() => Date.now());\n  const computeNextFireAtMs: ComputeNextFireAtMs =\n    deps?.computeNextFireAtMs ?? defaultComputeNextFireAtMs;\n\n  const installed = new Map<TriggerId, InstalledCronTrigger>();\n  const versions = new Map<TriggerId, number>();\n  let listening = false;\n\n  /**\n   * Bump version to invalidate pending operations\n   */\n  function bumpVersion(triggerId: TriggerId): number {\n    const next = (versions.get(triggerId) ?? 0) + 1;\n    versions.set(triggerId, next);\n    return next;\n  }\n\n  /**\n   * Clear alarm by name\n   */\n  async function clearAlarmByName(name: string): Promise<void> {\n    if (!chrome.alarms?.clear) return;\n    try {\n      await Promise.resolve(chrome.alarms.clear(name));\n    } catch (e) {\n      logger.debug('[CronTriggerHandler] alarms.clear failed:', e);\n    }\n  }\n\n  /**\n   * Clear all cron alarms\n   */\n  async function clearAllCronAlarms(): Promise<void> {\n    if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;\n    try {\n      const alarms = await Promise.resolve(chrome.alarms.getAll());\n      const list = Array.isArray(alarms) ? alarms : [];\n      await Promise.all(\n        list\n          .filter((a) => a?.name && a.name.startsWith(ALARM_PREFIX))\n          .map((a) => clearAlarmByName(a.name)),\n      );\n    } catch (e) {\n      logger.debug('[CronTriggerHandler] alarms.getAll failed:', e);\n    }\n  }\n\n  /**\n   * Schedule next alarm for trigger\n   */\n  async function scheduleNext(triggerId: TriggerId, expectedVersion: number): Promise<void> {\n    if (!chrome.alarms?.create) {\n      logger.warn('[CronTriggerHandler] chrome.alarms.create is unavailable');\n      return;\n    }\n\n    const entry = installed.get(triggerId);\n    if (!entry || entry.version !== expectedVersion) return;\n\n    const fromMs = now();\n    const nextMs = await Promise.resolve(\n      computeNextFireAtMs({\n        cron: entry.spec.cron,\n        timezone: entry.timezone,\n        fromMs,\n      }),\n    );\n\n    // Check version again after async\n    if (installed.get(triggerId)?.version !== expectedVersion) return;\n\n    const name = alarmNameForTrigger(triggerId);\n    await Promise.resolve(chrome.alarms.create(name, { when: nextMs }));\n  }\n\n  /**\n   * Handle alarm event\n   */\n  const onAlarm = (alarm: chrome.alarms.Alarm): void => {\n    const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');\n    if (!triggerId) return;\n\n    const entry = installed.get(triggerId);\n    if (!entry) return;\n\n    const expectedVersion = entry.version;\n\n    void (async () => {\n      try {\n        await fireCallback.onFire(triggerId, {\n          sourceTabId: undefined,\n          sourceUrl: undefined,\n        });\n      } catch (e) {\n        logger.error(`[CronTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n      } finally {\n        // Reschedule if still valid\n        // eslint-disable-next-line no-unsafe-finally\n        if (installed.get(triggerId)?.version !== expectedVersion) return;\n        try {\n          await scheduleNext(triggerId, expectedVersion);\n        } catch (e) {\n          logger.error(`[CronTriggerHandler] Failed to reschedule trigger \"${triggerId}\":`, e);\n        }\n      }\n    })();\n  };\n\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.alarms?.onAlarm?.addListener) {\n      logger.warn('[CronTriggerHandler] chrome.alarms.onAlarm is unavailable');\n      return;\n    }\n    chrome.alarms.onAlarm.addListener(onAlarm);\n    listening = true;\n  }\n\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.alarms.onAlarm.removeListener(onAlarm);\n    } catch (e) {\n      logger.debug('[CronTriggerHandler] alarms.onAlarm.removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  return {\n    kind: 'cron',\n\n    async install(trigger: CronTriggerSpec): Promise<void> {\n      const cron = normalizeCronExpression(trigger.cron);\n      const timezone = normalizeTimezone(trigger.timezone);\n\n      const version = bumpVersion(trigger.id);\n      installed.set(trigger.id, {\n        spec: { ...trigger, cron },\n        timezone,\n        version,\n      });\n\n      ensureListening();\n      await scheduleNext(trigger.id, version);\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      const id = triggerId as TriggerId;\n      bumpVersion(id);\n      installed.delete(id);\n      await clearAlarmByName(alarmNameForTrigger(id));\n\n      if (installed.size === 0) {\n        stopListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      for (const id of installed.keys()) bumpVersion(id);\n      installed.clear();\n      await clearAllCronAlarms();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger.ts",
    "content": "/**\n * @fileoverview DOM Trigger Handler (P4-06)\n * @description\n * Bridges DOM triggers to a content-script MutationObserver (`inject-scripts/dom-observer.js`).\n *\n * Contract:\n * - Background -> content: { action: 'set_dom_triggers', triggers: [...] }\n * - Content -> background: { action: 'dom_trigger_fired', triggerId, url }\n * - Ping: { action: 'dom_observer_ping' } -> { status:'pong' }\n *\n * Design notes:\n * - Reuses existing V2 dom observer script for consistency and auditability.\n * - Single handler instance manages multiple triggers.\n * - Sync is coalesced to avoid storms during TriggerManager.refresh().\n * - Top-frame only (no frameId in TriggerFireContext).\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport { CONTENT_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '../../../../../common/message-types';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\nexport interface DomTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ntype DomTriggerSpec = TriggerSpecByKind<'dom'>;\n\n/**\n * Payload sent to dom-observer content script\n */\ninterface DomObserverTriggerPayload {\n  id: string;\n  selector: string;\n  appear: boolean;\n  once: boolean;\n  debounceMs: number;\n}\n\n/**\n * Message received when DOM trigger fires\n */\ninterface DomTriggerFiredMessage {\n  action: string;\n  triggerId: string;\n  url?: string;\n}\n\n// ==================== Constants ====================\n\nconst DOM_OBSERVER_SCRIPT_FILE = 'inject-scripts/dom-observer.js';\nconst DEFAULT_DEBOUNCE_MS = 800;\n\n// ==================== Utilities ====================\n\nfunction normalizeDebounceMs(value: unknown): number {\n  if (value === undefined || value === null) return DEFAULT_DEBOUNCE_MS;\n  if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS;\n  return Math.max(0, Math.floor(value));\n}\n\n/**\n * Build payload for dom-observer content script\n */\nfunction buildDomObserverPayload(\n  installed: Map<TriggerId, DomTriggerSpec>,\n): DomObserverTriggerPayload[] {\n  const out: DomObserverTriggerPayload[] = [];\n\n  for (const t of installed.values()) {\n    const selector = String(t.selector ?? '').trim();\n    if (!selector) continue;\n\n    out.push({\n      id: t.id,\n      selector,\n      appear: t.appear !== false, // default true\n      once: t.once !== false, // default true\n      debounceMs: normalizeDebounceMs(t.debounceMs),\n    });\n  }\n\n  // Deterministic ordering for tests and debugging\n  out.sort((a, b) => a.id.localeCompare(b.id));\n  return out;\n}\n\n/**\n * Check if URL is injectable (http/https/file)\n */\nfunction isInjectableUrl(url: string): boolean {\n  return /^(https?:|file:)/i.test(url);\n}\n\n/**\n * Type guard for DOM trigger fired message\n */\nfunction isDomTriggerFiredMessage(msg: unknown): msg is DomTriggerFiredMessage {\n  if (!msg || typeof msg !== 'object') return false;\n  const anyMsg = msg as Record<string, unknown>;\n  return (\n    anyMsg.action === TOOL_MESSAGE_TYPES.DOM_TRIGGER_FIRED && typeof anyMsg.triggerId === 'string'\n  );\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create DOM trigger handler factory\n */\nexport function createDomTriggerHandlerFactory(\n  deps?: DomTriggerHandlerDeps,\n): TriggerHandlerFactory<'dom'> {\n  return (fireCallback) => createDomTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create DOM trigger handler\n */\nexport function createDomTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: DomTriggerHandlerDeps,\n): TriggerHandler<'dom'> {\n  const logger = deps?.logger ?? console;\n\n  const installed = new Map<TriggerId, DomTriggerSpec>();\n\n  // Payload cache for efficiency\n  let payloadDirty = true;\n  let payloadCache: DomObserverTriggerPayload[] = [];\n\n  // Listener states\n  let messageListening = false;\n  let navigationListening = false;\n\n  // Coalesce sync to avoid storms (e.g. TriggerManager.refresh)\n  let syncPromise: Promise<void> | null = null;\n  let pendingSync = false;\n\n  function markPayloadDirty(): void {\n    payloadDirty = true;\n  }\n\n  function getPayload(): DomObserverTriggerPayload[] {\n    if (!payloadDirty) return payloadCache;\n    payloadCache = buildDomObserverPayload(installed);\n    payloadDirty = false;\n    return payloadCache;\n  }\n\n  /**\n   * Ping dom-observer to check if injected\n   */\n  async function pingDomObserver(tabId: number): Promise<boolean> {\n    try {\n      const resp = await chrome.tabs.sendMessage(tabId, {\n        action: CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING,\n      });\n      return (resp as { status?: string } | undefined)?.status === 'pong';\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Inject dom-observer script if not present\n   */\n  async function ensureDomObserverInjected(tabId: number): Promise<void> {\n    const ok = await pingDomObserver(tabId);\n    if (ok) return;\n\n    if (!chrome.scripting?.executeScript) {\n      logger.warn('[DomTriggerHandler] chrome.scripting.executeScript is unavailable');\n      return;\n    }\n\n    try {\n      await chrome.scripting.executeScript({\n        target: { tabId },\n        files: [DOM_OBSERVER_SCRIPT_FILE],\n        world: 'ISOLATED',\n      });\n    } catch (e) {\n      // Best-effort: injection can fail on restricted pages (chrome://, etc.)\n      logger.debug('[DomTriggerHandler] executeScript failed:', e);\n    }\n  }\n\n  /**\n   * Send triggers to dom-observer\n   */\n  async function setDomTriggers(\n    tabId: number,\n    triggers: DomObserverTriggerPayload[],\n  ): Promise<void> {\n    try {\n      await chrome.tabs.sendMessage(tabId, {\n        action: TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS,\n        triggers,\n      });\n    } catch (e) {\n      // No receiver / restricted pages are expected; keep best-effort.\n      logger.debug('[DomTriggerHandler] set_dom_triggers sendMessage failed:', e);\n    }\n  }\n\n  /**\n   * Sync triggers to a single tab\n   */\n  async function syncTab(tabId: number, url: string | undefined): Promise<void> {\n    if (typeof url === 'string' && url && !isInjectableUrl(url)) return;\n\n    const payload = getPayload();\n    if (payload.length > 0) {\n      await ensureDomObserverInjected(tabId);\n    }\n    await setDomTriggers(tabId, payload);\n  }\n\n  /**\n   * Sync triggers to all tabs\n   */\n  async function doSyncAllTabs(): Promise<void> {\n    if (!chrome.tabs?.query) {\n      logger.warn('[DomTriggerHandler] chrome.tabs.query is unavailable');\n      return;\n    }\n\n    let tabs: chrome.tabs.Tab[] = [];\n    try {\n      tabs = await chrome.tabs.query({});\n    } catch (e) {\n      logger.debug('[DomTriggerHandler] tabs.query failed:', e);\n      return;\n    }\n\n    await Promise.all(\n      tabs\n        .filter((t) => typeof t.id === 'number')\n        .filter((t) => (typeof t.url === 'string' ? isInjectableUrl(t.url) : true))\n        .map((t) => syncTab(t.id as number, t.url)),\n    );\n  }\n\n  /**\n   * Request sync (coalesced)\n   */\n  async function requestSyncAllTabs(): Promise<void> {\n    pendingSync = true;\n    if (!syncPromise) {\n      syncPromise = (async () => {\n        while (pendingSync) {\n          pendingSync = false;\n          await doSyncAllTabs();\n        }\n      })().finally(() => {\n        syncPromise = null;\n      });\n    }\n    return syncPromise;\n  }\n\n  /**\n   * Handle runtime message (dom_trigger_fired)\n   */\n  const onRuntimeMessage = (\n    message: unknown,\n    sender: chrome.runtime.MessageSender,\n    sendResponse: (response?: unknown) => void,\n  ): boolean => {\n    if (!isDomTriggerFiredMessage(message)) return false;\n\n    const triggerId = message.triggerId as TriggerId;\n    if (!installed.has(triggerId)) {\n      try {\n        sendResponse({ ok: false });\n      } catch {\n        // ignore\n      }\n      return false;\n    }\n\n    const sourceTabId = sender.tab?.id;\n    const sourceUrl = message.url ?? sender.tab?.url;\n\n    // Fire-and-forget: do not block chrome messaging thread\n    Promise.resolve(fireCallback.onFire(triggerId, { sourceTabId, sourceUrl })).catch((e) => {\n      logger.error(`[DomTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n    });\n\n    try {\n      sendResponse({ ok: true });\n    } catch {\n      // ignore\n    }\n    return false;\n  };\n\n  /**\n   * Handle navigation completed (re-sync triggers to tab)\n   */\n  const onNavigationCompleted = (\n    details: chrome.webNavigation.WebNavigationFramedCallbackDetails,\n  ): void => {\n    if (details.frameId !== 0) return; // Top frame only\n    if (installed.size === 0) return;\n    if (typeof details.url === 'string' && details.url && !isInjectableUrl(details.url)) return;\n\n    void syncTab(details.tabId, details.url).catch((e) => {\n      logger.debug('[DomTriggerHandler] syncTab on navigation failed:', e);\n    });\n  };\n\n  function ensureMessageListening(): void {\n    if (messageListening) return;\n    if (!chrome.runtime?.onMessage?.addListener) {\n      logger.warn('[DomTriggerHandler] chrome.runtime.onMessage is unavailable');\n      return;\n    }\n    chrome.runtime.onMessage.addListener(onRuntimeMessage);\n    messageListening = true;\n  }\n\n  function stopMessageListening(): void {\n    if (!messageListening) return;\n    try {\n      chrome.runtime.onMessage.removeListener(onRuntimeMessage);\n    } catch (e) {\n      logger.debug('[DomTriggerHandler] runtime.onMessage.removeListener failed:', e);\n    } finally {\n      messageListening = false;\n    }\n  }\n\n  function ensureNavigationListening(): void {\n    if (navigationListening) return;\n    if (!chrome.webNavigation?.onCompleted?.addListener) {\n      logger.warn('[DomTriggerHandler] chrome.webNavigation.onCompleted is unavailable');\n      return;\n    }\n    chrome.webNavigation.onCompleted.addListener(onNavigationCompleted);\n    navigationListening = true;\n  }\n\n  function stopNavigationListening(): void {\n    if (!navigationListening) return;\n    try {\n      chrome.webNavigation.onCompleted.removeListener(onNavigationCompleted);\n    } catch (e) {\n      logger.debug('[DomTriggerHandler] webNavigation.onCompleted.removeListener failed:', e);\n    } finally {\n      navigationListening = false;\n    }\n  }\n\n  return {\n    kind: 'dom',\n\n    async install(trigger: DomTriggerSpec): Promise<void> {\n      installed.set(trigger.id, trigger);\n      markPayloadDirty();\n\n      // Ensure listeners are ready before pushing triggers\n      ensureMessageListening();\n      ensureNavigationListening();\n\n      await requestSyncAllTabs();\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      installed.delete(triggerId as TriggerId);\n      markPayloadDirty();\n\n      await requestSyncAllTabs();\n\n      if (installed.size === 0) {\n        stopNavigationListening();\n        stopMessageListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      installed.clear();\n      markPayloadDirty();\n\n      await requestSyncAllTabs();\n\n      stopNavigationListening();\n      stopMessageListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/index.ts",
    "content": "/**\n * @fileoverview Triggers 模块导出入口\n */\n\nexport * from './trigger-handler';\nexport * from './trigger-manager';\nexport * from './url-trigger';\nexport * from './command-trigger';\nexport * from './context-menu-trigger';\nexport * from './dom-trigger';\nexport * from './cron-trigger';\nexport * from './manual-trigger';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger.ts",
    "content": "/**\n * @fileoverview Interval Trigger Handler (M3.1)\n * @description\n * 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。\n *\n * 策略：\n * - 每个触发器对应一个重复 alarm\n * - 使用 delayInMinutes 使首次触发在配置的间隔后\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\ntype IntervalTriggerSpec = TriggerSpecByKind<'interval'>;\n\nexport interface IntervalTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ninterface InstalledIntervalTrigger {\n  spec: IntervalTriggerSpec;\n  periodMinutes: number;\n  version: number;\n}\n\n// ==================== Constants ====================\n\nconst ALARM_PREFIX = 'rr_v3_interval_';\n\n// ==================== Utilities ====================\n\n/**\n * 校验并规范化 periodMinutes\n */\nfunction normalizePeriodMinutes(value: unknown): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    throw new Error('periodMinutes must be a finite number');\n  }\n  if (value < 1) {\n    throw new Error('periodMinutes must be >= 1');\n  }\n  return value;\n}\n\n/**\n * 生成 alarm 名称\n */\nfunction alarmNameForTrigger(triggerId: TriggerId): string {\n  return `${ALARM_PREFIX}${triggerId}`;\n}\n\n/**\n * 从 alarm 名称解析 triggerId\n */\nfunction parseTriggerIdFromAlarmName(name: string): TriggerId | null {\n  if (!name.startsWith(ALARM_PREFIX)) return null;\n  const id = name.slice(ALARM_PREFIX.length);\n  return id ? (id as TriggerId) : null;\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * 创建 interval 触发器处理器工厂\n */\nexport function createIntervalTriggerHandlerFactory(\n  deps?: IntervalTriggerHandlerDeps,\n): TriggerHandlerFactory<'interval'> {\n  return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);\n}\n\n/**\n * 创建 interval 触发器处理器\n */\nexport function createIntervalTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: IntervalTriggerHandlerDeps,\n): TriggerHandler<'interval'> {\n  const logger = deps?.logger ?? console;\n\n  const installed = new Map<TriggerId, InstalledIntervalTrigger>();\n  const versions = new Map<TriggerId, number>();\n  let listening = false;\n\n  /**\n   * 递增版本号以使挂起的操作失效\n   */\n  function bumpVersion(triggerId: TriggerId): number {\n    const next = (versions.get(triggerId) ?? 0) + 1;\n    versions.set(triggerId, next);\n    return next;\n  }\n\n  /**\n   * 清除指定 alarm\n   */\n  async function clearAlarmByName(name: string): Promise<void> {\n    if (!chrome.alarms?.clear) return;\n    try {\n      await Promise.resolve(chrome.alarms.clear(name));\n    } catch (e) {\n      logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);\n    }\n  }\n\n  /**\n   * 清除所有 interval alarms\n   */\n  async function clearAllIntervalAlarms(): Promise<void> {\n    if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;\n    try {\n      const alarms = await Promise.resolve(chrome.alarms.getAll());\n      const list = Array.isArray(alarms) ? alarms : [];\n      await Promise.all(\n        list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),\n      );\n    } catch (e) {\n      logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);\n    }\n  }\n\n  /**\n   * 调度 alarm\n   */\n  async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {\n    if (!chrome.alarms?.create) {\n      logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');\n      return;\n    }\n\n    const entry = installed.get(triggerId);\n    if (!entry || entry.version !== expectedVersion) return;\n\n    const name = alarmNameForTrigger(triggerId);\n    const periodInMinutes = entry.periodMinutes;\n\n    try {\n      // 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm\n      // 首次触发在 periodInMinutes 后，之后每隔 periodInMinutes 触发\n      await Promise.resolve(\n        chrome.alarms.create(name, {\n          delayInMinutes: periodInMinutes,\n          periodInMinutes,\n        }),\n      );\n    } catch (e) {\n      logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger \"${triggerId}\":`, e);\n    }\n  }\n\n  /**\n   * Alarm 事件处理\n   */\n  const onAlarm = (alarm: chrome.alarms.Alarm): void => {\n    const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');\n    if (!triggerId) return;\n\n    const entry = installed.get(triggerId);\n    if (!entry) return;\n\n    // 触发回调\n    Promise.resolve(\n      fireCallback.onFire(triggerId, {\n        sourceTabId: undefined,\n        sourceUrl: undefined,\n      }),\n    ).catch((e) => {\n      logger.error(`[IntervalTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n    });\n  };\n\n  /**\n   * 确保正在监听 alarm 事件\n   */\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.alarms?.onAlarm?.addListener) {\n      logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');\n      return;\n    }\n    chrome.alarms.onAlarm.addListener(onAlarm);\n    listening = true;\n  }\n\n  /**\n   * 停止监听 alarm 事件\n   */\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.alarms.onAlarm.removeListener(onAlarm);\n    } catch (e) {\n      logger.debug('[IntervalTriggerHandler] removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  return {\n    kind: 'interval',\n\n    async install(trigger: IntervalTriggerSpec): Promise<void> {\n      const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);\n\n      const version = bumpVersion(trigger.id);\n      installed.set(trigger.id, {\n        spec: { ...trigger, periodMinutes },\n        periodMinutes,\n        version,\n      });\n\n      ensureListening();\n      await schedule(trigger.id, version);\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      const id = triggerId as TriggerId;\n      bumpVersion(id);\n      installed.delete(id);\n      await clearAlarmByName(alarmNameForTrigger(id));\n\n      if (installed.size === 0) {\n        stopListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      for (const id of installed.keys()) {\n        bumpVersion(id);\n      }\n      installed.clear();\n      await clearAllIntervalAlarms();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger.ts",
    "content": "/**\n * @fileoverview Manual Trigger Handler (P4-08)\n * @description\n * Manual triggers are the simplest trigger type - they don't auto-fire.\n * They're only triggered programmatically via RPC or UI.\n *\n * This handler just tracks installed triggers but doesn't set up any listeners.\n * Manual triggers are fired by calling TriggerManager's fire method directly.\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\nexport interface ManualTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ntype ManualTriggerSpec = TriggerSpecByKind<'manual'>;\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create manual trigger handler factory\n */\nexport function createManualTriggerHandlerFactory(\n  deps?: ManualTriggerHandlerDeps,\n): TriggerHandlerFactory<'manual'> {\n  return (fireCallback) => createManualTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create manual trigger handler\n *\n * Manual triggers don't auto-fire - they're only triggered via RPC.\n * This handler just tracks which manual triggers are installed.\n */\nexport function createManualTriggerHandler(\n  _fireCallback: TriggerFireCallback,\n  _deps?: ManualTriggerHandlerDeps,\n): TriggerHandler<'manual'> {\n  const installed = new Map<TriggerId, ManualTriggerSpec>();\n\n  return {\n    kind: 'manual',\n\n    async install(trigger: ManualTriggerSpec): Promise<void> {\n      installed.set(trigger.id, trigger);\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      installed.delete(triggerId as TriggerId);\n    },\n\n    async uninstallAll(): Promise<void> {\n      installed.clear();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/once-trigger.ts",
    "content": "/**\n * @fileoverview Once Trigger Handler (M3.1)\n * @description\n * 使用 chrome.alarms 的 when 参数实现一次性定时触发。\n *\n * 行为：\n * - 每个触发器对应一个一次性 alarm\n * - 触发后自动将触发器禁用 (enabled=false) 并卸载\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind } from '../../domain/triggers';\nimport { createTriggersStore } from '../../storage/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\ntype OnceTriggerSpec = TriggerSpecByKind<'once'>;\n\nexport interface OnceTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n  /**\n   * 可选：自定义禁用触发器的方法\n   * 如果未提供，将直接更新 TriggerStore\n   */\n  disableTrigger?: (triggerId: TriggerId) => Promise<void>;\n}\n\ninterface InstalledOnceTrigger {\n  spec: OnceTriggerSpec;\n  whenMs: UnixMillis;\n  version: number;\n}\n\n// ==================== Constants ====================\n\nconst ALARM_PREFIX = 'rr_v3_once_';\n\n// ==================== Utilities ====================\n\n/**\n * 校验并规范化 whenMs\n */\nfunction normalizeWhenMs(value: unknown): UnixMillis {\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    throw new Error('whenMs must be a finite number');\n  }\n  return Math.floor(value) as UnixMillis;\n}\n\n/**\n * 生成 alarm 名称\n */\nfunction alarmNameForTrigger(triggerId: TriggerId): string {\n  return `${ALARM_PREFIX}${triggerId}`;\n}\n\n/**\n * 从 alarm 名称解析 triggerId\n */\nfunction parseTriggerIdFromAlarmName(name: string): TriggerId | null {\n  if (!name.startsWith(ALARM_PREFIX)) return null;\n  const id = name.slice(ALARM_PREFIX.length);\n  return id ? (id as TriggerId) : null;\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * 创建 once 触发器处理器工厂\n */\nexport function createOnceTriggerHandlerFactory(\n  deps?: OnceTriggerHandlerDeps,\n): TriggerHandlerFactory<'once'> {\n  return (fireCallback) => createOnceTriggerHandler(fireCallback, deps);\n}\n\n/**\n * 创建 once 触发器处理器\n */\nexport function createOnceTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: OnceTriggerHandlerDeps,\n): TriggerHandler<'once'> {\n  const logger = deps?.logger ?? console;\n\n  // 延迟创建 store，避免在测试环境中出问题\n  let triggersStore: ReturnType<typeof createTriggersStore> | null = null;\n  const getTriggersStore = () => {\n    if (!triggersStore) {\n      triggersStore = createTriggersStore();\n    }\n    return triggersStore;\n  };\n\n  const disableTrigger =\n    deps?.disableTrigger ??\n    (async (triggerId: TriggerId) => {\n      const store = getTriggersStore();\n      const existing = await store.get(triggerId);\n      if (!existing) return;\n      if (!existing.enabled) return;\n      await store.save({ ...existing, enabled: false });\n    });\n\n  const installed = new Map<TriggerId, InstalledOnceTrigger>();\n  const versions = new Map<TriggerId, number>();\n  let listening = false;\n\n  /**\n   * 递增版本号以使挂起的操作失效\n   */\n  function bumpVersion(triggerId: TriggerId): number {\n    const next = (versions.get(triggerId) ?? 0) + 1;\n    versions.set(triggerId, next);\n    return next;\n  }\n\n  /**\n   * 清除指定 alarm\n   */\n  async function clearAlarmByName(name: string): Promise<void> {\n    if (!chrome.alarms?.clear) return;\n    try {\n      await Promise.resolve(chrome.alarms.clear(name));\n    } catch (e) {\n      logger.debug('[OnceTriggerHandler] alarms.clear failed:', e);\n    }\n  }\n\n  /**\n   * 清除所有 once alarms\n   */\n  async function clearAllOnceAlarms(): Promise<void> {\n    if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;\n    try {\n      const alarms = await Promise.resolve(chrome.alarms.getAll());\n      const list = Array.isArray(alarms) ? alarms : [];\n      await Promise.all(\n        list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),\n      );\n    } catch (e) {\n      logger.debug('[OnceTriggerHandler] alarms.getAll failed:', e);\n    }\n  }\n\n  /**\n   * 调度 alarm\n   */\n  async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {\n    if (!chrome.alarms?.create) {\n      logger.warn('[OnceTriggerHandler] chrome.alarms.create is unavailable');\n      return;\n    }\n\n    const entry = installed.get(triggerId);\n    if (!entry || entry.version !== expectedVersion) return;\n\n    const name = alarmNameForTrigger(triggerId);\n\n    try {\n      await Promise.resolve(chrome.alarms.create(name, { when: entry.whenMs }));\n    } catch (e) {\n      logger.error(`[OnceTriggerHandler] alarms.create failed for trigger \"${triggerId}\":`, e);\n    }\n  }\n\n  /**\n   * 内部卸载逻辑（不触发外部 uninstall）\n   */\n  async function uninstallInternal(triggerId: TriggerId): Promise<void> {\n    bumpVersion(triggerId);\n    installed.delete(triggerId);\n    await clearAlarmByName(alarmNameForTrigger(triggerId));\n\n    if (installed.size === 0) {\n      stopListening();\n    }\n  }\n\n  /**\n   * Alarm 事件处理\n   */\n  const onAlarm = (alarm: chrome.alarms.Alarm): void => {\n    const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');\n    if (!triggerId) return;\n\n    const entry = installed.get(triggerId);\n    if (!entry) return;\n\n    const expectedVersion = entry.version;\n\n    void (async () => {\n      try {\n        await fireCallback.onFire(triggerId, {\n          sourceTabId: undefined,\n          sourceUrl: undefined,\n        });\n      } catch (e) {\n        logger.error(`[OnceTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n      } finally {\n        // 检查版本是否仍然有效\n        if (installed.get(triggerId)?.version === expectedVersion) {\n          // 禁用触发器\n          try {\n            await disableTrigger(triggerId);\n          } catch (e) {\n            logger.error(\n              `[OnceTriggerHandler] Failed to disable trigger \"${triggerId}\" after fire:`,\n              e,\n            );\n          }\n\n          // 卸载触发器\n          try {\n            await uninstallInternal(triggerId);\n          } catch (e) {\n            logger.error(\n              `[OnceTriggerHandler] Failed to uninstall trigger \"${triggerId}\" after fire:`,\n              e,\n            );\n          }\n        }\n      }\n    })();\n  };\n\n  /**\n   * 确保正在监听 alarm 事件\n   */\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.alarms?.onAlarm?.addListener) {\n      logger.warn('[OnceTriggerHandler] chrome.alarms.onAlarm is unavailable');\n      return;\n    }\n    chrome.alarms.onAlarm.addListener(onAlarm);\n    listening = true;\n  }\n\n  /**\n   * 停止监听 alarm 事件\n   */\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.alarms.onAlarm.removeListener(onAlarm);\n    } catch (e) {\n      logger.debug('[OnceTriggerHandler] removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  return {\n    kind: 'once',\n\n    async install(trigger: OnceTriggerSpec): Promise<void> {\n      const whenMs = normalizeWhenMs(trigger.whenMs);\n\n      const version = bumpVersion(trigger.id);\n      installed.set(trigger.id, {\n        spec: { ...trigger, whenMs },\n        whenMs,\n        version,\n      });\n\n      ensureListening();\n      await schedule(trigger.id, version);\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      await uninstallInternal(triggerId as TriggerId);\n    },\n\n    async uninstallAll(): Promise<void> {\n      for (const id of installed.keys()) {\n        bumpVersion(id);\n      }\n      installed.clear();\n      await clearAllOnceAlarms();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler.ts",
    "content": "/**\n * @fileoverview 触发器处理器接口定义\n * @description 定义各类触发器的统一接口\n */\n\nimport type { TriggerSpec, TriggerKind } from '../../domain/triggers';\n\n/**\n * 触发器处理器接口\n * @description 每种触发器类型需要实现此接口\n */\nexport interface TriggerHandler<K extends TriggerKind = TriggerKind> {\n  /** 触发器类型 */\n  readonly kind: K;\n\n  /**\n   * 安装触发器\n   * @description 注册 chrome API 监听器等\n   * @param trigger 触发器规范\n   */\n  install(trigger: Extract<TriggerSpec, { kind: K }>): Promise<void>;\n\n  /**\n   * 卸载触发器\n   * @description 移除 chrome API 监听器等\n   * @param triggerId 触发器 ID\n   */\n  uninstall(triggerId: string): Promise<void>;\n\n  /**\n   * 卸载所有触发器\n   * @description 清理所有此类型的触发器\n   */\n  uninstallAll(): Promise<void>;\n\n  /**\n   * 获取已安装的触发器 ID 列表\n   */\n  getInstalledIds(): string[];\n}\n\n/**\n * 触发器触发回调\n * @description TriggerManager 注入给各 Handler 的回调\n */\nexport interface TriggerFireCallback {\n  /**\n   * 触发器被触发时调用\n   * @param triggerId 触发器 ID\n   * @param context 触发上下文\n   */\n  onFire(\n    triggerId: string,\n    context: {\n      sourceTabId?: number;\n      sourceUrl?: string;\n    },\n  ): Promise<void>;\n}\n\n/**\n * 触发器处理器工厂\n */\nexport type TriggerHandlerFactory<K extends TriggerKind> = (\n  fireCallback: TriggerFireCallback,\n) => TriggerHandler<K>;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager.ts",
    "content": "/**\n * @fileoverview 触发器管理器\n * @description\n * TriggerManager 负责管理所有触发器 Handler 的生命周期：\n * - 从 TriggerStore 加载触发器并安装\n * - 处理触发器触发事件，调用 enqueueRun\n * - 提供防风暴机制 (cooldown + maxQueued)\n *\n * 设计理由：\n * - Orchestrator 模式：TriggerManager 不直接实现各类触发器逻辑，而是委托给 per-kind Handler\n * - Handler 工厂模式：TriggerManager 在构造时创建 Handler 实例，注入 fireCallback\n * - 防风暴：cooldown (per-trigger) + maxQueued (global best-effort)\n */\n\nimport type { UnixMillis } from '../../domain/json';\nimport type { RunId, TriggerId } from '../../domain/ids';\nimport type { TriggerFireContext, TriggerKind, TriggerSpec } from '../../domain/triggers';\nimport type { StoragePort } from '../storage/storage-port';\nimport type { EventsBus } from '../transport/events-bus';\nimport type { RunScheduler } from '../queue/scheduler';\nimport { enqueueRun, type EnqueueRunResult } from '../queue/enqueue-run';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\n/**\n * Handler 工厂映射\n */\nexport type TriggerHandlerFactories = Partial<{\n  [K in TriggerKind]: TriggerHandlerFactory<K>;\n}>;\n\n/**\n * 防风暴配置\n */\nexport interface TriggerManagerStormControl {\n  /**\n   * 同一触发器两次触发之间的最小间隔 (ms)\n   * - 0 或 undefined 表示禁用冷却\n   */\n  cooldownMs?: number;\n\n  /**\n   * 全局最大排队 Run 数量\n   * - 达到上限时拒绝新的触发\n   * - undefined 表示禁用上限检查\n   * - 注意：这是 best-effort 检查，非原子性\n   */\n  maxQueued?: number;\n}\n\n/**\n * TriggerManager 依赖\n */\nexport interface TriggerManagerDeps {\n  /** 存储层 */\n  storage: Pick<StoragePort, 'triggers' | 'flows' | 'runs' | 'queue'>;\n  /** 事件总线 */\n  events: Pick<EventsBus, 'append'>;\n  /** 调度器 (可选) */\n  scheduler?: Pick<RunScheduler, 'kick'>;\n  /** Handler 工厂映射 */\n  handlerFactories: TriggerHandlerFactories;\n  /** 防风暴配置 */\n  storm?: TriggerManagerStormControl;\n  /** RunId 生成器 (用于测试注入) */\n  generateRunId?: () => RunId;\n  /** 时间源 (用于测试注入) */\n  now?: () => UnixMillis;\n  /** 日志器 */\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\n/**\n * TriggerManager 状态\n */\nexport interface TriggerManagerState {\n  /** 是否已启动 */\n  started: boolean;\n  /** 已安装的触发器 ID 列表 */\n  installedTriggerIds: TriggerId[];\n}\n\n/**\n * TriggerManager 接口\n */\nexport interface TriggerManager {\n  /** 启动管理器，加载并安装所有启用的触发器 */\n  start(): Promise<void>;\n  /** 停止管理器，卸载所有触发器 */\n  stop(): Promise<void>;\n  /** 刷新触发器，重新从存储加载并安装 */\n  refresh(): Promise<void>;\n  /**\n   * 手动触发一个触发器\n   * @description 仅供 RPC/UI 调用，用于 manual 触发器\n   */\n  fire(\n    triggerId: TriggerId,\n    context?: { sourceTabId?: number; sourceUrl?: string },\n  ): Promise<EnqueueRunResult>;\n  /** 销毁管理器 */\n  dispose(): Promise<void>;\n  /** 获取当前状态 */\n  getState(): TriggerManagerState;\n}\n\n// ==================== Utilities ====================\n\n/**\n * 校验非负整数\n */\nfunction normalizeNonNegativeInt(value: unknown, fallback: number, fieldName: string): number {\n  if (value === undefined || value === null) return fallback;\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    throw new Error(`${fieldName} must be a finite number`);\n  }\n  return Math.max(0, Math.floor(value));\n}\n\n/**\n * 校验正整数\n */\nfunction normalizePositiveInt(value: unknown, fieldName: string): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    throw new Error(`${fieldName} must be a finite number`);\n  }\n  const intValue = Math.floor(value);\n  if (intValue < 1) {\n    throw new Error(`${fieldName} must be >= 1`);\n  }\n  return intValue;\n}\n\n// ==================== Implementation ====================\n\n/**\n * 创建 TriggerManager\n */\nexport function createTriggerManager(deps: TriggerManagerDeps): TriggerManager {\n  const logger = deps.logger ?? console;\n  const now = deps.now ?? (() => Date.now());\n\n  // 防风暴参数\n  const cooldownMs = normalizeNonNegativeInt(deps.storm?.cooldownMs, 0, 'storm.cooldownMs');\n  const maxQueued =\n    deps.storm?.maxQueued === undefined || deps.storm?.maxQueued === null\n      ? undefined\n      : normalizePositiveInt(deps.storm.maxQueued, 'storm.maxQueued');\n\n  // 状态\n  const installed = new Map<TriggerId, TriggerSpec>();\n  const lastFireAt = new Map<TriggerId, UnixMillis>();\n  let started = false;\n  let inFlightEnqueues = 0;\n\n  // 防止 refresh 重入\n  let refreshPromise: Promise<void> | null = null;\n  let pendingRefresh = false;\n\n  // Handler 实例\n  const handlers = new Map<TriggerKind, TriggerHandler<TriggerKind>>();\n\n  // 触发回调\n  const fireCallback: TriggerFireCallback = {\n    onFire: async (triggerId, context) => {\n      // 捕获所有异常，避免抛入 chrome API 监听器\n      try {\n        await handleFire(triggerId as TriggerId, context);\n      } catch (e) {\n        logger.error('[TriggerManager] onFire failed:', e);\n      }\n    },\n  };\n\n  // 初始化 Handler 实例\n  for (const [kind, factory] of Object.entries(deps.handlerFactories) as Array<\n    [TriggerKind, TriggerHandlerFactory<TriggerKind> | undefined]\n  >) {\n    if (!factory) continue; // Skip undefined factory values\n\n    const handler = factory(fireCallback) as TriggerHandler<TriggerKind>;\n    if (handler.kind !== kind) {\n      throw new Error(\n        `[TriggerManager] Handler kind mismatch: factory key is \"${kind}\", but handler.kind is \"${handler.kind}\"`,\n      );\n    }\n    handlers.set(kind, handler);\n  }\n\n  /**\n   * 处理触发器触发（内部方法）\n   * @param throwOnDrop 如果为 true，则在 cooldown/maxQueued 等情况下抛出错误\n   * @returns EnqueueRunResult 或 null（静默丢弃）\n   */\n  async function handleFire(\n    triggerId: TriggerId,\n    context: { sourceTabId?: number; sourceUrl?: string },\n    options?: { throwOnDrop?: boolean },\n  ): Promise<EnqueueRunResult | null> {\n    if (!started) {\n      if (options?.throwOnDrop) {\n        throw new Error('TriggerManager is not started');\n      }\n      return null;\n    }\n\n    const trigger = installed.get(triggerId);\n    if (!trigger) {\n      if (options?.throwOnDrop) {\n        throw new Error(`Trigger \"${triggerId}\" is not installed`);\n      }\n      return null;\n    }\n\n    const t = now();\n\n    // Per-trigger cooldown 检查\n    const prevLastFireAt = lastFireAt.get(triggerId);\n    if (cooldownMs > 0 && prevLastFireAt !== undefined && t - prevLastFireAt < cooldownMs) {\n      logger.debug(`[TriggerManager] Dropping trigger \"${triggerId}\" (cooldown ${cooldownMs}ms)`);\n      if (options?.throwOnDrop) {\n        throw new Error(`Trigger \"${triggerId}\" dropped (cooldown ${cooldownMs}ms)`);\n      }\n      return null;\n    }\n\n    // Global maxQueued 检查 (best-effort)\n    // 注意：在 cooldown 设置前检查，避免因 maxQueued drop 而误设 cooldown\n    if (maxQueued !== undefined) {\n      const queued = await deps.storage.queue.list('queued');\n      if (queued.length + inFlightEnqueues >= maxQueued) {\n        logger.warn(\n          `[TriggerManager] Dropping trigger \"${triggerId}\" (queued=${queued.length}, inFlight=${inFlightEnqueues}, maxQueued=${maxQueued})`,\n        );\n        if (options?.throwOnDrop) {\n          throw new Error(`Trigger \"${triggerId}\" dropped (maxQueued=${maxQueued})`);\n        }\n        return null;\n      }\n    }\n\n    // 设置 lastFireAt 以抑制并发触发（在 maxQueued 检查通过后）\n    if (cooldownMs > 0) {\n      lastFireAt.set(triggerId, t);\n    }\n\n    // 构建触发上下文\n    const triggerContext: TriggerFireContext = {\n      triggerId: trigger.id,\n      kind: trigger.kind,\n      firedAt: t,\n      sourceTabId: context.sourceTabId,\n      sourceUrl: context.sourceUrl,\n    };\n\n    inFlightEnqueues += 1;\n    try {\n      const result = await enqueueRun(\n        {\n          storage: deps.storage,\n          events: deps.events,\n          scheduler: deps.scheduler,\n          generateRunId: deps.generateRunId,\n          now,\n        },\n        {\n          flowId: trigger.flowId,\n          args: trigger.args,\n          trigger: triggerContext,\n        },\n      );\n      return result;\n    } catch (e) {\n      // 入队失败时回滚 cooldown 标记\n      if (cooldownMs > 0) {\n        if (prevLastFireAt === undefined) {\n          lastFireAt.delete(triggerId);\n        } else {\n          lastFireAt.set(triggerId, prevLastFireAt);\n        }\n      }\n      const msg = e instanceof Error ? e.message : String(e);\n      logger.error(`[TriggerManager] enqueueRun failed for trigger \"${triggerId}\":`, e);\n      if (options?.throwOnDrop) {\n        throw new Error(`enqueueRun failed for trigger \"${triggerId}\": ${msg}`);\n      }\n      return null;\n    } finally {\n      inFlightEnqueues -= 1;\n    }\n  }\n\n  /**\n   * 手动触发一个触发器（对外暴露）\n   * @description 用于 RPC/UI 调用，会抛出错误而不是静默丢弃\n   */\n  async function fire(\n    triggerId: TriggerId,\n    context: { sourceTabId?: number; sourceUrl?: string } = {},\n  ): Promise<EnqueueRunResult> {\n    const result = await handleFire(triggerId, context, { throwOnDrop: true });\n    if (!result) {\n      throw new Error(`Trigger \"${triggerId}\" did not enqueue a run`);\n    }\n    return result;\n  }\n\n  /**\n   * 执行刷新\n   */\n  async function doRefresh(): Promise<void> {\n    const triggers = await deps.storage.triggers.list();\n    if (!started) return;\n\n    // 先卸载所有，再重新安装 (简单策略，保证一致性)\n    // Best-effort: 单个 handler 卸载失败不影响其他\n    for (const handler of handlers.values()) {\n      try {\n        await handler.uninstallAll();\n      } catch (e) {\n        logger.warn(`[TriggerManager] Error during uninstallAll for kind \"${handler.kind}\":`, e);\n      }\n    }\n    installed.clear();\n\n    // 安装启用的触发器\n    for (const trigger of triggers) {\n      if (!started) return;\n      if (!trigger.enabled) continue;\n\n      const handler = handlers.get(trigger.kind);\n      if (!handler) {\n        logger.warn(`[TriggerManager] No handler registered for kind \"${trigger.kind}\"`);\n        continue;\n      }\n\n      try {\n        await handler.install(trigger as Parameters<typeof handler.install>[0]);\n        installed.set(trigger.id, trigger);\n      } catch (e) {\n        logger.error(`[TriggerManager] Failed to install trigger \"${trigger.id}\":`, e);\n      }\n    }\n  }\n\n  /**\n   * 刷新触发器 (合并并发调用)\n   */\n  async function refresh(): Promise<void> {\n    if (!started) {\n      throw new Error('TriggerManager is not started');\n    }\n\n    pendingRefresh = true;\n    if (!refreshPromise) {\n      refreshPromise = (async () => {\n        while (started && pendingRefresh) {\n          pendingRefresh = false;\n          await doRefresh();\n        }\n      })().finally(() => {\n        refreshPromise = null;\n      });\n    }\n\n    return refreshPromise;\n  }\n\n  /**\n   * 启动管理器\n   */\n  async function start(): Promise<void> {\n    if (started) return;\n    started = true;\n    await refresh();\n  }\n\n  /**\n   * 停止管理器\n   */\n  async function stop(): Promise<void> {\n    if (!started) return;\n\n    started = false;\n    pendingRefresh = false;\n\n    // 等待进行中的 refresh 完成\n    if (refreshPromise) {\n      try {\n        await refreshPromise;\n      } catch {\n        // 忽略 refresh 错误\n      }\n    }\n\n    // 卸载所有触发器\n    for (const handler of handlers.values()) {\n      try {\n        await handler.uninstallAll();\n      } catch (e) {\n        logger.warn('[TriggerManager] Error uninstalling handler:', e);\n      }\n    }\n    installed.clear();\n    lastFireAt.clear();\n  }\n\n  /**\n   * 销毁管理器\n   */\n  async function dispose(): Promise<void> {\n    await stop();\n  }\n\n  /**\n   * 获取状态\n   */\n  function getState(): TriggerManagerState {\n    return {\n      started,\n      installedTriggerIds: Array.from(installed.keys()),\n    };\n  }\n\n  return { start, stop, refresh, fire, dispose, getState };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/engine/triggers/url-trigger.ts",
    "content": "/**\n * @fileoverview URL Trigger Handler (P4-03)\n * @description\n * Listens to `chrome.webNavigation.onCompleted` and fires installed URL triggers.\n *\n * URL matching semantics:\n * - kind:'url' - Full URL prefix match (allows query/hash variations)\n * - kind:'domain' - Safe subdomain match (hostname === domain OR hostname.endsWith('.' + domain))\n * - kind:'path' - Pathname prefix match\n *\n * Design rationale:\n * - No regex/wildcards for performance and auditability\n * - Domain matching uses safe subdomain logic to avoid false positives (e.g. 'notexample.com')\n * - Single listener instance manages multiple triggers efficiently\n */\n\nimport type { TriggerId } from '../../domain/ids';\nimport type { TriggerSpecByKind, UrlMatchRule } from '../../domain/triggers';\nimport type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';\n\n// ==================== Types ====================\n\nexport interface UrlTriggerHandlerDeps {\n  logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n}\n\ntype UrlTriggerSpec = TriggerSpecByKind<'url'>;\n\n/**\n * Compiled URL match rules for efficient matching\n */\ninterface CompiledUrlRules {\n  /** Full URL prefixes */\n  urlPrefixes: string[];\n  /** Normalized domains (lowercase, no leading/trailing dots) */\n  domains: string[];\n  /** Normalized path prefixes (always starts with '/') */\n  pathPrefixes: string[];\n}\n\ninterface InstalledUrlTrigger {\n  spec: UrlTriggerSpec;\n  rules: CompiledUrlRules;\n}\n\n// ==================== Normalization Utilities ====================\n\n/**\n * Normalize domain value\n * - Trim whitespace\n * - Convert to lowercase\n * - Remove leading/trailing dots\n */\nfunction normalizeDomain(value: string): string | null {\n  const normalized = value.trim().toLowerCase().replace(/^\\.+/, '').replace(/\\.+$/, '');\n  return normalized || null;\n}\n\n/**\n * Normalize path prefix\n * - Trim whitespace\n * - Ensure starts with '/'\n */\nfunction normalizePathPrefix(value: string): string | null {\n  const trimmed = value.trim();\n  if (!trimmed) return null;\n  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;\n}\n\n/**\n * Normalize URL prefix\n * - Trim whitespace only\n */\nfunction normalizeUrlPrefix(value: string): string | null {\n  const trimmed = value.trim();\n  return trimmed || null;\n}\n\n/**\n * Compile URL match rules from spec\n */\nfunction compileUrlMatchRules(match: UrlMatchRule[] | undefined): CompiledUrlRules {\n  const urlPrefixes: string[] = [];\n  const domains: string[] = [];\n  const pathPrefixes: string[] = [];\n\n  for (const rule of match ?? []) {\n    const { kind } = rule;\n    const raw = typeof rule.value === 'string' ? rule.value : String(rule.value ?? '');\n\n    switch (kind) {\n      case 'url': {\n        const normalized = normalizeUrlPrefix(raw);\n        if (normalized) urlPrefixes.push(normalized);\n        break;\n      }\n      case 'domain': {\n        const normalized = normalizeDomain(raw);\n        if (normalized) domains.push(normalized);\n        break;\n      }\n      case 'path': {\n        const normalized = normalizePathPrefix(raw);\n        if (normalized) pathPrefixes.push(normalized);\n        break;\n      }\n    }\n  }\n\n  return { urlPrefixes, domains, pathPrefixes };\n}\n\n// ==================== Matching Logic ====================\n\n/**\n * Check if hostname matches domain (exact or subdomain)\n */\nfunction hostnameMatchesDomain(hostname: string, domain: string): boolean {\n  if (hostname === domain) return true;\n  return hostname.endsWith(`.${domain}`);\n}\n\n/**\n * Check if URL matches any of the compiled rules\n */\nfunction matchesRules(compiled: CompiledUrlRules, urlString: string, parsed: URL): boolean {\n  // URL prefix match\n  for (const prefix of compiled.urlPrefixes) {\n    if (urlString.startsWith(prefix)) return true;\n  }\n\n  // Domain match\n  const hostname = parsed.hostname.toLowerCase();\n  for (const domain of compiled.domains) {\n    if (hostnameMatchesDomain(hostname, domain)) return true;\n  }\n\n  // Path prefix match\n  const pathname = parsed.pathname || '/';\n  for (const prefix of compiled.pathPrefixes) {\n    if (pathname.startsWith(prefix)) return true;\n  }\n\n  return false;\n}\n\n// ==================== Handler Implementation ====================\n\n/**\n * Create URL trigger handler factory\n */\nexport function createUrlTriggerHandlerFactory(\n  deps?: UrlTriggerHandlerDeps,\n): TriggerHandlerFactory<'url'> {\n  return (fireCallback) => createUrlTriggerHandler(fireCallback, deps);\n}\n\n/**\n * Create URL trigger handler\n */\nexport function createUrlTriggerHandler(\n  fireCallback: TriggerFireCallback,\n  deps?: UrlTriggerHandlerDeps,\n): TriggerHandler<'url'> {\n  const logger = deps?.logger ?? console;\n\n  const installed = new Map<TriggerId, InstalledUrlTrigger>();\n  let listening = false;\n\n  /**\n   * Handle webNavigation.onCompleted event\n   */\n  const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails): void => {\n    // Only handle main frame navigations\n    if (details.frameId !== 0) return;\n\n    const urlString = details.url;\n\n    // Parse URL\n    let parsed: URL;\n    try {\n      parsed = new URL(urlString);\n    } catch {\n      return; // Invalid URL, skip\n    }\n\n    if (installed.size === 0) return;\n\n    // Snapshot to avoid iteration hazards during concurrent install/uninstall\n    const snapshot = Array.from(installed.entries());\n\n    for (const [triggerId, trigger] of snapshot) {\n      if (!matchesRules(trigger.rules, urlString, parsed)) continue;\n\n      // Fire and forget: chrome event listeners should not block navigation\n      Promise.resolve(\n        fireCallback.onFire(triggerId, {\n          sourceTabId: details.tabId,\n          sourceUrl: urlString,\n        }),\n      ).catch((e) => {\n        logger.error(`[UrlTriggerHandler] onFire failed for trigger \"${triggerId}\":`, e);\n      });\n    }\n  };\n\n  /**\n   * Ensure listener is registered\n   */\n  function ensureListening(): void {\n    if (listening) return;\n    if (!chrome.webNavigation?.onCompleted?.addListener) {\n      logger.warn('[UrlTriggerHandler] chrome.webNavigation.onCompleted is unavailable');\n      return;\n    }\n    chrome.webNavigation.onCompleted.addListener(onCompleted);\n    listening = true;\n  }\n\n  /**\n   * Stop listening\n   */\n  function stopListening(): void {\n    if (!listening) return;\n    try {\n      chrome.webNavigation.onCompleted.removeListener(onCompleted);\n    } catch (e) {\n      logger.debug('[UrlTriggerHandler] removeListener failed:', e);\n    } finally {\n      listening = false;\n    }\n  }\n\n  return {\n    kind: 'url',\n\n    async install(trigger: UrlTriggerSpec): Promise<void> {\n      installed.set(trigger.id, {\n        spec: trigger,\n        rules: compileUrlMatchRules(trigger.match),\n      });\n      ensureListening();\n    },\n\n    async uninstall(triggerId: string): Promise<void> {\n      installed.delete(triggerId as TriggerId);\n      if (installed.size === 0) {\n        stopListening();\n      }\n    },\n\n    async uninstallAll(): Promise<void> {\n      installed.clear();\n      stopListening();\n    },\n\n    getInstalledIds(): string[] {\n      return Array.from(installed.keys());\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/index.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 公共 API 入口\n * @description 导出所有公共类型和接口\n */\n\n// ==================== Domain ====================\nexport * from './domain';\n\n// ==================== Engine ====================\nexport * from './engine';\n\n// ==================== Storage ====================\nexport * from './storage';\n\n// ==================== Factory Functions ====================\n\nimport type { StoragePort } from './engine/storage/storage-port';\nimport { createFlowsStore } from './storage/flows';\nimport { createRunsStore } from './storage/runs';\nimport { createEventsStore } from './storage/events';\nimport { createQueueStore } from './storage/queue';\nimport { createPersistentVarsStore } from './storage/persistent-vars';\nimport { createTriggersStore } from './storage/triggers';\n\n/**\n * 创建完整的 StoragePort 实现\n */\nexport function createStoragePort(): StoragePort {\n  return {\n    flows: createFlowsStore(),\n    runs: createRunsStore(),\n    events: createEventsStore(),\n    queue: createQueueStore(),\n    persistentVars: createPersistentVarsStore(),\n    triggers: createTriggersStore(),\n  };\n}\n\n// ==================== Version ====================\n\n/** V3 API 版本 */\nexport const RR_V3_VERSION = '3.0.0' as const;\n\n/** 是否为 V3 API */\nexport const IS_RR_V3 = true as const;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/db.ts",
    "content": "/**\n * @fileoverview V3 IndexedDB 数据库定义\n * @description 定义 rr_v3 数据库的 schema 和初始化逻辑\n */\n\n/** 数据库名称 */\nexport const RR_V3_DB_NAME = 'rr_v3';\n\n/** 数据库版本 */\nexport const RR_V3_DB_VERSION = 1;\n\n/**\n * Store 名称常量\n */\nexport const RR_V3_STORES = {\n  FLOWS: 'flows',\n  RUNS: 'runs',\n  EVENTS: 'events',\n  QUEUE: 'queue',\n  PERSISTENT_VARS: 'persistent_vars',\n  TRIGGERS: 'triggers',\n} as const;\n\n/**\n * Store 配置\n */\nexport interface StoreConfig {\n  keyPath: string | string[];\n  autoIncrement?: boolean;\n  indexes?: Array<{\n    name: string;\n    keyPath: string | string[];\n    options?: IDBIndexParameters;\n  }>;\n}\n\n/**\n * V3 Store Schema 定义\n * @description 包含 Phase 1-3 所需的所有索引，避免后续升级\n */\nexport const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {\n  [RR_V3_STORES.FLOWS]: {\n    keyPath: 'id',\n    indexes: [\n      { name: 'name', keyPath: 'name' },\n      { name: 'updatedAt', keyPath: 'updatedAt' },\n    ],\n  },\n  [RR_V3_STORES.RUNS]: {\n    keyPath: 'id',\n    indexes: [\n      { name: 'status', keyPath: 'status' },\n      { name: 'flowId', keyPath: 'flowId' },\n      { name: 'createdAt', keyPath: 'createdAt' },\n      { name: 'updatedAt', keyPath: 'updatedAt' },\n      // Compound index for listing runs by flow and status\n      { name: 'flowId_status', keyPath: ['flowId', 'status'] },\n    ],\n  },\n  [RR_V3_STORES.EVENTS]: {\n    keyPath: ['runId', 'seq'],\n    indexes: [\n      { name: 'runId', keyPath: 'runId' },\n      { name: 'type', keyPath: 'type' },\n      // Compound index for filtering events by run and type\n      { name: 'runId_type', keyPath: ['runId', 'type'] },\n    ],\n  },\n  [RR_V3_STORES.QUEUE]: {\n    keyPath: 'id',\n    indexes: [\n      { name: 'status', keyPath: 'status' },\n      { name: 'priority', keyPath: 'priority' },\n      { name: 'createdAt', keyPath: 'createdAt' },\n      { name: 'flowId', keyPath: 'flowId' },\n      // Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.\n      { name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },\n      // Phase 3: Lease expiration tracking\n      { name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },\n    ],\n  },\n  [RR_V3_STORES.PERSISTENT_VARS]: {\n    keyPath: 'key',\n    indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],\n  },\n  [RR_V3_STORES.TRIGGERS]: {\n    keyPath: 'id',\n    indexes: [\n      { name: 'kind', keyPath: 'kind' },\n      { name: 'flowId', keyPath: 'flowId' },\n      { name: 'enabled', keyPath: 'enabled' },\n      // Compound index for listing enabled triggers by kind\n      { name: 'kind_enabled', keyPath: ['kind', 'enabled'] },\n    ],\n  },\n};\n\n/**\n * 数据库升级处理器\n */\nexport function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {\n  // Version 0 -> 1: 创建所有 stores\n  if (oldVersion < 1) {\n    for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {\n      const store = db.createObjectStore(storeName, {\n        keyPath: config.keyPath,\n        autoIncrement: config.autoIncrement,\n      });\n\n      // 创建索引\n      if (config.indexes) {\n        for (const index of config.indexes) {\n          store.createIndex(index.name, index.keyPath, index.options);\n        }\n      }\n    }\n  }\n}\n\n/** 全局数据库实例 */\nlet dbInstance: IDBDatabase | null = null;\nlet dbPromise: Promise<IDBDatabase> | null = null;\n\n/**\n * 打开 V3 数据库\n * @description 单例模式，确保只有一个数据库连接\n */\nexport async function openRrV3Db(): Promise<IDBDatabase> {\n  if (dbInstance) {\n    return dbInstance;\n  }\n\n  if (dbPromise) {\n    return dbPromise;\n  }\n\n  dbPromise = new Promise<IDBDatabase>((resolve, reject) => {\n    const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);\n\n    request.onerror = () => {\n      dbPromise = null;\n      reject(new Error(`Failed to open database: ${request.error?.message}`));\n    };\n\n    request.onsuccess = () => {\n      dbInstance = request.result;\n\n      // 处理版本变更（其他 tab 升级了数据库）\n      dbInstance.onversionchange = () => {\n        dbInstance?.close();\n        dbInstance = null;\n        dbPromise = null;\n      };\n\n      resolve(dbInstance);\n    };\n\n    request.onupgradeneeded = (event) => {\n      const db = request.result;\n      const oldVersion = event.oldVersion;\n      const newVersion = event.newVersion ?? RR_V3_DB_VERSION;\n      handleUpgrade(db, oldVersion, newVersion);\n    };\n  });\n\n  return dbPromise;\n}\n\n/**\n * 关闭数据库连接\n * @description 主要用于测试\n */\nexport function closeRrV3Db(): void {\n  if (dbInstance) {\n    dbInstance.close();\n    dbInstance = null;\n    dbPromise = null;\n  }\n}\n\n/**\n * 删除数据库\n * @description 主要用于测试\n */\nexport async function deleteRrV3Db(): Promise<void> {\n  closeRrV3Db();\n\n  return new Promise((resolve, reject) => {\n    const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);\n    request.onsuccess = () => resolve();\n    request.onerror = () => reject(request.error);\n  });\n}\n\n/**\n * 执行事务\n * @param storeNames Store 名称（单个或多个）\n * @param mode 事务模式\n * @param callback 事务回调\n */\nexport async function withTransaction<T>(\n  storeNames: string | string[],\n  mode: IDBTransactionMode,\n  callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,\n): Promise<T> {\n  const db = await openRrV3Db();\n  const names = Array.isArray(storeNames) ? storeNames : [storeNames];\n  const tx = db.transaction(names, mode);\n\n  const stores: Record<string, IDBObjectStore> = {};\n  for (const name of names) {\n    stores[name] = tx.objectStore(name);\n  }\n\n  return new Promise<T>((resolve, reject) => {\n    let result: T;\n\n    tx.oncomplete = () => resolve(result);\n    tx.onerror = () => reject(tx.error);\n    tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));\n\n    Promise.resolve(callback(stores))\n      .then((r) => {\n        result = r;\n      })\n      .catch((err) => {\n        tx.abort();\n        reject(err);\n      });\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/events.ts",
    "content": "/**\n * @fileoverview RunEvent 持久化\n * @description 实现事件的原子 seq 分配和存储\n */\n\nimport type { RunId } from '../domain/ids';\nimport type { RunEvent, RunEventInput, RunRecordV3 } from '../domain/events';\nimport { RR_ERROR_CODES, createRRError } from '../domain/errors';\nimport type { EventsStore } from '../engine/storage/storage-port';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/**\n * IDB request helper - promisify IDBRequest with RRError wrapping\n */\nfunction idbRequest<T>(request: IDBRequest<T>, context: string): Promise<T> {\n  return new Promise((resolve, reject) => {\n    request.onsuccess = () => resolve(request.result);\n    request.onerror = () => {\n      const error = request.error;\n      reject(\n        createRRError(\n          RR_ERROR_CODES.INTERNAL,\n          `IDB error in ${context}: ${error?.message ?? 'unknown'}`,\n        ),\n      );\n    };\n  });\n}\n\n/**\n * 创建 EventsStore 实现\n * @description\n * - append() 在单个事务中原子分配 seq\n * - seq 由 RunRecordV3.nextSeq 作为单一事实来源\n */\nexport function createEventsStore(): EventsStore {\n  return {\n    /**\n     * 追加事件并原子分配 seq\n     * @description 在单个事务中：读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq\n     */\n    async append(input: RunEventInput): Promise<RunEvent> {\n      return withTransaction(\n        [RR_V3_STORES.RUNS, RR_V3_STORES.EVENTS],\n        'readwrite',\n        async (stores) => {\n          const runsStore = stores[RR_V3_STORES.RUNS];\n          const eventsStore = stores[RR_V3_STORES.EVENTS];\n\n          // Step 1: Read nextSeq from RunRecordV3 (single source of truth)\n          const run = await idbRequest<RunRecordV3 | undefined>(\n            runsStore.get(input.runId),\n            `append.getRun(${input.runId})`,\n          );\n\n          if (!run) {\n            throw createRRError(\n              RR_ERROR_CODES.INTERNAL,\n              `Run \"${input.runId}\" not found when appending event`,\n            );\n          }\n\n          const seq = run.nextSeq;\n\n          // Validate seq integrity\n          if (!Number.isSafeInteger(seq) || seq < 0) {\n            throw createRRError(\n              RR_ERROR_CODES.INVARIANT_VIOLATION,\n              `Invalid nextSeq for run \"${input.runId}\": ${String(seq)}`,\n            );\n          }\n\n          // Step 2: Create complete event with allocated seq\n          const event: RunEvent = {\n            ...input,\n            seq,\n            ts: input.ts ?? Date.now(),\n          } as RunEvent;\n\n          // Step 3: Write event to events store\n          await idbRequest(eventsStore.add(event), `append.addEvent(${input.runId}, seq=${seq})`);\n\n          // Step 4: Increment nextSeq in runs store (same transaction)\n          const updatedRun: RunRecordV3 = {\n            ...run,\n            nextSeq: seq + 1,\n            updatedAt: Date.now(),\n          };\n\n          await idbRequest(\n            runsStore.put(updatedRun),\n            `append.updateNextSeq(${input.runId}, nextSeq=${seq + 1})`,\n          );\n\n          return event;\n        },\n      );\n    },\n\n    /**\n     * 列出事件\n     * @description 利用复合主键 [runId, seq] 实现高效范围查询\n     */\n    async list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]> {\n      return withTransaction(RR_V3_STORES.EVENTS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.EVENTS];\n        const fromSeq = opts?.fromSeq ?? 0;\n        const limit = opts?.limit;\n\n        // Early return for zero limit\n        if (limit === 0) {\n          return [];\n        }\n\n        return new Promise<RunEvent[]>((resolve, reject) => {\n          const results: RunEvent[] = [];\n\n          // Use compound primary key [runId, seq] for efficient range query\n          // This yields events in seq-ascending order naturally\n          const range = IDBKeyRange.bound([runId, fromSeq], [runId, Number.MAX_SAFE_INTEGER]);\n\n          const request = store.openCursor(range);\n\n          request.onsuccess = () => {\n            const cursor = request.result;\n\n            if (!cursor) {\n              resolve(results);\n              return;\n            }\n\n            const event = cursor.value as RunEvent;\n            results.push(event);\n\n            // Check limit\n            if (limit !== undefined && results.length >= limit) {\n              resolve(results);\n              return;\n            }\n\n            cursor.continue();\n          };\n\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/flows.ts",
    "content": "/**\n * @fileoverview FlowV3 持久化\n * @description 实现 Flow 的 CRUD 操作\n */\n\nimport type { FlowId } from '../domain/ids';\nimport type { FlowV3 } from '../domain/flow';\nimport { FLOW_SCHEMA_VERSION } from '../domain/flow';\nimport { RR_ERROR_CODES, createRRError } from '../domain/errors';\nimport type { FlowsStore } from '../engine/storage/storage-port';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/**\n * 校验 Flow 结构\n */\nfunction validateFlow(flow: FlowV3): void {\n  // 校验 schema 版本\n  if (flow.schemaVersion !== FLOW_SCHEMA_VERSION) {\n    throw createRRError(\n      RR_ERROR_CODES.VALIDATION_ERROR,\n      `Invalid schema version: expected ${FLOW_SCHEMA_VERSION}, got ${flow.schemaVersion}`,\n    );\n  }\n\n  // 校验必填字段\n  if (!flow.id) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow id is required');\n  }\n  if (!flow.name) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow name is required');\n  }\n  if (!flow.entryNodeId) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow entryNodeId is required');\n  }\n\n  // 校验 entryNodeId 存在\n  const nodeIds = new Set(flow.nodes.map((n) => n.id));\n  if (!nodeIds.has(flow.entryNodeId)) {\n    throw createRRError(\n      RR_ERROR_CODES.VALIDATION_ERROR,\n      `Entry node \"${flow.entryNodeId}\" does not exist in flow`,\n    );\n  }\n\n  // 校验边引用\n  for (const edge of flow.edges) {\n    if (!nodeIds.has(edge.from)) {\n      throw createRRError(\n        RR_ERROR_CODES.VALIDATION_ERROR,\n        `Edge \"${edge.id}\" references non-existent source node \"${edge.from}\"`,\n      );\n    }\n    if (!nodeIds.has(edge.to)) {\n      throw createRRError(\n        RR_ERROR_CODES.VALIDATION_ERROR,\n        `Edge \"${edge.id}\" references non-existent target node \"${edge.to}\"`,\n      );\n    }\n  }\n}\n\n/**\n * 创建 FlowsStore 实现\n */\nexport function createFlowsStore(): FlowsStore {\n  return {\n    async list(): Promise<FlowV3[]> {\n      return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.FLOWS];\n        return new Promise<FlowV3[]>((resolve, reject) => {\n          const request = store.getAll();\n          request.onsuccess = () => resolve(request.result as FlowV3[]);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async get(id: FlowId): Promise<FlowV3 | null> {\n      return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.FLOWS];\n        return new Promise<FlowV3 | null>((resolve, reject) => {\n          const request = store.get(id);\n          request.onsuccess = () => resolve((request.result as FlowV3) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async save(flow: FlowV3): Promise<void> {\n      // 校验\n      validateFlow(flow);\n\n      return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.FLOWS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(flow);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async delete(id: FlowId): Promise<void> {\n      return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.FLOWS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.delete(id);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/index.ts",
    "content": "/**\n * @fileoverview Import 模块导出入口\n */\n\nexport * from './v2-reader';\nexport * from './v2-to-v3';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-reader.ts",
    "content": "/**\n * @fileoverview V2 数据读取器\n * @description 读取 V2 格式的数据（占位实现）\n */\n\n/**\n * V2 数据读取器接口\n * @description Phase 5+ 实现\n */\nexport interface V2Reader {\n  /** 读取 V2 Flows */\n  readFlows(): Promise<unknown[]>;\n  /** 读取 V2 Runs */\n  readRuns(): Promise<unknown[]>;\n  /** 读取 V2 Triggers */\n  readTriggers(): Promise<unknown[]>;\n  /** 读取 V2 Schedules */\n  readSchedules(): Promise<unknown[]>;\n}\n\n/**\n * 创建 NotImplemented 的 V2Reader\n */\nexport function createNotImplementedV2Reader(): V2Reader {\n  const notImplemented = async () => {\n    throw new Error('V2Reader not implemented');\n  };\n\n  return {\n    readFlows: notImplemented,\n    readRuns: notImplemented,\n    readTriggers: notImplemented,\n    readSchedules: notImplemented,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/import/v2-to-v3.ts",
    "content": "/**\n * @fileoverview V2 到 V3 数据转换器\n * @description 将 V2 格式数据转换为 V3 格式，支持双向转换\n */\n\nimport type { FlowV3, NodeV3, EdgeV3, FlowBinding } from '../../domain/flow';\nimport type { TriggerSpec } from '../../domain/triggers';\nimport type { VariableDefinition } from '../../domain/variables';\nimport type { NodeId, FlowId, EdgeId } from '../../domain/ids';\nimport type { ISODateTimeString } from '../../domain/json';\nimport { FLOW_SCHEMA_VERSION } from '../../domain/flow';\n\n// ==================== V2 Types (imported from record-replay) ====================\n\n/** V2 Node type definition */\ninterface V2Node {\n  id: string;\n  type: string;\n  name?: string;\n  disabled?: boolean;\n  config?: Record<string, unknown>;\n  ui?: { x: number; y: number };\n}\n\n/** V2 Edge type definition */\ninterface V2Edge {\n  id: string;\n  from: string;\n  to: string;\n  label?: string;\n}\n\n/** V2 Variable definition */\ninterface V2VariableDef {\n  key: string;\n  label?: string;\n  sensitive?: boolean;\n  default?: unknown;\n  type?: string;\n  rules?: { required?: boolean; pattern?: string; enum?: string[] };\n}\n\n/** V2 Flow binding */\ninterface V2Binding {\n  type: 'domain' | 'path' | 'url';\n  value: string;\n}\n\n/** V2 Flow definition */\ninterface V2Flow {\n  id: string;\n  name: string;\n  description?: string;\n  version: number;\n  meta?: {\n    createdAt?: string;\n    updatedAt?: string;\n    domain?: string;\n    tags?: string[];\n    bindings?: V2Binding[];\n    tool?: { category?: string; description?: string };\n    exposedOutputs?: Array<{ nodeId: string; as: string }>;\n  };\n  variables?: V2VariableDef[];\n  nodes?: V2Node[];\n  edges?: V2Edge[];\n  subflows?: Record<string, { nodes: V2Node[]; edges: V2Edge[] }>;\n}\n\n// ==================== Conversion Result Types ====================\n\nexport interface ConversionResult<T> {\n  success: boolean;\n  data?: T;\n  errors: string[];\n  warnings: string[];\n}\n\n// ==================== V2 -> V3 Conversion ====================\n\n/**\n * 将 V2 Flow 转换为 V3 Flow\n * @param v2Flow V2 格式的 Flow\n * @returns 转换结果，包含成功/失败状态、数据和错误/警告信息\n */\nexport function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult<FlowV3> {\n  const errors: string[] = [];\n  const warnings: string[] = [];\n\n  // 1. 基础字段验证\n  if (!v2Flow.id) {\n    errors.push('V2 Flow missing required field: id');\n  }\n  if (!v2Flow.name) {\n    errors.push('V2 Flow missing required field: name');\n  }\n  if (!v2Flow.nodes || v2Flow.nodes.length === 0) {\n    errors.push('V2 Flow has no nodes');\n  }\n\n  // 2. 检查不支持的特性\n  if (v2Flow.subflows && Object.keys(v2Flow.subflows).length > 0) {\n    errors.push(\n      'V3 does not support subflows yet. Flow contains subflows: ' +\n        Object.keys(v2Flow.subflows).join(', '),\n    );\n  }\n\n  // 检查 foreach/while 节点\n  const unsupportedNodes = (v2Flow.nodes || []).filter(\n    (n) => n.type === 'foreach' || n.type === 'while',\n  );\n  if (unsupportedNodes.length > 0) {\n    errors.push(\n      'V3 does not support foreach/while nodes yet. Found: ' +\n        unsupportedNodes.map((n) => `${n.id} (${n.type})`).join(', '),\n    );\n  }\n\n  // 如果有致命错误，直接返回\n  if (errors.length > 0) {\n    return { success: false, errors, warnings };\n  }\n\n  // 3. 转换节点\n  const nodes: NodeV3[] = [];\n  for (const v2Node of v2Flow.nodes || []) {\n    const node = convertNodeV2ToV3(v2Node);\n    if (node) {\n      nodes.push(node);\n    } else {\n      warnings.push(`Skipped invalid node: ${v2Node.id}`);\n    }\n  }\n\n  // 4. 转换边\n  const edges: EdgeV3[] = [];\n  for (const v2Edge of v2Flow.edges || []) {\n    const edge = convertEdgeV2ToV3(v2Edge);\n    if (edge) {\n      edges.push(edge);\n    } else {\n      warnings.push(`Skipped invalid edge: ${v2Edge.id}`);\n    }\n  }\n\n  // 5. 计算 entryNodeId\n  const entryResult = findEntryNodeId(nodes, edges);\n  warnings.push(...entryResult.warnings);\n  if (!entryResult.nodeId) {\n    errors.push('Could not determine entry node. No valid root node found.');\n    return { success: false, errors, warnings };\n  }\n  const entryNodeId = entryResult.nodeId;\n\n  // 6. 转换变量\n  const variables = convertVariablesV2ToV3(v2Flow.variables || []);\n\n  // 7. 转换元数据\n  const meta = convertMetaV2ToV3(v2Flow.meta);\n\n  // 8. 构建 V3 Flow\n  const now = new Date().toISOString() as ISODateTimeString;\n  const v3Flow: FlowV3 = {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id: v2Flow.id as FlowId,\n    name: v2Flow.name,\n    createdAt: (v2Flow.meta?.createdAt as ISODateTimeString) || now,\n    updatedAt: (v2Flow.meta?.updatedAt as ISODateTimeString) || now,\n    entryNodeId,\n    nodes,\n    edges,\n  };\n\n  // 可选字段\n  if (v2Flow.description) {\n    v3Flow.description = v2Flow.description;\n  }\n  if (variables.length > 0) {\n    v3Flow.variables = variables;\n  }\n  if (meta) {\n    v3Flow.meta = meta;\n  }\n\n  return { success: true, data: v3Flow, errors, warnings };\n}\n\n/**\n * 转换单个 V2 Node 为 V3 Node\n */\nfunction convertNodeV2ToV3(v2Node: V2Node): NodeV3 | null {\n  if (!v2Node.id || !v2Node.type) {\n    return null;\n  }\n\n  const node: NodeV3 = {\n    id: v2Node.id as NodeId,\n    kind: v2Node.type, // V2 type -> V3 kind\n    config: (v2Node.config as Record<string, unknown>) || {},\n  };\n\n  // 可选字段\n  if (v2Node.name) {\n    node.name = v2Node.name;\n  }\n  if (v2Node.disabled) {\n    node.disabled = v2Node.disabled;\n  }\n  if (v2Node.ui) {\n    node.ui = v2Node.ui;\n  }\n\n  return node;\n}\n\n/**\n * 转换单个 V2 Edge 为 V3 Edge\n */\nfunction convertEdgeV2ToV3(v2Edge: V2Edge): EdgeV3 | null {\n  if (!v2Edge.id || !v2Edge.from || !v2Edge.to) {\n    return null;\n  }\n\n  const edge: EdgeV3 = {\n    id: v2Edge.id as EdgeId,\n    from: v2Edge.from as NodeId,\n    to: v2Edge.to as NodeId,\n  };\n\n  // label 直接传递\n  if (v2Edge.label) {\n    edge.label = v2Edge.label as EdgeV3['label'];\n  }\n\n  return edge;\n}\n\n/** entryNodeId 计算结果 */\ninterface EntryNodeResult {\n  nodeId: NodeId | null;\n  warnings: string[];\n}\n\n/**\n * 找到入口节点 ID\n *\n * 规则：\n * 1. 排除 trigger 类型节点（这些是 UI 节点，不参与执行）\n * 2. 只统计「可执行节点 -> 可执行节点」的边来计算入度（忽略 trigger 指出的边）\n * 3. 找到入度为 0 的节点作为候选\n * 4. 如果有多个候选，使用稳定选择规则：\n *    - 优先选择 UI 坐标最靠左上的节点（按 x 升序，x 相同按 y 升序）\n *    - 如果无 UI 坐标，按 ID 字典序取第一个\n */\nfunction findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): EntryNodeResult {\n  const warnings: string[] = [];\n\n  // 1. 排除 trigger 节点，获取可执行节点\n  const executableNodes = nodes.filter((n) => n.kind !== 'trigger');\n  if (executableNodes.length === 0) {\n    warnings.push('No executable nodes found; cannot determine entry node');\n    return { nodeId: null, warnings };\n  }\n\n  const executableNodeIds = new Set<NodeId>(executableNodes.map((n) => n.id));\n\n  // 2. 计算入度（只统计可执行节点之间的边）\n  const inDegree = new Map<NodeId, number>();\n  for (const node of executableNodes) {\n    inDegree.set(node.id, 0);\n  }\n  for (const edge of edges) {\n    // 忽略从非可执行节点（如 trigger）指出的边\n    if (!executableNodeIds.has(edge.from)) {\n      continue;\n    }\n    // 忽略指向非可执行节点的边\n    if (!executableNodeIds.has(edge.to)) {\n      continue;\n    }\n    inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);\n  }\n\n  // 3. 找入度为 0 的节点\n  const rootNodes = executableNodes.filter((n) => inDegree.get(n.id) === 0);\n\n  if (rootNodes.length === 0) {\n    // 没有入度为 0 的节点，说明图中存在环，使用稳定选择器选择 fallback\n    const fallbackResult = selectStableRootNode(executableNodes);\n    warnings.push(\n      `No inDegree=0 executable node found (graph may contain cycles); ` +\n        `falling back to \"${fallbackResult.node.id}\" by ${fallbackResult.rule}`,\n    );\n    return { nodeId: fallbackResult.node.id, warnings };\n  }\n\n  // 4. 单个根节点，直接返回\n  if (rootNodes.length === 1) {\n    return { nodeId: rootNodes[0].id, warnings };\n  }\n\n  // 5. 多个根节点，使用稳定选择规则\n  const selectedResult = selectStableRootNode(rootNodes);\n  const candidateIds = rootNodes\n    .map((n) => n.id)\n    .sort((a, b) => a.localeCompare(b))\n    .join(', ');\n  warnings.push(\n    `Multiple inDegree=0 executable nodes (${candidateIds}); ` +\n      `selected \"${selectedResult.node.id}\" by ${selectedResult.rule}`,\n  );\n\n  return { nodeId: selectedResult.node.id, warnings };\n}\n\n/** 稳定选择结果 */\ninterface StableSelectionResult {\n  node: NodeV3;\n  rule: string;\n}\n\n/**\n * 从多个根节点中选择一个稳定的入口节点\n * 优先按 UI 坐标（左上角优先），其次按 ID 字典序\n */\nfunction selectStableRootNode(nodes: NodeV3[]): StableSelectionResult {\n  // 检查节点是否有有效的 UI 坐标\n  const hasValidUi = (n: NodeV3): n is NodeV3 & { ui: { x: number; y: number } } =>\n    !!n.ui && Number.isFinite(n.ui.x) && Number.isFinite(n.ui.y);\n\n  const nodesWithUi = nodes.filter(hasValidUi);\n\n  if (nodesWithUi.length > 0) {\n    // 按 UI 坐标排序：x 升序 -> y 升序 -> id 字典序（作为 tie-breaker）\n    nodesWithUi.sort((a, b) => {\n      if (a.ui.x !== b.ui.x) return a.ui.x - b.ui.x;\n      if (a.ui.y !== b.ui.y) return a.ui.y - b.ui.y;\n      return a.id.localeCompare(b.id);\n    });\n    const selected = nodesWithUi[0];\n    return {\n      node: selected,\n      rule: `ui(x=${selected.ui.x}, y=${selected.ui.y})`,\n    };\n  }\n\n  // 无 UI 坐标，按 ID 字典序\n  const sortedById = [...nodes].sort((a, b) => a.id.localeCompare(b.id));\n  return { node: sortedById[0], rule: 'id' };\n}\n\n/**\n * 转换变量定义\n */\nfunction convertVariablesV2ToV3(v2Variables: V2VariableDef[]): VariableDefinition[] {\n  return v2Variables\n    .filter((v) => v.key)\n    .map((v) => {\n      const variable: VariableDefinition = {\n        name: v.key,\n      };\n\n      if (v.label) {\n        variable.label = v.label;\n      }\n      if (v.sensitive) {\n        variable.sensitive = v.sensitive;\n      }\n      if (v.default !== undefined) {\n        variable.default = v.default;\n      }\n      if (v.rules?.required) {\n        variable.required = v.rules.required;\n      }\n\n      return variable;\n    });\n}\n\n/**\n * 转换元数据\n */\nfunction convertMetaV2ToV3(v2Meta: V2Flow['meta']): FlowV3['meta'] | undefined {\n  if (!v2Meta) return undefined;\n\n  const meta: FlowV3['meta'] = {};\n\n  if (v2Meta.tags && v2Meta.tags.length > 0) {\n    meta.tags = v2Meta.tags;\n  }\n\n  if (v2Meta.bindings && v2Meta.bindings.length > 0) {\n    meta.bindings = v2Meta.bindings.map((b) => ({\n      kind: b.type, // V2 type -> V3 kind\n      value: b.value,\n    }));\n  }\n\n  // 如果 meta 为空对象，返回 undefined\n  if (Object.keys(meta).length === 0) {\n    return undefined;\n  }\n\n  return meta;\n}\n\n// ==================== V3 -> V2 Conversion ====================\n\n/**\n * 将 V3 Flow 转换为 V2 Flow（用于在 V2 Builder 中编辑）\n * @param v3Flow V3 格式的 Flow\n * @returns 转换结果\n */\nexport function convertFlowV3ToV2(v3Flow: FlowV3): ConversionResult<V2Flow> {\n  const errors: string[] = [];\n  const warnings: string[] = [];\n\n  // 1. 转换节点\n  const nodes: V2Node[] = v3Flow.nodes.map((n) => ({\n    id: n.id,\n    type: n.kind, // V3 kind -> V2 type\n    name: n.name,\n    disabled: n.disabled,\n    config: n.config as Record<string, unknown>,\n    ui: n.ui,\n  }));\n\n  // 2. 转换边\n  const edges: V2Edge[] = v3Flow.edges.map((e) => ({\n    id: e.id,\n    from: e.from,\n    to: e.to,\n    label: e.label,\n  }));\n\n  // 3. 转换变量\n  const variables: V2VariableDef[] = (v3Flow.variables || []).map((v) => ({\n    key: v.name,\n    label: v.label,\n    sensitive: v.sensitive,\n    default: v.default,\n    rules: v.required ? { required: v.required } : undefined,\n  }));\n\n  // 4. 转换元数据\n  const meta: V2Flow['meta'] = {\n    createdAt: v3Flow.createdAt,\n    updatedAt: v3Flow.updatedAt,\n  };\n\n  if (v3Flow.meta?.tags) {\n    meta.tags = v3Flow.meta.tags;\n  }\n\n  if (v3Flow.meta?.bindings) {\n    meta.bindings = v3Flow.meta.bindings.map((b) => ({\n      type: b.kind, // V3 kind -> V2 type\n      value: b.value,\n    }));\n  }\n\n  // 5. 构建 V2 Flow\n  const v2Flow: V2Flow = {\n    id: v3Flow.id,\n    name: v3Flow.name,\n    description: v3Flow.description,\n    version: 2, // V2 版本\n    meta,\n    variables: variables.length > 0 ? variables : undefined,\n    nodes,\n    edges,\n  };\n\n  return { success: true, data: v2Flow, errors, warnings };\n}\n\n// ==================== Trigger Conversion ====================\n\n/** V2 Trigger 定义 */\ninterface V2Trigger {\n  id: string;\n  type: 'url' | 'command' | 'manual' | 'schedule' | 'element';\n  flowId: string;\n  enabled?: boolean;\n  match?: Array<{ kind: string; value: string }>;\n  title?: string;\n  commandKey?: string;\n  selector?: string;\n  appear?: boolean;\n  once?: boolean;\n  debounceMs?: number;\n  schedule?: {\n    type: 'interval' | 'daily' | 'weekly';\n    intervalMs?: number;\n    time?: string;\n    days?: number[];\n  };\n}\n\n/**\n * 将 V2 Trigger 转换为 V3 TriggerSpec\n * @param v2Trigger V2 格式的 Trigger\n * @returns 转换结果\n */\nexport function convertTriggerV2ToV3(v2Trigger: V2Trigger): ConversionResult<TriggerSpec> {\n  const errors: string[] = [];\n  const warnings: string[] = [];\n\n  if (!v2Trigger.id) {\n    errors.push('V2 Trigger missing required field: id');\n  }\n  if (!v2Trigger.flowId) {\n    errors.push('V2 Trigger missing required field: flowId');\n  }\n  if (!v2Trigger.type) {\n    errors.push('V2 Trigger missing required field: type');\n  }\n\n  if (errors.length > 0) {\n    return { success: false, errors, warnings };\n  }\n\n  // 根据 type 构建不同的 TriggerSpec\n  let trigger: TriggerSpec;\n\n  switch (v2Trigger.type) {\n    case 'manual':\n      trigger = {\n        id: v2Trigger.id,\n        kind: 'manual',\n        flowId: v2Trigger.flowId as FlowId,\n        enabled: v2Trigger.enabled ?? true,\n      };\n      break;\n\n    case 'command':\n      trigger = {\n        id: v2Trigger.id,\n        kind: 'command',\n        flowId: v2Trigger.flowId as FlowId,\n        enabled: v2Trigger.enabled ?? true,\n        command: v2Trigger.commandKey || 'run_workflow',\n      };\n      break;\n\n    case 'url':\n      trigger = {\n        id: v2Trigger.id,\n        kind: 'url',\n        flowId: v2Trigger.flowId as FlowId,\n        enabled: v2Trigger.enabled ?? true,\n        patterns: (v2Trigger.match || []).map((m) => m.value),\n      };\n      break;\n\n    case 'schedule': { // 将 V2 schedule 转换为 cron 表达式\n      const cron = convertScheduleToCron(v2Trigger.schedule);\n      if (!cron) {\n        errors.push('Could not convert V2 schedule to cron expression');\n        return { success: false, errors, warnings };\n      }\n      trigger = {\n        id: v2Trigger.id,\n        kind: 'cron',\n        flowId: v2Trigger.flowId as FlowId,\n        enabled: v2Trigger.enabled ?? true,\n        cron,\n      };\n      break;\n    }\n\n    case 'element':\n      warnings.push('Element trigger is not fully supported in V3, converting to manual');\n      trigger = {\n        id: v2Trigger.id,\n        kind: 'manual',\n        flowId: v2Trigger.flowId as FlowId,\n        enabled: v2Trigger.enabled ?? true,\n      };\n      break;\n\n    default:\n      errors.push(`Unknown V2 trigger type: ${v2Trigger.type}`);\n      return { success: false, errors, warnings };\n  }\n\n  return { success: true, data: trigger, errors, warnings };\n}\n\n/**\n * 将 V2 schedule 配置转换为 cron 表达式\n */\nfunction convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {\n  if (!schedule) return null;\n\n  switch (schedule.type) {\n    case 'interval': { // 将间隔转换为近似 cron（每 N 分钟）\n      const intervalMinutes = Math.max(1, Math.round((schedule.intervalMs || 60000) / 60000));\n      if (intervalMinutes < 60) {\n        return `*/${intervalMinutes} * * * *`;\n      } else {\n        const hours = Math.round(intervalMinutes / 60);\n        return `0 */${hours} * * *`;\n      }\n    }\n\n    case 'daily':\n      // 每天指定时间\n      if (schedule.time) {\n        const [hour, minute] = schedule.time.split(':').map(Number);\n        return `${minute || 0} ${hour || 0} * * *`;\n      }\n      return '0 0 * * *'; // 默认每天 0:00\n\n    case 'weekly': { // 每周指定天数和时间\n      const days = (schedule.days || [0]).join(',');\n      if (schedule.time) {\n        const [hour, minute] = schedule.time.split(':').map(Number);\n        return `${minute || 0} ${hour || 0} * * ${days}`;\n      }\n      return `0 0 * * ${days}`;\n    }\n\n    default:\n      return null;\n  }\n}\n\n// ==================== Converter Interface ====================\n\n/**\n * V2 到 V3 转换器接口\n */\nexport interface V2ToV3Converter {\n  /** 转换 Flow */\n  convertFlow(v2Flow: unknown): FlowV3;\n  /** 转换 Trigger */\n  convertTrigger(v2Trigger: unknown): TriggerSpec;\n}\n\n/**\n * 创建 V2ToV3Converter 实例\n */\nexport function createV2ToV3Converter(): V2ToV3Converter {\n  return {\n    convertFlow(v2Flow: unknown): FlowV3 {\n      const result = convertFlowV2ToV3(v2Flow as V2Flow);\n      if (!result.success || !result.data) {\n        throw new Error(`Flow conversion failed: ${result.errors.join('; ')}`);\n      }\n      return result.data;\n    },\n\n    convertTrigger(v2Trigger: unknown): TriggerSpec {\n      const result = convertTriggerV2ToV3(v2Trigger as V2Trigger);\n      if (!result.success || !result.data) {\n        throw new Error(`Trigger conversion failed: ${result.errors.join('; ')}`);\n      }\n      return result.data;\n    },\n  };\n}\n\n/**\n * 创建 NotImplemented 的 V2ToV3Converter（向后兼容）\n * @deprecated 使用 createV2ToV3Converter() 替代\n */\nexport function createNotImplementedV2ToV3Converter(): V2ToV3Converter {\n  return createV2ToV3Converter();\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/index.ts",
    "content": "/**\n * @fileoverview Storage 层导出入口\n */\n\nexport * from './db';\nexport * from './flows';\nexport * from './runs';\nexport * from './events';\nexport * from './queue';\nexport * from './persistent-vars';\nexport * from './triggers';\nexport * from './import';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/persistent-vars.ts",
    "content": "/**\n * @fileoverview 持久化变量存储\n * @description 实现 $ 前缀变量的持久化，使用 LWW（Last-Write-Wins）策略\n */\n\nimport type { PersistentVarRecord, PersistentVariableName } from '../domain/variables';\nimport type { JsonValue } from '../domain/json';\nimport type { PersistentVarsStore } from '../engine/storage/storage-port';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/**\n * 创建 PersistentVarsStore 实现\n */\nexport function createPersistentVarsStore(): PersistentVarsStore {\n  return {\n    async get(key: PersistentVariableName): Promise<PersistentVarRecord | undefined> {\n      return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.PERSISTENT_VARS];\n        return new Promise<PersistentVarRecord | undefined>((resolve, reject) => {\n          const request = store.get(key);\n          request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async set(key: PersistentVariableName, value: JsonValue): Promise<PersistentVarRecord> {\n      return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.PERSISTENT_VARS];\n\n        // 先读取现有记录（用于 version 递增）\n        const existing = await new Promise<PersistentVarRecord | undefined>((resolve, reject) => {\n          const request = store.get(key);\n          request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);\n          request.onerror = () => reject(request.error);\n        });\n\n        const now = Date.now();\n        const record: PersistentVarRecord = {\n          key,\n          value,\n          updatedAt: now,\n          version: (existing?.version ?? 0) + 1,\n        };\n\n        await new Promise<void>((resolve, reject) => {\n          const request = store.put(record);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n\n        return record;\n      });\n    },\n\n    async delete(key: PersistentVariableName): Promise<void> {\n      return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.PERSISTENT_VARS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.delete(key);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async list(prefix?: PersistentVariableName): Promise<PersistentVarRecord[]> {\n      return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.PERSISTENT_VARS];\n\n        return new Promise<PersistentVarRecord[]>((resolve, reject) => {\n          const request = store.getAll();\n          request.onsuccess = () => {\n            let results = request.result as PersistentVarRecord[];\n\n            // 如果指定了前缀，过滤结果\n            if (prefix) {\n              results = results.filter((r) => r.key.startsWith(prefix));\n            }\n\n            resolve(results);\n          };\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/queue.ts",
    "content": "/**\n * @fileoverview RunQueue 持久化\n * @description 实现队列的 CRUD 操作和原子 claim\n */\n\nimport type { RunId } from '../domain/ids';\nimport {\n  DEFAULT_QUEUE_CONFIG,\n  type EnqueueInput,\n  type QueueItemStatus,\n  type RunQueue,\n  type RunQueueItem,\n} from '../engine/queue/queue';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/** Default lease TTL in milliseconds (from shared config to avoid drift) */\nconst DEFAULT_LEASE_TTL_MS = DEFAULT_QUEUE_CONFIG.leaseTtlMs;\n\n/**\n * IDB key range bounds for numeric fields.\n * Use MAX_VALUE to cover the full range of finite numbers (not just safe integers).\n */\nconst IDB_NUMBER_MIN = -Number.MAX_VALUE;\nconst IDB_NUMBER_MAX = Number.MAX_VALUE;\n\n/**\n * 创建 RunQueue 持久化实现\n * @description 实现队列持久化，包括 Phase 3 原子 claim\n */\nexport function createQueueStore(): RunQueue {\n  return {\n    async enqueue(input: EnqueueInput): Promise<RunQueueItem> {\n      const now = Date.now();\n      const item: RunQueueItem = {\n        ...input,\n        priority: input.priority ?? 0,\n        maxAttempts: input.maxAttempts ?? 1,\n        status: 'queued',\n        createdAt: now,\n        updatedAt: now,\n        attempt: 0,\n      };\n\n      await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.add(item);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n\n      return item;\n    },\n\n    async claimNext(ownerId: string, now: number): Promise<RunQueueItem | null> {\n      // Validate inputs\n      if (!ownerId) {\n        throw new Error('ownerId is required');\n      }\n      if (!Number.isFinite(now)) {\n        throw new Error(`Invalid now: ${String(now)}`);\n      }\n\n      return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        const index = store.index('status_priority_createdAt');\n\n        /**\n         * Atomic claim implementation using two-step cursor approach:\n         *\n         * Desired ordering: priority DESC, createdAt ASC (FIFO within same priority)\n         *\n         * IndexedDB compound indexes only support single sort direction for the entire tuple.\n         * The index ['status', 'priority', 'createdAt'] is stored ASC.\n         *\n         * Strategy:\n         * 1. Use 'prev' cursor to find the highest priority (overall DESC)\n         * 2. Use 'next' cursor within that priority to find earliest createdAt (FIFO)\n         *\n         * Both operations are within the same readwrite transaction, ensuring atomicity\n         * since IndexedDB serializes readwrite transactions on the same store.\n         */\n\n        // Step 1: Find the highest priority among queued items\n        const queuedRange = IDBKeyRange.bound(\n          ['queued', IDB_NUMBER_MIN, IDB_NUMBER_MIN],\n          ['queued', IDB_NUMBER_MAX, IDB_NUMBER_MAX],\n        );\n\n        const highestPriority = await new Promise<number | null>((resolve, reject) => {\n          const request = index.openCursor(queuedRange, 'prev');\n          request.onerror = () => reject(request.error);\n          request.onsuccess = () => {\n            const cursor = request.result;\n            if (!cursor) {\n              resolve(null);\n              return;\n            }\n            const item = cursor.value as RunQueueItem;\n            resolve(item.priority);\n          };\n        });\n\n        // No queued items available\n        if (highestPriority === null) {\n          return null;\n        }\n\n        // Step 2: Find the earliest createdAt within the highest priority (FIFO)\n        const fifoRange = IDBKeyRange.bound(\n          ['queued', highestPriority, IDB_NUMBER_MIN],\n          ['queued', highestPriority, IDB_NUMBER_MAX],\n        );\n\n        return new Promise<RunQueueItem | null>((resolve, reject) => {\n          const request = index.openCursor(fifoRange, 'next');\n          request.onerror = () => reject(request.error);\n          request.onsuccess = () => {\n            const cursor = request.result;\n            if (!cursor) {\n              // No items found (should not happen given step 1 succeeded)\n              resolve(null);\n              return;\n            }\n\n            const existing = cursor.value as RunQueueItem;\n\n            // Defensive check: ensure status is still queued\n            if (existing.status !== 'queued') {\n              resolve(null);\n              return;\n            }\n\n            // Atomically update to running with lease\n            const updated: RunQueueItem = {\n              ...existing,\n              status: 'running',\n              updatedAt: now,\n              attempt: existing.attempt + 1,\n              lease: {\n                ownerId,\n                expiresAt: now + DEFAULT_LEASE_TTL_MS,\n              },\n            };\n\n            const updateRequest = cursor.update(updated);\n            updateRequest.onerror = () => reject(updateRequest.error);\n            updateRequest.onsuccess = () => resolve(updated);\n          };\n        });\n      });\n    },\n\n    async heartbeat(ownerId: string, now: number): Promise<void> {\n      // Validate inputs\n      if (!ownerId) {\n        throw new Error('ownerId is required');\n      }\n      if (!Number.isFinite(now)) {\n        throw new Error(`Invalid now: ${String(now)}`);\n      }\n\n      await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        const statusIndex = store.index('status');\n\n        /**\n         * Renew leases for all items owned by ownerId in the given status.\n         * Uses cursor iteration to update each item atomically.\n         */\n        const renewForStatus = async (status: QueueItemStatus): Promise<void> => {\n          await new Promise<void>((resolve, reject) => {\n            const request = statusIndex.openCursor(IDBKeyRange.only(status));\n            request.onerror = () => reject(request.error);\n            request.onsuccess = () => {\n              const cursor = request.result;\n              if (!cursor) {\n                resolve();\n                return;\n              }\n\n              const item = cursor.value as RunQueueItem;\n              const lease = item.lease;\n\n              // Skip items not owned by this ownerId\n              if (!lease || lease.ownerId !== ownerId) {\n                cursor.continue();\n                return;\n              }\n\n              // Renew the lease\n              const updated: RunQueueItem = {\n                ...item,\n                updatedAt: now,\n                lease: {\n                  ...lease,\n                  expiresAt: now + DEFAULT_LEASE_TTL_MS,\n                },\n              };\n\n              const updateRequest = cursor.update(updated);\n              updateRequest.onerror = () => reject(updateRequest.error);\n              updateRequest.onsuccess = () => cursor.continue();\n            };\n          });\n        };\n\n        // Renew both running and paused items for the owner.\n        // Paused items also need renewal to prevent TTL expiration during debug/manual pause.\n        await renewForStatus('running');\n        await renewForStatus('paused');\n      });\n    },\n\n    async reclaimExpiredLeases(now: number): Promise<RunId[]> {\n      if (!Number.isFinite(now)) {\n        throw new Error(`Invalid now: ${String(now)}`);\n      }\n\n      return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        const leaseIndex = store.index('lease_expiresAt');\n\n        // Scan all items where lease.expiresAt < now (strictly less than)\n        const expiredRange = IDBKeyRange.upperBound(now, true);\n\n        return new Promise<RunId[]>((resolve, reject) => {\n          const reclaimed: RunId[] = [];\n          const request = leaseIndex.openCursor(expiredRange);\n\n          request.onerror = () => reject(request.error);\n          request.onsuccess = () => {\n            const cursor = request.result;\n            if (!cursor) {\n              resolve(reclaimed);\n              return;\n            }\n\n            const item = cursor.value as RunQueueItem;\n            const expiresAtKey = cursor.key;\n\n            // Defensive: index key should be a finite number (Unix millis)\n            if (typeof expiresAtKey !== 'number' || !Number.isFinite(expiresAtKey)) {\n              cursor.continue();\n              return;\n            }\n\n            // The key range already guarantees expiresAtKey < now, but keep a guard\n            // to be resilient to non-standard IndexedDB implementations.\n            if (expiresAtKey >= now) {\n              cursor.continue();\n              return;\n            }\n\n            const isReclaimable = item.status === 'running' || item.status === 'paused';\n\n            // Reclaim policy:\n            // - running/paused + expired lease => move back to queued, drop lease\n            // - any other status + expired lease => drop lease defensively (shouldn't happen)\n            // Note: attempt is NOT reset on reclaim - preserves retry history.\n            const { lease: _droppedLease, ...itemWithoutLease } = item;\n            const updated: RunQueueItem = isReclaimable\n              ? { ...itemWithoutLease, status: 'queued', updatedAt: now }\n              : { ...itemWithoutLease, updatedAt: now };\n\n            const updateRequest = cursor.update(updated);\n            updateRequest.onerror = () => reject(updateRequest.error);\n            updateRequest.onsuccess = () => {\n              if (isReclaimable) {\n                reclaimed.push(item.id);\n              }\n              cursor.continue();\n            };\n          };\n        });\n      });\n    },\n\n    async recoverOrphanLeases(\n      ownerId: string,\n      now: number,\n    ): Promise<{\n      requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>;\n      adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>;\n    }> {\n      // Validate inputs\n      if (!ownerId) {\n        throw new Error('ownerId is required');\n      }\n      if (!Number.isFinite(now)) {\n        throw new Error(`Invalid now: ${String(now)}`);\n      }\n\n      return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        const statusIndex = store.index('status');\n\n        const requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = [];\n        const adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = [];\n\n        /**\n         * 扫描并回收孤儿 running 项\n         * @description\n         * - 孤儿定义：无租约或 lease.ownerId !== currentOwnerId\n         * - 回收策略：status -> queued，清除 lease，保留 attempt\n         */\n        const recoverRunningItems = (): Promise<void> =>\n          new Promise<void>((resolve, reject) => {\n            const request = statusIndex.openCursor(IDBKeyRange.only('running'));\n            request.onerror = () => reject(request.error);\n            request.onsuccess = () => {\n              const cursor = request.result;\n              if (!cursor) {\n                resolve();\n                return;\n              }\n\n              const item = cursor.value as RunQueueItem;\n              const prevOwnerId = item.lease?.ownerId;\n\n              // 非孤儿：lease 存在且属于当前 ownerId\n              const isOrphan = !item.lease || item.lease.ownerId !== ownerId;\n              if (!isOrphan) {\n                cursor.continue();\n                return;\n              }\n\n              // 回收：移除 lease，状态改为 queued\n              const { lease: _droppedLease, ...itemWithoutLease } = item;\n              const updated: RunQueueItem = {\n                ...itemWithoutLease,\n                status: 'queued',\n                updatedAt: now,\n              };\n\n              const updateRequest = cursor.update(updated);\n              updateRequest.onerror = () => reject(updateRequest.error);\n              updateRequest.onsuccess = () => {\n                requeuedRunning.push({\n                  runId: item.id,\n                  ...(prevOwnerId ? { prevOwnerId } : {}),\n                });\n                cursor.continue();\n              };\n            };\n          });\n\n        /**\n         * 扫描并接管孤儿 paused 项\n         * @description\n         * - 孤儿定义：无租约或 lease.ownerId !== currentOwnerId\n         * - 接管策略：保持 status=paused，更新 lease.ownerId 为新 ownerId，续约 TTL\n         */\n        const recoverPausedItems = (): Promise<void> =>\n          new Promise<void>((resolve, reject) => {\n            const request = statusIndex.openCursor(IDBKeyRange.only('paused'));\n            request.onerror = () => reject(request.error);\n            request.onsuccess = () => {\n              const cursor = request.result;\n              if (!cursor) {\n                resolve();\n                return;\n              }\n\n              const item = cursor.value as RunQueueItem;\n              const prevOwnerId = item.lease?.ownerId;\n\n              // 非孤儿：lease 存在且属于当前 ownerId\n              const isOrphan = !item.lease || item.lease.ownerId !== ownerId;\n              if (!isOrphan) {\n                cursor.continue();\n                return;\n              }\n\n              // 接管：更新 lease 为新 ownerId，续约 TTL\n              const updated: RunQueueItem = {\n                ...item,\n                updatedAt: now,\n                lease: {\n                  ownerId,\n                  expiresAt: now + DEFAULT_LEASE_TTL_MS,\n                },\n              };\n\n              const updateRequest = cursor.update(updated);\n              updateRequest.onerror = () => reject(updateRequest.error);\n              updateRequest.onsuccess = () => {\n                adoptedPaused.push({\n                  runId: item.id,\n                  ...(prevOwnerId ? { prevOwnerId } : {}),\n                });\n                cursor.continue();\n              };\n            };\n          });\n\n        // 顺序执行：先处理 running，再处理 paused\n        await recoverRunningItems();\n        await recoverPausedItems();\n\n        return { requeuedRunning, adoptedPaused };\n      });\n    },\n\n    async markRunning(runId: RunId, ownerId: string, now: number): Promise<void> {\n      await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n\n        const existing = await new Promise<RunQueueItem | null>((resolve, reject) => {\n          const request = store.get(runId);\n          request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n\n        if (!existing) {\n          throw new Error(`Queue item \"${runId}\" not found`);\n        }\n\n        // Attempt semantics:\n        // - queued -> running: attempt + 1 (a new scheduling attempt)\n        // - paused/running -> running: attempt unchanged (resume/idempotent)\n        const nextAttempt = existing.status === 'queued' ? existing.attempt + 1 : existing.attempt;\n\n        const updated: RunQueueItem = {\n          ...existing,\n          status: 'running',\n          updatedAt: now,\n          attempt: nextAttempt,\n          lease: {\n            ownerId,\n            expiresAt: now + DEFAULT_LEASE_TTL_MS,\n          },\n        };\n\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(updated);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async markPaused(runId: RunId, ownerId: string, now: number): Promise<void> {\n      await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n\n        const existing = await new Promise<RunQueueItem | null>((resolve, reject) => {\n          const request = store.get(runId);\n          request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n\n        if (!existing) {\n          throw new Error(`Queue item \"${runId}\" not found`);\n        }\n\n        const updated: RunQueueItem = {\n          ...existing,\n          status: 'paused',\n          updatedAt: now,\n          lease: {\n            ownerId,\n            expiresAt: now + DEFAULT_LEASE_TTL_MS,\n          },\n        };\n\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(updated);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async markDone(runId: RunId, now: number): Promise<void> {\n      await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.delete(runId);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async cancel(runId: RunId, _now: number, _reason?: string): Promise<void> {\n      // 从队列中删除\n      await this.markDone(runId, _now);\n    },\n\n    async get(runId: RunId): Promise<RunQueueItem | null> {\n      return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n        return new Promise<RunQueueItem | null>((resolve, reject) => {\n          const request = store.get(runId);\n          request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async list(status?: QueueItemStatus): Promise<RunQueueItem[]> {\n      return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.QUEUE];\n\n        if (status) {\n          // 使用索引查询\n          const index = store.index('status');\n          return new Promise<RunQueueItem[]>((resolve, reject) => {\n            const request = index.getAll(IDBKeyRange.only(status));\n            request.onsuccess = () => resolve(request.result as RunQueueItem[]);\n            request.onerror = () => reject(request.error);\n          });\n        }\n\n        // 获取所有\n        return new Promise<RunQueueItem[]>((resolve, reject) => {\n          const request = store.getAll();\n          request.onsuccess = () => resolve(request.result as RunQueueItem[]);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/runs.ts",
    "content": "/**\n * @fileoverview RunRecordV3 持久化\n * @description 实现 Run 记录的 CRUD 操作\n */\n\nimport type { RunId } from '../domain/ids';\nimport type { RunRecordV3 } from '../domain/events';\nimport { RUN_SCHEMA_VERSION } from '../domain/events';\nimport { RR_ERROR_CODES, createRRError } from '../domain/errors';\nimport type { RunsStore } from '../engine/storage/storage-port';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/**\n * 校验 Run 记录结构\n */\nfunction validateRunRecord(record: RunRecordV3): void {\n  // 校验 schema 版本\n  if (record.schemaVersion !== RUN_SCHEMA_VERSION) {\n    throw createRRError(\n      RR_ERROR_CODES.VALIDATION_ERROR,\n      `Invalid schema version: expected ${RUN_SCHEMA_VERSION}, got ${record.schemaVersion}`,\n    );\n  }\n\n  // 校验必填字段\n  if (!record.id) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run id is required');\n  }\n  if (!record.flowId) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run flowId is required');\n  }\n  if (!record.status) {\n    throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Run status is required');\n  }\n}\n\n/**\n * 创建 RunsStore 实现\n */\nexport function createRunsStore(): RunsStore {\n  return {\n    async list(): Promise<RunRecordV3[]> {\n      return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.RUNS];\n        return new Promise<RunRecordV3[]>((resolve, reject) => {\n          const request = store.getAll();\n          request.onsuccess = () => resolve(request.result as RunRecordV3[]);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async get(id: RunId): Promise<RunRecordV3 | null> {\n      return withTransaction(RR_V3_STORES.RUNS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.RUNS];\n        return new Promise<RunRecordV3 | null>((resolve, reject) => {\n          const request = store.get(id);\n          request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async save(record: RunRecordV3): Promise<void> {\n      // 校验\n      validateRunRecord(record);\n\n      return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.RUNS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(record);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async patch(id: RunId, patch: Partial<RunRecordV3>): Promise<void> {\n      return withTransaction(RR_V3_STORES.RUNS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.RUNS];\n\n        // 先读取现有记录\n        const existing = await new Promise<RunRecordV3 | null>((resolve, reject) => {\n          const request = store.get(id);\n          request.onsuccess = () => resolve((request.result as RunRecordV3) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n\n        if (!existing) {\n          throw createRRError(RR_ERROR_CODES.INTERNAL, `Run \"${id}\" not found`);\n        }\n\n        // 合并并更新\n        const updated: RunRecordV3 = {\n          ...existing,\n          ...patch,\n          id: existing.id, // 确保 id 不变\n          schemaVersion: existing.schemaVersion, // 确保版本不变\n          updatedAt: Date.now(),\n        };\n\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(updated);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/record-replay-v3/storage/triggers.ts",
    "content": "/**\n * @fileoverview 触发器存储\n * @description 实现触发器的 CRUD 操作（Phase 4 完整实现）\n */\n\nimport type { TriggerId } from '../domain/ids';\nimport type { TriggerSpec } from '../domain/triggers';\nimport type { TriggersStore } from '../engine/storage/storage-port';\nimport { RR_V3_STORES, withTransaction } from './db';\n\n/**\n * 创建 TriggersStore 实现\n */\nexport function createTriggersStore(): TriggersStore {\n  return {\n    async list(): Promise<TriggerSpec[]> {\n      return withTransaction(RR_V3_STORES.TRIGGERS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.TRIGGERS];\n        return new Promise<TriggerSpec[]>((resolve, reject) => {\n          const request = store.getAll();\n          request.onsuccess = () => resolve(request.result as TriggerSpec[]);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async get(id: TriggerId): Promise<TriggerSpec | null> {\n      return withTransaction(RR_V3_STORES.TRIGGERS, 'readonly', async (stores) => {\n        const store = stores[RR_V3_STORES.TRIGGERS];\n        return new Promise<TriggerSpec | null>((resolve, reject) => {\n          const request = store.get(id);\n          request.onsuccess = () => resolve((request.result as TriggerSpec) ?? null);\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async save(spec: TriggerSpec): Promise<void> {\n      return withTransaction(RR_V3_STORES.TRIGGERS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.TRIGGERS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.put(spec);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n\n    async delete(id: TriggerId): Promise<void> {\n      return withTransaction(RR_V3_STORES.TRIGGERS, 'readwrite', async (stores) => {\n        const store = stores[RR_V3_STORES.TRIGGERS];\n        return new Promise<void>((resolve, reject) => {\n          const request = store.delete(id);\n          request.onsuccess = () => resolve();\n          request.onerror = () => reject(request.error);\n        });\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/semantic-similarity.ts",
    "content": "import type { ModelPreset } from '@/utils/semantic-similarity-engine';\nimport { OffscreenManager } from '@/utils/offscreen-manager';\nimport { BACKGROUND_MESSAGE_TYPES, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';\nimport { STORAGE_KEYS, ERROR_MESSAGES } from '@/common/constants';\nimport { hasAnyModelCache } from '@/utils/semantic-similarity-engine';\n\n/**\n * Model configuration state management interface\n */\ninterface ModelConfig {\n  modelPreset: ModelPreset;\n  modelVersion: 'full' | 'quantized' | 'compressed';\n  modelDimension: number;\n}\n\nlet currentBackgroundModelConfig: ModelConfig | null = null;\n\n/**\n * Initialize semantic engine only if model cache exists\n * This is called during plugin startup to avoid downloading models unnecessarily\n */\nexport async function initializeSemanticEngineIfCached(): Promise<boolean> {\n  try {\n    console.log('Background: Checking if semantic engine should be initialized from cache...');\n\n    const hasCachedModel = await hasAnyModelCache();\n    if (!hasCachedModel) {\n      console.log('Background: No cached models found, skipping semantic engine initialization');\n      return false;\n    }\n\n    console.log('Background: Found cached models, initializing semantic engine...');\n    await initializeDefaultSemanticEngine();\n    return true;\n  } catch (error) {\n    console.error('Background: Error during conditional semantic engine initialization:', error);\n    return false;\n  }\n}\n\n/**\n * Initialize default semantic engine model\n */\nexport async function initializeDefaultSemanticEngine(): Promise<void> {\n  try {\n    console.log('Background: Initializing default semantic engine...');\n\n    // Update status to initializing\n    await updateModelStatus('initializing', 0);\n\n    const result = await chrome.storage.local.get([STORAGE_KEYS.SEMANTIC_MODEL, 'selectedVersion']);\n    const defaultModel =\n      (result[STORAGE_KEYS.SEMANTIC_MODEL] as ModelPreset) || 'multilingual-e5-small';\n    const defaultVersion =\n      (result.selectedVersion as 'full' | 'quantized' | 'compressed') || 'quantized';\n\n    const { PREDEFINED_MODELS } = await import('@/utils/semantic-similarity-engine');\n    const modelInfo = PREDEFINED_MODELS[defaultModel];\n\n    await OffscreenManager.getInstance().ensureOffscreenDocument();\n\n    const response = await chrome.runtime.sendMessage({\n      target: 'offscreen',\n      type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,\n      config: {\n        useLocalFiles: false,\n        modelPreset: defaultModel,\n        modelVersion: defaultVersion,\n        modelDimension: modelInfo.dimension,\n        forceOffscreen: true,\n      },\n    });\n\n    if (response && response.success) {\n      currentBackgroundModelConfig = {\n        modelPreset: defaultModel,\n        modelVersion: defaultVersion,\n        modelDimension: modelInfo.dimension,\n      };\n      console.log('Semantic engine initialized successfully:', currentBackgroundModelConfig);\n\n      // Update status to ready\n      await updateModelStatus('ready', 100);\n\n      // Also initialize ContentIndexer now that semantic engine is ready\n      try {\n        const { getGlobalContentIndexer } = await import('@/utils/content-indexer');\n        const contentIndexer = getGlobalContentIndexer();\n        contentIndexer.startSemanticEngineInitialization();\n        console.log('ContentIndexer initialization triggered after semantic engine initialization');\n      } catch (indexerError) {\n        console.warn(\n          'Failed to initialize ContentIndexer after semantic engine initialization:',\n          indexerError,\n        );\n      }\n    } else {\n      const errorMessage = response?.error || ERROR_MESSAGES.TOOL_EXECUTION_FAILED;\n      await updateModelStatus('error', 0, errorMessage, 'unknown');\n      throw new Error(errorMessage);\n    }\n  } catch (error: any) {\n    console.error('Background: Failed to initialize default semantic engine:', error);\n    const errorMessage = error?.message || 'Unknown error during semantic engine initialization';\n    await updateModelStatus('error', 0, errorMessage, 'unknown');\n    // Don't throw error, let the extension continue running\n  }\n}\n\n/**\n * Check if model switch is needed\n */\nfunction needsModelSwitch(\n  modelPreset: ModelPreset,\n  modelVersion: 'full' | 'quantized' | 'compressed',\n  modelDimension?: number,\n): boolean {\n  if (!currentBackgroundModelConfig) {\n    return true;\n  }\n\n  const keyFields = ['modelPreset', 'modelVersion', 'modelDimension'];\n  for (const field of keyFields) {\n    const newValue =\n      field === 'modelPreset'\n        ? modelPreset\n        : field === 'modelVersion'\n          ? modelVersion\n          : modelDimension;\n    if (newValue !== currentBackgroundModelConfig[field as keyof ModelConfig]) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\n/**\n * Handle model switching\n */\nexport async function handleModelSwitch(\n  modelPreset: ModelPreset,\n  modelVersion: 'full' | 'quantized' | 'compressed' = 'quantized',\n  modelDimension?: number,\n  previousDimension?: number,\n): Promise<{ success: boolean; error?: string }> {\n  try {\n    const needsSwitch = needsModelSwitch(modelPreset, modelVersion, modelDimension);\n    if (!needsSwitch) {\n      await updateModelStatus('ready', 100);\n      return { success: true };\n    }\n\n    await updateModelStatus('downloading', 0);\n\n    try {\n      await OffscreenManager.getInstance().ensureOffscreenDocument();\n    } catch (offscreenError) {\n      console.error('Background: Failed to create offscreen document:', offscreenError);\n      const errorMessage = `Failed to create offscreen document: ${offscreenError}`;\n      await updateModelStatus('error', 0, errorMessage, 'unknown');\n      return { success: false, error: errorMessage };\n    }\n\n    const response = await chrome.runtime.sendMessage({\n      target: 'offscreen',\n      type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,\n      config: {\n        useLocalFiles: false,\n        modelPreset: modelPreset,\n        modelVersion: modelVersion,\n        modelDimension: modelDimension,\n        forceOffscreen: true,\n      },\n    });\n\n    if (response && response.success) {\n      currentBackgroundModelConfig = {\n        modelPreset: modelPreset,\n        modelVersion: modelVersion,\n        modelDimension: modelDimension!,\n      };\n\n      // Only reinitialize ContentIndexer when dimension changes\n      try {\n        if (modelDimension && previousDimension && modelDimension !== previousDimension) {\n          const { getGlobalContentIndexer } = await import('@/utils/content-indexer');\n          const contentIndexer = getGlobalContentIndexer();\n          await contentIndexer.reinitialize();\n        }\n      } catch (indexerError) {\n        console.warn('Background: Failed to reinitialize ContentIndexer:', indexerError);\n      }\n\n      await updateModelStatus('ready', 100);\n      return { success: true };\n    } else {\n      const errorMessage = response?.error || 'Failed to switch model';\n      const errorType = analyzeErrorType(errorMessage);\n      await updateModelStatus('error', 0, errorMessage, errorType);\n      throw new Error(errorMessage);\n    }\n  } catch (error: any) {\n    console.error('Model switch failed:', error);\n    const errorMessage = error.message || 'Unknown error';\n    const errorType = analyzeErrorType(errorMessage);\n    await updateModelStatus('error', 0, errorMessage, errorType);\n    return { success: false, error: errorMessage };\n  }\n}\n\n/**\n * Get model status\n */\nexport async function handleGetModelStatus(): Promise<{\n  success: boolean;\n  status?: any;\n  error?: string;\n}> {\n  try {\n    if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {\n      console.error('Background: chrome.storage.local is not available for status query');\n      return {\n        success: true,\n        status: {\n          initializationStatus: 'idle',\n          downloadProgress: 0,\n          isDownloading: false,\n          lastUpdated: Date.now(),\n        },\n      };\n    }\n\n    const result = await chrome.storage.local.get(['modelState']);\n    const modelState = result.modelState || {\n      status: 'idle',\n      downloadProgress: 0,\n      isDownloading: false,\n      lastUpdated: Date.now(),\n    };\n\n    return {\n      success: true,\n      status: {\n        initializationStatus: modelState.status,\n        downloadProgress: modelState.downloadProgress,\n        isDownloading: modelState.isDownloading,\n        lastUpdated: modelState.lastUpdated,\n        errorMessage: modelState.errorMessage,\n        errorType: modelState.errorType,\n      },\n    };\n  } catch (error: any) {\n    console.error('Failed to get model status:', error);\n    return { success: false, error: error.message };\n  }\n}\n\n/**\n * Update model status\n */\nexport async function updateModelStatus(\n  status: string,\n  progress: number,\n  errorMessage?: string,\n  errorType?: string,\n): Promise<void> {\n  try {\n    // Check if chrome.storage is available\n    if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {\n      console.error('Background: chrome.storage.local is not available for status update');\n      return;\n    }\n\n    const modelState = {\n      status,\n      downloadProgress: progress,\n      isDownloading: status === 'downloading' || status === 'initializing',\n      lastUpdated: Date.now(),\n      errorMessage: errorMessage || '',\n      errorType: errorType || '',\n    };\n    await chrome.storage.local.set({ modelState });\n  } catch (error) {\n    console.error('Failed to update model status:', error);\n  }\n}\n\n/**\n * Handle model status updates from offscreen document\n */\nexport async function handleUpdateModelStatus(\n  modelState: any,\n): Promise<{ success: boolean; error?: string }> {\n  try {\n    // Check if chrome.storage is available\n    if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.local) {\n      console.error('Background: chrome.storage.local is not available');\n      return { success: false, error: 'chrome.storage.local is not available' };\n    }\n\n    await chrome.storage.local.set({ modelState });\n    return { success: true };\n  } catch (error: any) {\n    console.error('Background: Failed to update model status:', error);\n    return { success: false, error: error.message };\n  }\n}\n\n/**\n * Analyze error type based on error message\n */\nfunction analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' {\n  const message = errorMessage.toLowerCase();\n\n  if (\n    message.includes('network') ||\n    message.includes('fetch') ||\n    message.includes('timeout') ||\n    message.includes('connection') ||\n    message.includes('cors') ||\n    message.includes('failed to fetch')\n  ) {\n    return 'network';\n  }\n\n  if (\n    message.includes('corrupt') ||\n    message.includes('invalid') ||\n    message.includes('format') ||\n    message.includes('parse') ||\n    message.includes('decode') ||\n    message.includes('onnx')\n  ) {\n    return 'file';\n  }\n\n  return 'unknown';\n}\n\n/**\n * Initialize semantic similarity module message listeners\n */\nexport const initSemanticSimilarityListener = () => {\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    if (message.type === BACKGROUND_MESSAGE_TYPES.SWITCH_SEMANTIC_MODEL) {\n      handleModelSwitch(\n        message.modelPreset,\n        message.modelVersion,\n        message.modelDimension,\n        message.previousDimension,\n      )\n        .then((result: { success: boolean; error?: string }) => sendResponse(result))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    } else if (message.type === BACKGROUND_MESSAGE_TYPES.GET_MODEL_STATUS) {\n      handleGetModelStatus()\n        .then((result: { success: boolean; status?: any; error?: string }) => sendResponse(result))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    } else if (message.type === BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS) {\n      handleUpdateModelStatus(message.modelState)\n        .then((result: { success: boolean; error?: string }) => sendResponse(result))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    } else if (message.type === BACKGROUND_MESSAGE_TYPES.INITIALIZE_SEMANTIC_ENGINE) {\n      initializeDefaultSemanticEngine()\n        .then(() => sendResponse({ success: true }))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    }\n  });\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/storage-manager.ts",
    "content": "import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\n\n/**\n * Get storage statistics\n */\nexport async function handleGetStorageStats(): Promise<{\n  success: boolean;\n  stats?: any;\n  error?: string;\n}> {\n  try {\n    // Get ContentIndexer statistics\n    const { getGlobalContentIndexer } = await import('@/utils/content-indexer');\n    const contentIndexer = getGlobalContentIndexer();\n\n    // Note: Semantic engine initialization is now user-controlled\n    // ContentIndexer will be initialized when user manually triggers semantic engine initialization\n\n    // Get statistics\n    const stats = contentIndexer.getStats();\n\n    return {\n      success: true,\n      stats: {\n        indexedPages: stats.indexedPages || 0,\n        totalDocuments: stats.totalDocuments || 0,\n        totalTabs: stats.totalTabs || 0,\n        indexSize: stats.indexSize || 0,\n        isInitialized: stats.isInitialized || false,\n        semanticEngineReady: stats.semanticEngineReady || false,\n        semanticEngineInitializing: stats.semanticEngineInitializing || false,\n      },\n    };\n  } catch (error: any) {\n    console.error('Background: Failed to get storage stats:', error);\n    return {\n      success: false,\n      error: error.message,\n      stats: {\n        indexedPages: 0,\n        totalDocuments: 0,\n        totalTabs: 0,\n        indexSize: 0,\n        isInitialized: false,\n        semanticEngineReady: false,\n        semanticEngineInitializing: false,\n      },\n    };\n  }\n}\n\n/**\n * Clear all data\n */\nexport async function handleClearAllData(): Promise<{ success: boolean; error?: string }> {\n  try {\n    // 1. Clear all ContentIndexer indexes\n    try {\n      const { getGlobalContentIndexer } = await import('@/utils/content-indexer');\n      const contentIndexer = getGlobalContentIndexer();\n\n      await contentIndexer.clearAllIndexes();\n      console.log('Storage: ContentIndexer indexes cleared successfully');\n    } catch (indexerError) {\n      console.warn('Background: Failed to clear ContentIndexer indexes:', indexerError);\n      // Continue with other cleanup operations\n    }\n\n    // 2. Clear all VectorDatabase data\n    try {\n      const { clearAllVectorData } = await import('@/utils/vector-database');\n      await clearAllVectorData();\n      console.log('Storage: Vector database data cleared successfully');\n    } catch (vectorError) {\n      console.warn('Background: Failed to clear vector data:', vectorError);\n      // Continue with other cleanup operations\n    }\n\n    // 3. Clear related data in chrome.storage (preserve model preferences)\n    try {\n      const keysToRemove = ['vectorDatabaseStats', 'lastCleanupTime', 'contentIndexerStats'];\n      await chrome.storage.local.remove(keysToRemove);\n      console.log('Storage: Chrome storage data cleared successfully');\n    } catch (storageError) {\n      console.warn('Background: Failed to clear chrome storage data:', storageError);\n    }\n\n    return { success: true };\n  } catch (error: any) {\n    console.error('Background: Failed to clear all data:', error);\n    return { success: false, error: error.message };\n  }\n}\n\n/**\n * Initialize storage manager module message listeners\n */\nexport const initStorageManagerListener = () => {\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    if (message.type === BACKGROUND_MESSAGE_TYPES.GET_STORAGE_STATS) {\n      handleGetStorageStats()\n        .then((result: { success: boolean; stats?: any; error?: string }) => sendResponse(result))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    } else if (message.type === BACKGROUND_MESSAGE_TYPES.CLEAR_ALL_DATA) {\n      handleClearAllData()\n        .then((result: { success: boolean; error?: string }) => sendResponse(result))\n        .catch((error: any) => sendResponse({ success: false, error: error.message }));\n      return true;\n    }\n  });\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/base-browser.ts",
    "content": "import { ToolExecutor } from '@/common/tool-handler';\nimport type { ToolResult } from '@/common/tool-handler';\nimport { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';\n\nconst PING_TIMEOUT_MS = 300;\n\n/**\n * Base class for browser tool executors\n */\nexport abstract class BaseBrowserToolExecutor implements ToolExecutor {\n  abstract name: string;\n  abstract execute(args: any): Promise<ToolResult>;\n\n  /**\n   * Inject content script into tab\n   */\n  protected async injectContentScript(\n    tabId: number,\n    files: string[],\n    injectImmediately = false,\n    world: 'MAIN' | 'ISOLATED' = 'ISOLATED',\n    allFrames: boolean = false,\n    frameIds?: number[],\n  ): Promise<void> {\n    console.log(`Injecting ${files.join(', ')} into tab ${tabId}`);\n\n    // check if script is already injected\n    try {\n      const pingFrameId = frameIds?.[0];\n      const response = await Promise.race([\n        typeof pingFrameId === 'number'\n          ? chrome.tabs.sendMessage(\n              tabId,\n              { action: `${this.name}_ping` },\n              { frameId: pingFrameId },\n            )\n          : chrome.tabs.sendMessage(tabId, { action: `${this.name}_ping` }),\n        new Promise((_, reject) =>\n          setTimeout(\n            () => reject(new Error(`${this.name} Ping action to tab ${tabId} timed out`)),\n            PING_TIMEOUT_MS,\n          ),\n        ),\n      ]);\n\n      if (response && response.status === 'pong') {\n        console.log(\n          `pong received for action '${this.name}' in tab ${tabId}. Assuming script is active.`,\n        );\n        return;\n      } else {\n        console.warn(`Unexpected ping response in tab ${tabId}:`, response);\n      }\n    } catch (error) {\n      console.error(\n        `ping content script failed: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n\n    try {\n      const target: { tabId: number; allFrames?: boolean; frameIds?: number[] } = { tabId };\n      if (frameIds && frameIds.length > 0) {\n        target.frameIds = frameIds;\n      } else if (allFrames) {\n        target.allFrames = true;\n      }\n      await chrome.scripting.executeScript({\n        target,\n        files,\n        injectImmediately,\n        world,\n      } as any);\n      console.log(`'${files.join(', ')}' injection successful for tab ${tabId}`);\n    } catch (injectionError) {\n      const errorMessage =\n        injectionError instanceof Error ? injectionError.message : String(injectionError);\n      console.error(\n        `Content script '${files.join(', ')}' injection failed for tab ${tabId}: ${errorMessage}`,\n      );\n      throw new Error(\n        `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to inject content script in tab ${tabId}: ${errorMessage}`,\n      );\n    }\n  }\n\n  /**\n   * Send message to tab\n   */\n  protected async sendMessageToTab(tabId: number, message: any, frameId?: number): Promise<any> {\n    try {\n      const response =\n        typeof frameId === 'number'\n          ? await chrome.tabs.sendMessage(tabId, message, { frameId })\n          : await chrome.tabs.sendMessage(tabId, message);\n\n      if (response && response.error) {\n        throw new Error(String(response.error));\n      }\n\n      return response;\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : String(error);\n      console.error(\n        `Error sending message to tab ${tabId} for action ${message?.action || 'unknown'}: ${errorMessage}`,\n      );\n\n      if (error instanceof Error) {\n        throw error;\n      }\n      throw new Error(errorMessage);\n    }\n  }\n\n  /**\n   * Try to get an existing tab by id. Returns null when not found.\n   */\n  protected async tryGetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {\n    if (typeof tabId !== 'number') return null;\n    try {\n      return await chrome.tabs.get(tabId);\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Get the active tab in the current window. Throws when not found.\n   */\n  protected async getActiveTabOrThrow(): Promise<chrome.tabs.Tab> {\n    const [active] = await chrome.tabs.query({ active: true, currentWindow: true });\n    if (!active || !active.id) throw new Error('Active tab not found');\n    return active;\n  }\n\n  /**\n   * Optionally focus window and/or activate tab. Defaults preserve current behavior\n   * when caller sets activate/focus flags explicitly.\n   */\n  protected async ensureFocus(\n    tab: chrome.tabs.Tab,\n    options: { activate?: boolean; focusWindow?: boolean } = {},\n  ): Promise<void> {\n    const activate = options.activate === true;\n    const focusWindow = options.focusWindow === true;\n    if (focusWindow && typeof tab.windowId === 'number') {\n      await chrome.windows.update(tab.windowId, { focused: true });\n    }\n    if (activate && typeof tab.id === 'number') {\n      await chrome.tabs.update(tab.id, { active: true });\n    }\n  }\n\n  /**\n   * Get the active tab. When windowId provided, search within that window; otherwise currentWindow.\n   */\n  protected async getActiveTabInWindow(windowId?: number): Promise<chrome.tabs.Tab | null> {\n    if (typeof windowId === 'number') {\n      const tabs = await chrome.tabs.query({ active: true, windowId });\n      return tabs && tabs[0] ? tabs[0] : null;\n    }\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    return tabs && tabs[0] ? tabs[0] : null;\n  }\n\n  /**\n   * Same as getActiveTabInWindow, but throws if not found.\n   */\n  protected async getActiveTabOrThrowInWindow(windowId?: number): Promise<chrome.tabs.Tab> {\n    const tab = await this.getActiveTabInWindow(windowId);\n    if (!tab || !tab.id) throw new Error('Active tab not found');\n    return tab;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/bookmark.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { getMessage } from '@/utils/i18n';\n\n/**\n * Bookmark search tool parameters interface\n */\ninterface BookmarkSearchToolParams {\n  query?: string; // Search keywords for matching bookmark titles and URLs\n  maxResults?: number; // Maximum number of results to return\n  folderPath?: string; // Optional, specify which folder to search in (can be ID or path string like \"Work/Projects\")\n}\n\n/**\n * Bookmark add tool parameters interface\n */\ninterface BookmarkAddToolParams {\n  url?: string; // URL to add as bookmark, if not provided use current active tab URL\n  title?: string; // Bookmark title, if not provided use page title\n  parentId?: string; // Parent folder ID or path string (like \"Work/Projects\"), if not provided add to \"Bookmarks Bar\" folder\n  createFolder?: boolean; // Whether to automatically create parent folder if it doesn't exist\n}\n\n/**\n * Bookmark delete tool parameters interface\n */\ninterface BookmarkDeleteToolParams {\n  bookmarkId?: string; // ID of bookmark to delete\n  url?: string; // URL of bookmark to delete (if ID not provided, search by URL)\n  title?: string; // Title of bookmark to delete (used for auxiliary matching, used together with URL)\n}\n\n// --- Helper Functions ---\n\n/**\n * Get the complete folder path of a bookmark\n * @param bookmarkNodeId ID of the bookmark or folder\n * @returns Returns folder path string (e.g., \"Bookmarks Bar > Folder A > Subfolder B\")\n */\nasync function getBookmarkFolderPath(bookmarkNodeId: string): Promise<string> {\n  const pathParts: string[] = [];\n\n  try {\n    // First get the node itself to check if it's a bookmark or folder\n    const initialNodes = await chrome.bookmarks.get(bookmarkNodeId);\n    if (initialNodes.length > 0 && initialNodes[0]) {\n      const initialNode = initialNodes[0];\n\n      // Build path starting from parent node (same for both bookmarks and folders)\n      let pathNodeId = initialNode.parentId;\n      while (pathNodeId) {\n        const parentNodes = await chrome.bookmarks.get(pathNodeId);\n        if (parentNodes.length === 0) break;\n\n        const parentNode = parentNodes[0];\n        if (parentNode.title) {\n          pathParts.unshift(parentNode.title);\n        }\n\n        if (!parentNode.parentId) break;\n        pathNodeId = parentNode.parentId;\n      }\n    }\n  } catch (error) {\n    console.error(`Error getting bookmark path for node ID ${bookmarkNodeId}:`, error);\n    return pathParts.join(' > ') || 'Error getting path';\n  }\n\n  return pathParts.join(' > ');\n}\n\n/**\n * Find bookmark folder by ID or path string\n * If it's an ID, validate it\n * If it's a path string, try to parse it\n * @param pathOrId Path string (e.g., \"Work/Projects\") or folder ID\n * @returns Returns folder node, or null if not found\n */\nasync function findFolderByPathOrId(\n  pathOrId: string,\n): Promise<chrome.bookmarks.BookmarkTreeNode | null> {\n  try {\n    const nodes = await chrome.bookmarks.get(pathOrId);\n    if (nodes && nodes.length > 0 && !nodes[0].url) {\n      return nodes[0];\n    }\n  } catch (e) {\n    // do nothing, try to parse as path string\n  }\n\n  const pathParts = pathOrId\n    .split('/')\n    .map((p) => p.trim())\n    .filter((p) => p.length > 0);\n  if (pathParts.length === 0) return null;\n\n  const rootChildren = await chrome.bookmarks.getChildren('0');\n\n  let currentNodes = rootChildren;\n  let foundFolder: chrome.bookmarks.BookmarkTreeNode | null = null;\n\n  for (let i = 0; i < pathParts.length; i++) {\n    const part = pathParts[i];\n    foundFolder = null;\n    let matchedNodeThisLevel: chrome.bookmarks.BookmarkTreeNode | null = null;\n\n    for (const node of currentNodes) {\n      if (!node.url && node.title.toLowerCase() === part.toLowerCase()) {\n        matchedNodeThisLevel = node;\n        break;\n      }\n    }\n\n    if (matchedNodeThisLevel) {\n      if (i === pathParts.length - 1) {\n        foundFolder = matchedNodeThisLevel;\n      } else {\n        currentNodes = await chrome.bookmarks.getChildren(matchedNodeThisLevel.id);\n      }\n    } else {\n      return null;\n    }\n  }\n\n  return foundFolder;\n}\n\n/**\n * Create folder path (if it doesn't exist)\n * @param folderPath Folder path string (e.g., \"Work/Projects/Subproject\")\n * @param parentId Optional parent folder ID, defaults to \"Bookmarks Bar\"\n * @returns Returns the created or found final folder node\n */\nasync function createFolderPath(\n  folderPath: string,\n  parentId?: string,\n): Promise<chrome.bookmarks.BookmarkTreeNode> {\n  const pathParts = folderPath\n    .split('/')\n    .map((p) => p.trim())\n    .filter((p) => p.length > 0);\n\n  if (pathParts.length === 0) {\n    throw new Error('Folder path cannot be empty');\n  }\n\n  // If no parent ID specified, use \"Bookmarks Bar\" folder\n  let currentParentId: string = parentId || '';\n  if (!currentParentId) {\n    const rootChildren = await chrome.bookmarks.getChildren('0');\n    // Find \"Bookmarks Bar\" folder (usually ID is '1', but search by title for compatibility)\n    const bookmarkBarFolder = rootChildren.find(\n      (node) =>\n        !node.url &&\n        (node.title === getMessage('bookmarksBarLabel') ||\n          node.title === 'Bookmarks bar' ||\n          node.title === 'Bookmarks Bar'),\n    );\n    currentParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID\n  }\n\n  let currentFolder: chrome.bookmarks.BookmarkTreeNode | null = null;\n\n  // Create or find folders level by level\n  for (const folderName of pathParts) {\n    const children: chrome.bookmarks.BookmarkTreeNode[] =\n      await chrome.bookmarks.getChildren(currentParentId);\n\n    // Check if folder with same name already exists\n    const existingFolder: chrome.bookmarks.BookmarkTreeNode | undefined = children.find(\n      (child: chrome.bookmarks.BookmarkTreeNode) =>\n        !child.url && child.title.toLowerCase() === folderName.toLowerCase(),\n    );\n\n    if (existingFolder) {\n      currentFolder = existingFolder;\n      currentParentId = existingFolder.id;\n    } else {\n      // Create new folder\n      currentFolder = await chrome.bookmarks.create({\n        parentId: currentParentId,\n        title: folderName,\n      });\n      currentParentId = currentFolder.id;\n    }\n  }\n\n  if (!currentFolder) {\n    throw new Error('Failed to create folder path');\n  }\n\n  return currentFolder;\n}\n\n/**\n * Flatten bookmark tree (or node array) to bookmark list (excluding folders)\n * @param nodes Bookmark tree nodes to flatten\n * @returns Returns actual bookmark node array (nodes with URLs)\n */\nfunction flattenBookmarkNodesToBookmarks(\n  nodes: chrome.bookmarks.BookmarkTreeNode[],\n): chrome.bookmarks.BookmarkTreeNode[] {\n  const result: chrome.bookmarks.BookmarkTreeNode[] = [];\n  const stack = [...nodes]; // Use stack for iterative traversal to avoid deep recursion issues\n\n  while (stack.length > 0) {\n    const node = stack.pop();\n    if (!node) continue;\n\n    if (node.url) {\n      // It's a bookmark\n      result.push(node);\n    }\n\n    if (node.children) {\n      // Add child nodes to stack for processing\n      for (let i = node.children.length - 1; i >= 0; i--) {\n        stack.push(node.children[i]);\n      }\n    }\n  }\n\n  return result;\n}\n\n/**\n * Find bookmarks by URL and title\n * @param url Bookmark URL\n * @param title Optional bookmark title for auxiliary matching\n * @returns Returns array of matching bookmarks\n */\nasync function findBookmarksByUrl(\n  url: string,\n  title?: string,\n): Promise<chrome.bookmarks.BookmarkTreeNode[]> {\n  // Use Chrome API to search by URL\n  const searchResults = await chrome.bookmarks.search({ url });\n\n  if (!title) {\n    return searchResults;\n  }\n\n  // If title is provided, further filter results\n  const titleLower = title.toLowerCase();\n  return searchResults.filter(\n    (bookmark) => bookmark.title && bookmark.title.toLowerCase().includes(titleLower),\n  );\n}\n\n/**\n * Bookmark search tool\n * Used to search bookmarks in Chrome browser\n */\nclass BookmarkSearchTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.BOOKMARK_SEARCH;\n\n  /**\n   * Execute bookmark search\n   */\n  async execute(args: BookmarkSearchToolParams): Promise<ToolResult> {\n    const { query = '', maxResults = 50, folderPath } = args;\n\n    console.log(\n      `BookmarkSearchTool: Searching bookmarks, keywords: \"${query}\", folder path: \"${folderPath}\"`,\n    );\n\n    try {\n      let bookmarksToSearch: chrome.bookmarks.BookmarkTreeNode[] = [];\n      let targetFolderNode: chrome.bookmarks.BookmarkTreeNode | null = null;\n\n      // If folder path is specified, find that folder first\n      if (folderPath) {\n        targetFolderNode = await findFolderByPathOrId(folderPath);\n        if (!targetFolderNode) {\n          return createErrorResponse(`Specified folder not found: \"${folderPath}\"`);\n        }\n        // Get all bookmarks in that folder and its subfolders\n        const subTree = await chrome.bookmarks.getSubTree(targetFolderNode.id);\n        bookmarksToSearch =\n          subTree.length > 0 ? flattenBookmarkNodesToBookmarks(subTree[0].children || []) : [];\n      }\n\n      let filteredBookmarks: chrome.bookmarks.BookmarkTreeNode[];\n\n      if (query) {\n        if (targetFolderNode) {\n          // Has query keywords and specified folder: manually filter bookmarks from folder\n          const lowerCaseQuery = query.toLowerCase();\n          filteredBookmarks = bookmarksToSearch.filter(\n            (bookmark) =>\n              (bookmark.title && bookmark.title.toLowerCase().includes(lowerCaseQuery)) ||\n              (bookmark.url && bookmark.url.toLowerCase().includes(lowerCaseQuery)),\n          );\n        } else {\n          // Has query keywords but no specified folder: use API search\n          filteredBookmarks = await chrome.bookmarks.search({ query });\n          // API search may return folders (if title matches), filter them out\n          filteredBookmarks = filteredBookmarks.filter((item) => !!item.url);\n        }\n      } else {\n        // No query keywords\n        if (!targetFolderNode) {\n          // No folder path specified, get all bookmarks\n          const tree = await chrome.bookmarks.getTree();\n          bookmarksToSearch = flattenBookmarkNodesToBookmarks(tree);\n        }\n        filteredBookmarks = bookmarksToSearch;\n      }\n\n      // Limit number of results\n      const limitedResults = filteredBookmarks.slice(0, maxResults);\n\n      // Add folder path information for each bookmark\n      const resultsWithPath = await Promise.all(\n        limitedResults.map(async (bookmark) => {\n          const path = await getBookmarkFolderPath(bookmark.id);\n          return {\n            id: bookmark.id,\n            title: bookmark.title,\n            url: bookmark.url,\n            dateAdded: bookmark.dateAdded,\n            folderPath: path,\n          };\n        }),\n      );\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(\n              {\n                success: true,\n                totalResults: resultsWithPath.length,\n                query: query || null,\n                folderSearched: targetFolderNode\n                  ? targetFolderNode.title || targetFolderNode.id\n                  : 'All bookmarks',\n                bookmarks: resultsWithPath,\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error searching bookmarks:', error);\n      return createErrorResponse(\n        `Error searching bookmarks: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\n/**\n * Bookmark add tool\n * Used to add new bookmarks to Chrome browser\n */\nclass BookmarkAddTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.BOOKMARK_ADD;\n\n  /**\n   * Execute add bookmark operation\n   */\n  async execute(args: BookmarkAddToolParams): Promise<ToolResult> {\n    const { url, title, parentId, createFolder = false } = args;\n\n    console.log(`BookmarkAddTool: Adding bookmark, options:`, args);\n\n    try {\n      // If no URL provided, use current active tab\n      let bookmarkUrl = url;\n      let bookmarkTitle = title;\n\n      if (!bookmarkUrl) {\n        // Get current active tab\n        const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n        if (!tabs[0] || !tabs[0].url) {\n          // tab.url might be undefined (e.g., chrome:// pages)\n          return createErrorResponse('No active tab with valid URL found, and no URL provided');\n        }\n\n        bookmarkUrl = tabs[0].url;\n        if (!bookmarkTitle) {\n          bookmarkTitle = tabs[0].title || bookmarkUrl; // If tab title is empty, use URL as title\n        }\n      }\n\n      if (!bookmarkUrl) {\n        // Should have been caught above, but as a safety measure\n        return createErrorResponse('URL is required to create bookmark');\n      }\n\n      // Parse parentId (could be ID or path string)\n      let actualParentId: string | undefined = undefined;\n      if (parentId) {\n        let folderNode = await findFolderByPathOrId(parentId);\n\n        if (!folderNode && createFolder) {\n          // If folder doesn't exist and creation is allowed, create folder path\n          try {\n            folderNode = await createFolderPath(parentId);\n          } catch (createError) {\n            return createErrorResponse(\n              `Failed to create folder path: ${createError instanceof Error ? createError.message : String(createError)}`,\n            );\n          }\n        }\n\n        if (folderNode) {\n          actualParentId = folderNode.id;\n        } else {\n          // Check if parentId might be a direct ID missed by findFolderByPathOrId (e.g., root folder '1')\n          try {\n            const nodes = await chrome.bookmarks.get(parentId);\n            if (nodes && nodes.length > 0 && !nodes[0].url) {\n              actualParentId = nodes[0].id;\n            } else {\n              return createErrorResponse(\n                `Specified parent folder (ID/path: \"${parentId}\") not found or is not a folder${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,\n              );\n            }\n          } catch (e) {\n            return createErrorResponse(\n              `Specified parent folder (ID/path: \"${parentId}\") not found or invalid${createFolder ? ', and creation failed' : '. You can set createFolder=true to auto-create folders'}`,\n            );\n          }\n        }\n      } else {\n        // If no parentId specified, default to \"Bookmarks Bar\"\n        const rootChildren = await chrome.bookmarks.getChildren('0');\n        const bookmarkBarFolder = rootChildren.find(\n          (node) =>\n            !node.url &&\n            (node.title === getMessage('bookmarksBarLabel') ||\n              node.title === 'Bookmarks bar' ||\n              node.title === 'Bookmarks Bar'),\n        );\n        actualParentId = bookmarkBarFolder?.id || '1'; // fallback to default ID\n      }\n      // If actualParentId is still undefined, chrome.bookmarks.create will use default \"Other Bookmarks\", but we've set Bookmarks Bar\n\n      // Create bookmark\n      const newBookmark = await chrome.bookmarks.create({\n        parentId: actualParentId, // If undefined, API uses default value\n        title: bookmarkTitle || bookmarkUrl, // Ensure title is never empty\n        url: bookmarkUrl,\n      });\n\n      // Get bookmark path\n      const path = await getBookmarkFolderPath(newBookmark.id);\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(\n              {\n                success: true,\n                message: 'Bookmark added successfully',\n                bookmark: {\n                  id: newBookmark.id,\n                  title: newBookmark.title,\n                  url: newBookmark.url,\n                  dateAdded: newBookmark.dateAdded,\n                  folderPath: path,\n                },\n                folderCreated: createFolder && parentId ? 'Folder created if necessary' : false,\n              },\n              null,\n              2,\n            ),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error adding bookmark:', error);\n      const errorMessage = error instanceof Error ? error.message : String(error);\n\n      // Provide more specific error messages for common error cases, such as trying to bookmark chrome:// URLs\n      if (errorMessage.includes(\"Can't bookmark URLs of type\")) {\n        return createErrorResponse(\n          `Error adding bookmark: Cannot bookmark this type of URL (e.g., chrome:// system pages). ${errorMessage}`,\n        );\n      }\n\n      return createErrorResponse(`Error adding bookmark: ${errorMessage}`);\n    }\n  }\n}\n\n/**\n * Bookmark delete tool\n * Used to delete bookmarks in Chrome browser\n */\nclass BookmarkDeleteTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.BOOKMARK_DELETE;\n\n  /**\n   * Execute delete bookmark operation\n   */\n  async execute(args: BookmarkDeleteToolParams): Promise<ToolResult> {\n    const { bookmarkId, url, title } = args;\n\n    console.log(`BookmarkDeleteTool: Deleting bookmark, options:`, args);\n\n    if (!bookmarkId && !url) {\n      return createErrorResponse('Must provide bookmark ID or URL to delete bookmark');\n    }\n\n    try {\n      let bookmarksToDelete: chrome.bookmarks.BookmarkTreeNode[] = [];\n\n      if (bookmarkId) {\n        // Delete by ID\n        try {\n          const nodes = await chrome.bookmarks.get(bookmarkId);\n          if (nodes && nodes.length > 0 && nodes[0].url) {\n            bookmarksToDelete = nodes;\n          } else {\n            return createErrorResponse(\n              `Bookmark with ID \"${bookmarkId}\" not found, or the ID does not correspond to a bookmark`,\n            );\n          }\n        } catch (error) {\n          return createErrorResponse(`Invalid bookmark ID: \"${bookmarkId}\"`);\n        }\n      } else if (url) {\n        // Delete by URL\n        bookmarksToDelete = await findBookmarksByUrl(url, title);\n        if (bookmarksToDelete.length === 0) {\n          return createErrorResponse(\n            `No bookmark found with URL \"${url}\"${title ? ` (title contains: \"${title}\")` : ''}`,\n          );\n        }\n      }\n\n      // Delete found bookmarks\n      const deletedBookmarks = [];\n      const errors = [];\n\n      for (const bookmark of bookmarksToDelete) {\n        try {\n          // Get path information before deletion\n          const path = await getBookmarkFolderPath(bookmark.id);\n\n          await chrome.bookmarks.remove(bookmark.id);\n\n          deletedBookmarks.push({\n            id: bookmark.id,\n            title: bookmark.title,\n            url: bookmark.url,\n            folderPath: path,\n          });\n        } catch (error) {\n          const errorMsg = error instanceof Error ? error.message : String(error);\n          errors.push(\n            `Failed to delete bookmark \"${bookmark.title}\" (ID: ${bookmark.id}): ${errorMsg}`,\n          );\n        }\n      }\n\n      if (deletedBookmarks.length === 0) {\n        return createErrorResponse(`Failed to delete bookmarks: ${errors.join('; ')}`);\n      }\n\n      const result: any = {\n        success: true,\n        message: `Successfully deleted ${deletedBookmarks.length} bookmark(s)`,\n        deletedBookmarks,\n      };\n\n      if (errors.length > 0) {\n        result.partialSuccess = true;\n        result.errors = errors;\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result, null, 2),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error deleting bookmark:', error);\n      return createErrorResponse(\n        `Error deleting bookmark: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const bookmarkSearchTool = new BookmarkSearchTool();\nexport const bookmarkAddTool = new BookmarkAddTool();\nexport const bookmarkDeleteTool = new BookmarkDeleteTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/common.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { captureFrameOnAction, isAutoCaptureActive } from './gif-recorder';\n\n// Default window dimensions\nconst DEFAULT_WINDOW_WIDTH = 1280;\nconst DEFAULT_WINDOW_HEIGHT = 720;\n\ninterface NavigateToolParams {\n  url?: string;\n  newWindow?: boolean;\n  width?: number;\n  height?: number;\n  refresh?: boolean;\n  tabId?: number;\n  windowId?: number;\n  background?: boolean; // when true, do not activate tab or focus window\n}\n\n/**\n * Tool for navigating to URLs in browser tabs or windows\n */\nclass NavigateTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NAVIGATE;\n\n  /**\n   * Trigger GIF auto-capture after successful navigation\n   */\n  private async triggerAutoCapture(tabId: number, url?: string): Promise<void> {\n    if (!isAutoCaptureActive(tabId)) {\n      return;\n    }\n    try {\n      await captureFrameOnAction(tabId, { type: 'navigate', url });\n    } catch (error) {\n      console.warn('[NavigateTool] Auto-capture failed:', error);\n    }\n  }\n\n  async execute(args: NavigateToolParams): Promise<ToolResult> {\n    const {\n      newWindow = false,\n      width,\n      height,\n      url,\n      refresh = false,\n      tabId,\n      background,\n      windowId,\n    } = args;\n\n    console.log(\n      `Attempting to ${refresh ? 'refresh current tab' : `open URL: ${url}`} with options:`,\n      args,\n    );\n\n    try {\n      // Handle refresh option first\n      if (refresh) {\n        console.log('Refreshing current active tab');\n        const explicit = await this.tryGetTab(tabId);\n        // Get target tab (explicit or active in provided window)\n        const targetTab = explicit || (await this.getActiveTabOrThrowInWindow(windowId));\n        if (!targetTab.id) return createErrorResponse('No target tab found to refresh');\n        await chrome.tabs.reload(targetTab.id);\n\n        console.log(`Refreshed tab ID: ${targetTab.id}`);\n\n        // Get updated tab information\n        const updatedTab = await chrome.tabs.get(targetTab.id);\n\n        // Trigger auto-capture on refresh\n        await this.triggerAutoCapture(updatedTab.id!, updatedTab.url);\n\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                message: 'Successfully refreshed current tab',\n                tabId: updatedTab.id,\n                windowId: updatedTab.windowId,\n                url: updatedTab.url,\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // Validate that url is provided when not refreshing\n      if (!url) {\n        return createErrorResponse('URL parameter is required when refresh is not true');\n      }\n\n      // Handle history navigation: url=\"back\" or url=\"forward\"\n      if (url === 'back' || url === 'forward') {\n        const explicitTab = await this.tryGetTab(tabId);\n        const targetTab = explicitTab || (await this.getActiveTabOrThrowInWindow(windowId));\n        if (!targetTab.id) {\n          return createErrorResponse('No target tab found for history navigation');\n        }\n\n        // Respect background flag for focus behavior\n        await this.ensureFocus(targetTab, {\n          activate: background !== true,\n          focusWindow: background !== true,\n        });\n\n        if (url === 'forward') {\n          await chrome.tabs.goForward(targetTab.id);\n          console.log(`Navigated forward in tab ID: ${targetTab.id}`);\n        } else {\n          await chrome.tabs.goBack(targetTab.id);\n          console.log(`Navigated back in tab ID: ${targetTab.id}`);\n        }\n\n        const updatedTab = await chrome.tabs.get(targetTab.id);\n\n        // Trigger auto-capture on history navigation\n        await this.triggerAutoCapture(updatedTab.id!, updatedTab.url);\n\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                message: `Successfully navigated ${url} in browser history`,\n                tabId: updatedTab.id,\n                windowId: updatedTab.windowId,\n                url: updatedTab.url,\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // 1. Check if URL is already open\n      // Prefer Chrome's URL match patterns for robust matching (host/path variations)\n      console.log(`Checking if URL is already open: ${url}`);\n\n      // Build robust match patterns from the provided URL.\n      // This mirrors the approach in CloseTabsTool: ensure wildcard path and\n      // add common variants (www/no-www, http/https) to handle real-world redirects.\n      const buildUrlPatterns = (input: string): string[] => {\n        const patterns = new Set<string>();\n        try {\n          if (!input.includes('*')) {\n            const u = new URL(input);\n            // Use host-level wildcard to include all paths; we'll do precise selection later\n            const pathWildcard = '/*';\n\n            const hostNoWww = u.host.replace(/^www\\./, '');\n            const hostWithWww = hostNoWww.startsWith('www.') ? hostNoWww : `www.${hostNoWww}`;\n\n            // Keep original host\n            patterns.add(`${u.protocol}//${u.host}${pathWildcard}`);\n            // Add no-www variant\n            patterns.add(`${u.protocol}//${hostNoWww}${pathWildcard}`);\n            // Add www variant\n            patterns.add(`${u.protocol}//${hostWithWww}${pathWildcard}`);\n\n            // Add protocol variant to catch http↔https redirects\n            const altProtocol = u.protocol === 'https:' ? 'http:' : 'https:';\n            patterns.add(`${altProtocol}//${u.host}${pathWildcard}`);\n            patterns.add(`${altProtocol}//${hostNoWww}${pathWildcard}`);\n            patterns.add(`${altProtocol}//${hostWithWww}${pathWildcard}`);\n          } else {\n            patterns.add(input);\n          }\n        } catch {\n          // Fallback: best-effort wildcard suffix\n          patterns.add(input.endsWith('/') ? `${input}*` : `${input}/*`);\n        }\n        return Array.from(patterns);\n      };\n\n      const urlPatterns = buildUrlPatterns(url);\n      const candidateTabs = await chrome.tabs.query({ url: urlPatterns });\n      console.log(`Found ${candidateTabs.length} matching tabs with patterns:`, urlPatterns);\n\n      // Prefer strict match when user specifies a concrete path/query.\n      // Only fall back to host-level activation when the target is site root.\n      const pickBestMatch = (target: string, tabsToPick: chrome.tabs.Tab[]) => {\n        let targetUrl: URL | undefined;\n        try {\n          targetUrl = new URL(target);\n        } catch {\n          // Not a fully-qualified URL; cannot do structured comparison\n          return tabsToPick[0];\n        }\n\n        const normalizePath = (p: string) => {\n          if (!p) return '/';\n          // Ensure leading slash\n          const withLeading = p.startsWith('/') ? p : `/${p}`;\n          // Remove trailing slash except when root\n          return withLeading !== '/' && withLeading.endsWith('/')\n            ? withLeading.slice(0, -1)\n            : withLeading;\n        };\n\n        const hostBase = (h: string) => h.replace(/^www\\./, '').toLowerCase();\n        const isRootTarget = normalizePath(targetUrl.pathname) === '/' && !targetUrl.search;\n        const targetPath = normalizePath(targetUrl.pathname);\n        const targetSearch = targetUrl.search || '';\n        const targetHostBase = hostBase(targetUrl.host);\n\n        let best: { tab?: chrome.tabs.Tab; score: number } = { score: -1 };\n\n        for (const tab of tabsToPick) {\n          const tabUrlStr = tab.url || '';\n          let tabUrl: URL | undefined;\n          try {\n            tabUrl = new URL(tabUrlStr);\n          } catch {\n            continue;\n          }\n\n          const tabHostBase = hostBase(tabUrl.host);\n          if (tabHostBase !== targetHostBase) continue;\n\n          const tabPath = normalizePath(tabUrl.pathname);\n          const tabSearch = tabUrl.search || '';\n\n          // Scoring:\n          // 3 - exact path match and (if target has query) exact query match\n          // 2 - exact path match ignoring query (target without query)\n          // 1 - same host, any path (only if target is root)\n          let score = -1;\n          const pathEqual = tabPath === targetPath;\n          const searchEqual = tabSearch === targetSearch;\n\n          if (pathEqual && (targetSearch ? searchEqual : true)) {\n            score = 3;\n          } else if (pathEqual && !targetSearch) {\n            score = 2;\n          }\n\n          if (score > best.score) {\n            best = { tab, score };\n            if (score === 3) break; // Cannot do better\n          }\n        }\n\n        return best.tab;\n      };\n\n      const explicitTab = await this.tryGetTab(tabId);\n      const existingTab = explicitTab || pickBestMatch(url, candidateTabs);\n      if (existingTab?.id !== undefined) {\n        console.log(\n          `URL already open in Tab ID: ${existingTab.id}, Window ID: ${existingTab.windowId}`,\n        );\n        // Update URL only when explicit tab specified and url differs\n        if (explicitTab && typeof explicitTab.id === 'number') {\n          await chrome.tabs.update(explicitTab.id, { url });\n        }\n        // Optionally bring to foreground based on background flag\n        await this.ensureFocus(existingTab, {\n          activate: background !== true,\n          focusWindow: background !== true,\n        });\n\n        console.log(`Activated existing Tab ID: ${existingTab.id}`);\n        // Get updated tab information and return it\n        const updatedTab = await chrome.tabs.get(existingTab.id);\n\n        // Trigger auto-capture on existing tab activation\n        await this.triggerAutoCapture(updatedTab.id!, updatedTab.url);\n\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                message: 'Activated existing tab',\n                tabId: updatedTab.id,\n                windowId: updatedTab.windowId,\n                url: updatedTab.url,\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // 2. If URL is not already open, decide how to open it based on options\n      const openInNewWindow = newWindow || typeof width === 'number' || typeof height === 'number';\n\n      if (openInNewWindow) {\n        console.log('Opening URL in a new window.');\n\n        // Create new window\n        const newWindow = await chrome.windows.create({\n          url: url,\n          width: typeof width === 'number' ? width : DEFAULT_WINDOW_WIDTH,\n          height: typeof height === 'number' ? height : DEFAULT_WINDOW_HEIGHT,\n          focused: background === true ? false : true,\n        });\n\n        if (newWindow && newWindow.id !== undefined) {\n          console.log(`URL opened in new Window ID: ${newWindow.id}`);\n\n          // Trigger auto-capture if the new window has a tab\n          const firstTab = newWindow.tabs?.[0];\n          if (firstTab?.id) {\n            await this.triggerAutoCapture(firstTab.id, firstTab.url);\n          }\n\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  message: 'Opened URL in new window',\n                  windowId: newWindow.id,\n                  tabs: newWindow.tabs\n                    ? newWindow.tabs.map((tab) => ({\n                        tabId: tab.id,\n                        url: tab.url,\n                      }))\n                    : [],\n                }),\n              },\n            ],\n            isError: false,\n          };\n        }\n      } else {\n        console.log('Opening URL in the last active window.');\n        // Try to open a new tab in the specified window, otherwise the most recently active window\n        let targetWindow: chrome.windows.Window | null = null;\n        if (typeof windowId === 'number') {\n          targetWindow = await chrome.windows.get(windowId, { populate: false });\n        }\n        if (!targetWindow) {\n          targetWindow = await chrome.windows.getLastFocused({ populate: false });\n        }\n\n        if (targetWindow && targetWindow.id !== undefined) {\n          console.log(`Found target Window ID: ${targetWindow.id}`);\n\n          const newTab = await chrome.tabs.create({\n            url: url,\n            windowId: targetWindow.id,\n            active: background === true ? false : true,\n          });\n          if (background !== true) {\n            await chrome.windows.update(targetWindow.id, { focused: true });\n          }\n\n          console.log(\n            `URL opened in new Tab ID: ${newTab.id} in existing Window ID: ${targetWindow.id}`,\n          );\n\n          // Trigger auto-capture on new tab\n          if (newTab.id) {\n            await this.triggerAutoCapture(newTab.id, newTab.url);\n          }\n\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  message: 'Opened URL in new tab in existing window',\n                  tabId: newTab.id,\n                  windowId: targetWindow.id,\n                  url: newTab.url,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } else {\n          // In rare cases, if there's no recently active window (e.g., browser just started with no windows)\n          // Fall back to opening in a new window\n          console.warn('No last focused window found, falling back to creating a new window.');\n\n          const fallbackWindow = await chrome.windows.create({\n            url: url,\n            width: DEFAULT_WINDOW_WIDTH,\n            height: DEFAULT_WINDOW_HEIGHT,\n            focused: true,\n          });\n\n          if (fallbackWindow && fallbackWindow.id !== undefined) {\n            console.log(`URL opened in fallback new Window ID: ${fallbackWindow.id}`);\n\n            // Trigger auto-capture if fallback window has a tab\n            const firstTab = fallbackWindow.tabs?.[0];\n            if (firstTab?.id) {\n              await this.triggerAutoCapture(firstTab.id, firstTab.url);\n            }\n\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: JSON.stringify({\n                    success: true,\n                    message: 'Opened URL in new window',\n                    windowId: fallbackWindow.id,\n                    tabs: fallbackWindow.tabs\n                      ? fallbackWindow.tabs.map((tab) => ({\n                          tabId: tab.id,\n                          url: tab.url,\n                        }))\n                      : [],\n                  }),\n                },\n              ],\n              isError: false,\n            };\n          }\n        }\n      }\n\n      // If all attempts fail, return a generic error\n      return createErrorResponse('Failed to open URL: Unknown error occurred');\n    } catch (error) {\n      if (chrome.runtime.lastError) {\n        console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);\n        return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);\n      } else {\n        console.error('Error in navigate:', error);\n        return createErrorResponse(\n          `Error navigating to URL: ${error instanceof Error ? error.message : String(error)}`,\n        );\n      }\n    }\n  }\n}\nexport const navigateTool = new NavigateTool();\n\ninterface CloseTabsToolParams {\n  tabIds?: number[];\n  url?: string;\n}\n\n/**\n * Tool for closing browser tabs\n */\nclass CloseTabsTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.CLOSE_TABS;\n\n  async execute(args: CloseTabsToolParams): Promise<ToolResult> {\n    const { tabIds, url } = args;\n    let urlPattern = url;\n    console.log(`Attempting to close tabs with options:`, args);\n\n    try {\n      // If URL is provided, close all tabs matching that URL\n      if (urlPattern) {\n        console.log(`Searching for tabs with URL: ${url}`);\n        try {\n          // Build a proper Chrome match pattern from a concrete URL.\n          // If caller already provided a match pattern with '*', use as-is.\n          if (!urlPattern.includes('*')) {\n            // Ignore search/hash; match by origin + pathname prefix.\n            // Use URL to normalize; fallback to simple suffixing when parsing fails.\n            try {\n              const u = new URL(urlPattern);\n              const basePath = u.pathname || '/';\n              const pathWithWildcard = basePath.endsWith('/') ? `${basePath}*` : `${basePath}/*`;\n              urlPattern = `${u.protocol}//${u.host}${pathWithWildcard}`;\n            } catch {\n              // Not a fully-qualified URL; ensure it ends with wildcard\n              urlPattern = urlPattern.endsWith('/') ? `${urlPattern}*` : `${urlPattern}/*`;\n            }\n          }\n        } catch {\n          // Best-effort: ensure we have some wildcard\n          urlPattern = urlPattern.endsWith('*')\n            ? urlPattern\n            : urlPattern.endsWith('/')\n              ? `${urlPattern}*`\n              : `${urlPattern}/*`;\n        }\n\n        const tabs = await chrome.tabs.query({ url: urlPattern });\n\n        if (!tabs || tabs.length === 0) {\n          console.log(`No tabs found with URL pattern: ${urlPattern}`);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: false,\n                  message: `No tabs found with URL pattern: ${urlPattern}`,\n                  closedCount: 0,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        }\n\n        console.log(`Found ${tabs.length} tabs with URL pattern: ${urlPattern}`);\n        const tabIdsToClose = tabs\n          .map((tab) => tab.id)\n          .filter((id): id is number => id !== undefined);\n\n        if (tabIdsToClose.length === 0) {\n          return createErrorResponse('Found tabs but could not get their IDs');\n        }\n\n        await chrome.tabs.remove(tabIdsToClose);\n\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                message: `Closed ${tabIdsToClose.length} tabs with URL: ${url}`,\n                closedCount: tabIdsToClose.length,\n                closedTabIds: tabIdsToClose,\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // If tabIds are provided, close those tabs\n      if (tabIds && tabIds.length > 0) {\n        console.log(`Closing tabs with IDs: ${tabIds.join(', ')}`);\n\n        // Verify that all tabIds exist\n        const existingTabs = await Promise.all(\n          tabIds.map(async (tabId) => {\n            try {\n              return await chrome.tabs.get(tabId);\n            } catch (error) {\n              console.warn(`Tab with ID ${tabId} not found`);\n              return null;\n            }\n          }),\n        );\n\n        const validTabIds = existingTabs\n          .filter((tab): tab is chrome.tabs.Tab => tab !== null)\n          .map((tab) => tab.id)\n          .filter((id): id is number => id !== undefined);\n\n        if (validTabIds.length === 0) {\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: false,\n                  message: 'None of the provided tab IDs exist',\n                  closedCount: 0,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        }\n\n        await chrome.tabs.remove(validTabIds);\n\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                message: `Closed ${validTabIds.length} tabs`,\n                closedCount: validTabIds.length,\n                closedTabIds: validTabIds,\n                invalidTabIds: tabIds.filter((id) => !validTabIds.includes(id)),\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // If no tabIds or URL provided, close the current active tab\n      console.log('No tabIds or URL provided, closing active tab');\n      const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n\n      if (!activeTab || !activeTab.id) {\n        return createErrorResponse('No active tab found');\n      }\n\n      await chrome.tabs.remove(activeTab.id);\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: 'Closed active tab',\n              closedCount: 1,\n              closedTabIds: [activeTab.id],\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in CloseTabsTool.execute:', error);\n      return createErrorResponse(\n        `Error closing tabs: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const closeTabsTool = new CloseTabsTool();\n\ninterface SwitchTabToolParams {\n  tabId: number;\n  windowId?: number;\n}\n\n/**\n * Tool for switching the active tab\n */\nclass SwitchTabTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.SWITCH_TAB;\n\n  async execute(args: SwitchTabToolParams): Promise<ToolResult> {\n    const { tabId, windowId } = args;\n\n    console.log(`Attempting to switch to tab ID: ${tabId} in window ID: ${windowId}`);\n\n    try {\n      if (windowId !== undefined) {\n        await chrome.windows.update(windowId, { focused: true });\n      }\n      await chrome.tabs.update(tabId, { active: true });\n\n      const updatedTab = await chrome.tabs.get(tabId);\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: `Successfully switched to tab ID: ${tabId}`,\n              tabId: updatedTab.id,\n              windowId: updatedTab.windowId,\n              url: updatedTab.url,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      if (chrome.runtime.lastError) {\n        console.error(`Chrome API Error: ${chrome.runtime.lastError.message}`, error);\n        return createErrorResponse(`Chrome API Error: ${chrome.runtime.lastError.message}`);\n      } else {\n        console.error('Error in SwitchTabTool.execute:', error);\n        return createErrorResponse(\n          `Error switching tab: ${error instanceof Error ? error.message : String(error)}`,\n        );\n      }\n    }\n  }\n}\n\nexport const switchTabTool = new SwitchTabTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/computer.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ERROR_MESSAGES, TIMEOUTS } from '@/common/constants';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { clickTool, fillTool } from './interaction';\nimport { keyboardTool } from './keyboard';\nimport { screenshotTool } from './screenshot';\nimport { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport {\n  captureFrameOnAction,\n  isAutoCaptureActive,\n  type ActionMetadata,\n  type ActionType,\n} from './gif-recorder';\n\ntype MouseButton = 'left' | 'right' | 'middle';\n\ninterface Coordinates {\n  x: number;\n  y: number;\n}\n\ninterface ZoomRegion {\n  x0: number;\n  y0: number;\n  x1: number;\n  y1: number;\n}\n\ninterface Modifiers {\n  altKey?: boolean;\n  ctrlKey?: boolean;\n  metaKey?: boolean;\n  shiftKey?: boolean;\n}\n\ninterface ComputerParams {\n  action:\n    | 'left_click'\n    | 'right_click'\n    | 'double_click'\n    | 'triple_click'\n    | 'left_click_drag'\n    | 'scroll'\n    | 'type'\n    | 'key'\n    | 'hover'\n    | 'wait'\n    | 'fill'\n    | 'fill_form'\n    | 'resize_page'\n    | 'scroll_to'\n    | 'zoom'\n    | 'screenshot';\n  // click/scroll coordinates in screenshot space (if screenshot context exists) or viewport space\n  coordinates?: Coordinates; // for click/scroll; for drag, this is endCoordinates\n  startCoordinates?: Coordinates; // for drag start\n  // Optional element refs (from chrome_read_page) as alternative to coordinates\n  ref?: string; // click target or drag end\n  startRef?: string; // drag start\n  scrollDirection?: 'up' | 'down' | 'left' | 'right';\n  scrollAmount?: number;\n  text?: string; // for type/key\n  repeat?: number; // for key action (1-100)\n  modifiers?: Modifiers; // for click actions\n  region?: ZoomRegion; // for zoom action\n  duration?: number; // seconds for wait\n  // For fill\n  selector?: string;\n  selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')\n  value?: string;\n  frameId?: number; // Target frame for selector/ref resolution\n  tabId?: number; // target existing tab id\n  windowId?: number;\n  background?: boolean; // avoid focusing/activating\n}\n\n// Minimal CDP helper encapsulated here to avoid scattering CDP code\nclass CDPHelper {\n  static async attach(tabId: number): Promise<void> {\n    await cdpSessionManager.attach(tabId, 'computer');\n  }\n\n  static async detach(tabId: number): Promise<void> {\n    await cdpSessionManager.detach(tabId, 'computer');\n  }\n\n  static async send(tabId: number, method: string, params?: object): Promise<any> {\n    return await cdpSessionManager.sendCommand(tabId, method, params);\n  }\n\n  static async dispatchMouseEvent(tabId: number, opts: any) {\n    const params: any = {\n      type: opts.type,\n      x: Math.round(opts.x),\n      y: Math.round(opts.y),\n      modifiers: opts.modifiers || 0,\n    };\n    if (\n      opts.type === 'mousePressed' ||\n      opts.type === 'mouseReleased' ||\n      opts.type === 'mouseMoved'\n    ) {\n      params.button = opts.button || 'none';\n      if (opts.type === 'mousePressed' || opts.type === 'mouseReleased') {\n        params.clickCount = opts.clickCount || 1;\n      }\n      // Per CDP: buttons is ignored for mouseWheel\n      params.buttons = opts.buttons !== undefined ? opts.buttons : 0;\n    }\n    if (opts.type === 'mouseWheel') {\n      params.deltaX = opts.deltaX || 0;\n      params.deltaY = opts.deltaY || 0;\n    }\n    await this.send(tabId, 'Input.dispatchMouseEvent', params);\n  }\n\n  static async insertText(tabId: number, text: string) {\n    await this.send(tabId, 'Input.insertText', { text });\n  }\n\n  static modifierMask(mods: string[]): number {\n    const map: Record<string, number> = {\n      alt: 1,\n      ctrl: 2,\n      control: 2,\n      meta: 4,\n      cmd: 4,\n      command: 4,\n      win: 4,\n      windows: 4,\n      shift: 8,\n    };\n    let mask = 0;\n    for (const m of mods) mask |= map[m] || 0;\n    return mask;\n  }\n\n  // Enhanced key mapping for common non-character keys\n  private static KEY_ALIASES: Record<string, { key: string; code?: string; text?: string }> = {\n    enter: { key: 'Enter', code: 'Enter' },\n    return: { key: 'Enter', code: 'Enter' },\n    backspace: { key: 'Backspace', code: 'Backspace' },\n    delete: { key: 'Delete', code: 'Delete' },\n    tab: { key: 'Tab', code: 'Tab' },\n    escape: { key: 'Escape', code: 'Escape' },\n    esc: { key: 'Escape', code: 'Escape' },\n    space: { key: ' ', code: 'Space', text: ' ' },\n    pageup: { key: 'PageUp', code: 'PageUp' },\n    pagedown: { key: 'PageDown', code: 'PageDown' },\n    home: { key: 'Home', code: 'Home' },\n    end: { key: 'End', code: 'End' },\n    arrowup: { key: 'ArrowUp', code: 'ArrowUp' },\n    arrowdown: { key: 'ArrowDown', code: 'ArrowDown' },\n    arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft' },\n    arrowright: { key: 'ArrowRight', code: 'ArrowRight' },\n  };\n\n  private static resolveKeyDef(token: string): { key: string; code?: string; text?: string } {\n    const t = (token || '').toLowerCase();\n    if (this.KEY_ALIASES[t]) return this.KEY_ALIASES[t];\n    if (/^f([1-9]|1[0-2])$/.test(t)) {\n      return { key: t.toUpperCase(), code: t.toUpperCase() };\n    }\n    if (t.length === 1) {\n      const upper = t.toUpperCase();\n      return { key: upper, code: `Key${upper}`, text: t };\n    }\n    return { key: token };\n  }\n\n  static async dispatchSimpleKey(tabId: number, token: string) {\n    const def = this.resolveKeyDef(token);\n    if (def.text && def.text.length === 1) {\n      await this.insertText(tabId, def.text);\n      return;\n    }\n    await this.send(tabId, 'Input.dispatchKeyEvent', {\n      type: 'rawKeyDown',\n      key: def.key,\n      code: def.code,\n    });\n    await this.send(tabId, 'Input.dispatchKeyEvent', {\n      type: 'keyUp',\n      key: def.key,\n      code: def.code,\n    });\n  }\n\n  static async dispatchKeyChord(tabId: number, chord: string) {\n    const parts = chord.split('+');\n    const modifiers: string[] = [];\n    let keyToken = '';\n    for (const pRaw of parts) {\n      const p = pRaw.trim().toLowerCase();\n      if (\n        ['ctrl', 'control', 'alt', 'shift', 'cmd', 'meta', 'command', 'win', 'windows'].includes(p)\n      )\n        modifiers.push(p);\n      else keyToken = pRaw.trim();\n    }\n    const mask = this.modifierMask(modifiers);\n    const def = this.resolveKeyDef(keyToken);\n    await this.send(tabId, 'Input.dispatchKeyEvent', {\n      type: 'rawKeyDown',\n      key: def.key,\n      code: def.code,\n      text: def.text,\n      modifiers: mask,\n    });\n    await this.send(tabId, 'Input.dispatchKeyEvent', {\n      type: 'keyUp',\n      key: def.key,\n      code: def.code,\n      modifiers: mask,\n    });\n  }\n}\n\nclass ComputerTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.COMPUTER;\n\n  async execute(args: ComputerParams): Promise<ToolResult> {\n    const params = args || ({} as ComputerParams);\n    if (!params.action) return createErrorResponse('Action parameter is required');\n\n    try {\n      const explicit = await this.tryGetTab(args.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n      if (!tab.id)\n        return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n\n      // Execute the action and capture frame on success\n      const result = await this.executeAction(params, tab);\n\n      // Trigger auto-capture on successful actions (except screenshot which is read-only)\n      if (!result.isError && params.action !== 'screenshot' && params.action !== 'wait') {\n        const actionType = this.mapActionToCapture(params.action);\n        if (actionType) {\n          // Convert to viewport-space coordinates for GIF overlays\n          // params.coordinates may be screenshot-space when screenshot context exists\n          const ctx = screenshotContextManager.getContext(tab.id);\n          const toViewport = (c?: Coordinates): { x: number; y: number } | undefined => {\n            if (!c) return undefined;\n            if (!ctx) return { x: c.x, y: c.y };\n            const scaled = scaleCoordinates(c.x, c.y, ctx);\n            return { x: scaled.x, y: scaled.y };\n          };\n\n          const endCoords = toViewport(params.coordinates);\n          const startCoords = toViewport(params.startCoordinates);\n\n          await this.triggerAutoCapture(tab.id, actionType, {\n            coordinateSpace: 'viewport',\n            coordinates: endCoords,\n            startCoordinates: startCoords,\n            endCoordinates: actionType === 'drag' ? endCoords : undefined,\n            text: params.text,\n            ref: params.ref,\n          });\n        }\n      }\n\n      return result;\n    } catch (error) {\n      console.error('Error in computer tool:', error);\n      return createErrorResponse(\n        `Failed to execute action: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  private mapActionToCapture(action: string): ActionType | null {\n    const mapping: Record<string, ActionType> = {\n      left_click: 'click',\n      right_click: 'right_click',\n      double_click: 'double_click',\n      triple_click: 'triple_click',\n      left_click_drag: 'drag',\n      scroll: 'scroll',\n      type: 'type',\n      key: 'key',\n      hover: 'hover',\n      fill: 'fill',\n      fill_form: 'fill',\n      resize_page: 'other',\n      scroll_to: 'scroll',\n      zoom: 'other',\n    };\n    return mapping[action] || null;\n  }\n\n  private async executeAction(params: ComputerParams, tab: chrome.tabs.Tab): Promise<ToolResult> {\n    if (!tab.id) {\n      return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n    }\n\n    // Helper to project coordinates using screenshot context when available\n    const project = (c?: Coordinates): Coordinates | undefined => {\n      if (!c) return undefined;\n      const ctx = screenshotContextManager.getContext(tab.id!);\n      if (!ctx) return c;\n      const scaled = scaleCoordinates(c.x, c.y, ctx);\n      return { x: scaled.x, y: scaled.y };\n    };\n\n    switch (params.action) {\n      case 'resize_page': {\n        const width = Number((params as any).coordinates?.x || (params as any).text);\n        const height = Number((params as any).coordinates?.y || (params as any).value);\n        const w = Number((params as any).width ?? width);\n        const h = Number((params as any).height ?? height);\n        if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {\n          return createErrorResponse('Provide width and height for resize_page (positive numbers)');\n        }\n        try {\n          // Prefer precise CDP emulation\n          await CDPHelper.attach(tab.id);\n          try {\n            await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', {\n              width: Math.round(w),\n              height: Math.round(h),\n              deviceScaleFactor: 0,\n              mobile: false,\n              screenWidth: Math.round(w),\n              screenHeight: Math.round(h),\n            });\n          } finally {\n            await CDPHelper.detach(tab.id);\n          }\n        } catch (e) {\n          // Fallback: window resize\n          if (tab.windowId !== undefined) {\n            await chrome.windows.update(tab.windowId, {\n              width: Math.round(w),\n              height: Math.round(h),\n            });\n          } else {\n            return createErrorResponse(\n              `Failed to resize via CDP and cannot determine windowId: ${e instanceof Error ? e.message : String(e)}`,\n            );\n          }\n        }\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({ success: true, action: 'resize_page', width: w, height: h }),\n            },\n          ],\n          isError: false,\n        };\n      }\n      case 'hover': {\n        // Resolve target point from ref | selector | coordinates\n        let coord: Coordinates | undefined = undefined;\n        let resolvedBy: 'ref' | 'selector' | 'coordinates' | undefined;\n\n        try {\n          if (params.ref) {\n            await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n            // Scroll element into view first to ensure it's visible\n            try {\n              await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref });\n            } catch {\n              // Best effort - continue even if scroll fails\n            }\n            // Re-resolve coordinates after scroll\n            const resolved = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n              ref: params.ref,\n            });\n            if (resolved && resolved.success) {\n              coord = project({ x: resolved.center.x, y: resolved.center.y });\n              resolvedBy = 'ref';\n            }\n          } else if (params.selector) {\n            await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n            const selectorType = params.selectorType || 'css';\n            const ensured = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n              selector: params.selector,\n              isXPath: selectorType === 'xpath',\n            });\n            if (ensured && ensured.success) {\n              // Scroll element into view first to ensure it's visible\n              const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined;\n              if (resolvedRef) {\n                try {\n                  await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef });\n                } catch {\n                  // Best effort - continue even if scroll fails\n                }\n                // Re-resolve coordinates after scroll\n                const reResolved = await this.sendMessageToTab(tab.id, {\n                  action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n                  ref: resolvedRef,\n                });\n                if (reResolved && reResolved.success) {\n                  coord = project({ x: reResolved.center.x, y: reResolved.center.y });\n                } else {\n                  coord = project({ x: ensured.center.x, y: ensured.center.y });\n                }\n              } else {\n                coord = project({ x: ensured.center.x, y: ensured.center.y });\n              }\n              resolvedBy = 'selector';\n            }\n          } else if (params.coordinates) {\n            coord = project(params.coordinates);\n            resolvedBy = 'coordinates';\n          }\n        } catch (e) {\n          // fall through to error handling below\n        }\n\n        if (!coord)\n          return createErrorResponse(\n            'Provide ref or selector or coordinates for hover, or failed to resolve target',\n          );\n        {\n          const stale = ((): any => {\n            if (!params.coordinates) return null;\n            const getHostname = (url: string): string => {\n              try {\n                return new URL(url).hostname;\n              } catch {\n                return '';\n              }\n            };\n            const currentHostname = getHostname(tab.url || '');\n            const ctx = screenshotContextManager.getContext(tab.id!);\n            const contextHostname = (ctx as any)?.hostname as string | undefined;\n            if (contextHostname && contextHostname !== currentHostname) {\n              return createErrorResponse(\n                `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during hover. Capture a new screenshot or use ref/selector.`,\n              );\n            }\n            return null;\n          })();\n          if (stale) return stale;\n        }\n\n        try {\n          await CDPHelper.attach(tab.id);\n          try {\n            // Move pointer to target. We can dispatch a single mouseMoved; browsers will generate mouseover/mouseenter as needed.\n            await CDPHelper.dispatchMouseEvent(tab.id, {\n              type: 'mouseMoved',\n              x: coord.x,\n              y: coord.y,\n              button: 'none',\n              buttons: 0,\n            });\n          } finally {\n            await CDPHelper.detach(tab.id);\n          }\n\n          // Optional hold to allow UI (menus/tooltips) to appear\n          const holdMs = Math.max(\n            0,\n            Math.min(params.duration ? params.duration * 1000 : 400, 5000),\n          );\n          if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));\n\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'hover',\n                  coordinates: coord,\n                  resolvedBy,\n                  transport: 'cdp',\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (error) {\n          console.warn('[ComputerTool] CDP hover failed, attempting DOM fallback', error);\n          return await this.domHoverFallback(tab.id, coord, resolvedBy, params.ref);\n        }\n      }\n      case 'left_click':\n      case 'right_click': {\n        // Calculate CDP modifier mask for click events\n        const modifiersMask = CDPHelper.modifierMask(\n          [\n            params.modifiers?.altKey ? 'alt' : undefined,\n            params.modifiers?.ctrlKey ? 'ctrl' : undefined,\n            params.modifiers?.metaKey ? 'meta' : undefined,\n            params.modifiers?.shiftKey ? 'shift' : undefined,\n          ].filter((v): v is string => typeof v === 'string'),\n        );\n\n        if (params.ref) {\n          // Prefer DOM click via ref\n          const domResult = await clickTool.execute({\n            ref: params.ref,\n            waitForNavigation: false,\n            timeout: TIMEOUTS.DEFAULT_WAIT * 5,\n            button: params.action === 'right_click' ? 'right' : 'left',\n            modifiers: params.modifiers,\n          });\n          return domResult;\n        }\n        if (params.selector) {\n          // Support selector-based click\n          const domResult = await clickTool.execute({\n            selector: params.selector,\n            selectorType: params.selectorType,\n            frameId: params.frameId,\n            waitForNavigation: false,\n            timeout: TIMEOUTS.DEFAULT_WAIT * 5,\n            button: params.action === 'right_click' ? 'right' : 'left',\n            modifiers: params.modifiers,\n          });\n          return domResult;\n        }\n        if (!params.coordinates)\n          return createErrorResponse('Provide ref, selector, or coordinates for click action');\n        {\n          const stale = ((): any => {\n            const getHostname = (url: string): string => {\n              try {\n                return new URL(url).hostname;\n              } catch {\n                return '';\n              }\n            };\n            const currentHostname = getHostname(tab.url || '');\n            const ctx = screenshotContextManager.getContext(tab.id!);\n            const contextHostname = (ctx as any)?.hostname as string | undefined;\n            if (contextHostname && contextHostname !== currentHostname) {\n              return createErrorResponse(\n                `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,\n              );\n            }\n            return null;\n          })();\n          if (stale) return stale;\n        }\n        const coord = project(params.coordinates)!;\n        // Prefer DOM path via existing click tool\n        const domResult = await clickTool.execute({\n          coordinates: coord,\n          waitForNavigation: false,\n          timeout: TIMEOUTS.DEFAULT_WAIT * 5,\n          button: params.action === 'right_click' ? 'right' : 'left',\n          modifiers: params.modifiers,\n        });\n        if (!domResult.isError) {\n          return domResult; // Standardized response from click tool\n        }\n        // Fallback to CDP if DOM failed\n        try {\n          await CDPHelper.attach(tab.id);\n          const button: MouseButton = params.action === 'right_click' ? 'right' : 'left';\n          const clickCount = 1;\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseMoved',\n            x: coord.x,\n            y: coord.y,\n            button: 'none',\n            buttons: 0,\n            modifiers: modifiersMask,\n          });\n          for (let i = 1; i <= clickCount; i++) {\n            await CDPHelper.dispatchMouseEvent(tab.id, {\n              type: 'mousePressed',\n              x: coord.x,\n              y: coord.y,\n              button,\n              buttons: button === 'left' ? 1 : 2,\n              clickCount: i,\n              modifiers: modifiersMask,\n            });\n            await CDPHelper.dispatchMouseEvent(tab.id, {\n              type: 'mouseReleased',\n              x: coord.x,\n              y: coord.y,\n              button,\n              buttons: 0,\n              clickCount: i,\n              modifiers: modifiersMask,\n            });\n          }\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: params.action,\n                  coordinates: coord,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          return createErrorResponse(\n            `CDP click failed: ${e instanceof Error ? e.message : String(e)}`,\n          );\n        }\n      }\n      case 'double_click':\n      case 'triple_click': {\n        // Calculate CDP modifier mask for click events\n        const modifiersMask = CDPHelper.modifierMask(\n          [\n            params.modifiers?.altKey ? 'alt' : undefined,\n            params.modifiers?.ctrlKey ? 'ctrl' : undefined,\n            params.modifiers?.metaKey ? 'meta' : undefined,\n            params.modifiers?.shiftKey ? 'shift' : undefined,\n          ].filter((v): v is string => typeof v === 'string'),\n        );\n\n        if (!params.coordinates && !params.ref && !params.selector)\n          return createErrorResponse(\n            'Provide ref, selector, or coordinates for double/triple click',\n          );\n        let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);\n        // If ref is provided, resolve center via accessibility helper\n        if (params.ref) {\n          try {\n            await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n            const resolved = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n              ref: params.ref,\n            });\n            if (resolved && resolved.success) {\n              coord = project({ x: resolved.center.x, y: resolved.center.y })!;\n            }\n          } catch (e) {\n            // ignore and use provided coordinates\n          }\n        } else if (params.selector) {\n          // Support selector-based click\n          try {\n            await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n            const selectorType = params.selectorType || 'css';\n            const ensured = await this.sendMessageToTab(\n              tab.id,\n              {\n                action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n                selector: params.selector,\n                isXPath: selectorType === 'xpath',\n              },\n              params.frameId,\n            );\n            if (ensured && ensured.success) {\n              coord = project({ x: ensured.center.x, y: ensured.center.y })!;\n            }\n          } catch (e) {\n            // ignore\n          }\n        }\n        if (!coord) return createErrorResponse('Failed to resolve coordinates from ref/selector');\n        {\n          const stale = ((): any => {\n            if (!params.coordinates) return null;\n            const getHostname = (url: string): string => {\n              try {\n                return new URL(url).hostname;\n              } catch {\n                return '';\n              }\n            };\n            const currentHostname = getHostname(tab.url || '');\n            const ctx = screenshotContextManager.getContext(tab.id!);\n            const contextHostname = (ctx as any)?.hostname as string | undefined;\n            if (contextHostname && contextHostname !== currentHostname) {\n              return createErrorResponse(\n                `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,\n              );\n            }\n            return null;\n          })();\n          if (stale) return stale;\n        }\n        try {\n          await CDPHelper.attach(tab.id);\n          const button: MouseButton = 'left';\n          const clickCount = params.action === 'double_click' ? 2 : 3;\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseMoved',\n            x: coord.x,\n            y: coord.y,\n            button: 'none',\n            buttons: 0,\n            modifiers: modifiersMask,\n          });\n          for (let i = 1; i <= clickCount; i++) {\n            await CDPHelper.dispatchMouseEvent(tab.id, {\n              type: 'mousePressed',\n              x: coord.x,\n              y: coord.y,\n              button,\n              buttons: 1,\n              clickCount: i,\n              modifiers: modifiersMask,\n            });\n            await CDPHelper.dispatchMouseEvent(tab.id, {\n              type: 'mouseReleased',\n              x: coord.x,\n              y: coord.y,\n              button,\n              buttons: 0,\n              clickCount: i,\n              modifiers: modifiersMask,\n            });\n          }\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: params.action,\n                  coordinates: coord,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          return createErrorResponse(\n            `CDP ${params.action} failed: ${e instanceof Error ? e.message : String(e)}`,\n          );\n        }\n      }\n      case 'left_click_drag': {\n        if (!params.startCoordinates && !params.startRef)\n          return createErrorResponse('Provide startRef or startCoordinates for drag');\n        if (!params.coordinates && !params.ref)\n          return createErrorResponse('Provide ref or end coordinates for drag');\n        let start = params.startCoordinates\n          ? project(params.startCoordinates)!\n          : (undefined as any);\n        let end = params.coordinates ? project(params.coordinates)! : (undefined as any);\n        {\n          const stale = ((): any => {\n            if (!params.startCoordinates && !params.coordinates) return null;\n            const getHostname = (url: string): string => {\n              try {\n                return new URL(url).hostname;\n              } catch {\n                return '';\n              }\n            };\n            const currentHostname = getHostname(tab.url || '');\n            const ctx = screenshotContextManager.getContext(tab.id!);\n            const contextHostname = (ctx as any)?.hostname as string | undefined;\n            if (contextHostname && contextHostname !== currentHostname) {\n              return createErrorResponse(\n                `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during left_click_drag. Capture a new screenshot or use ref/selector.`,\n              );\n            }\n            return null;\n          })();\n          if (stale) return stale;\n        }\n        if (params.startRef || params.ref) {\n          await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n        }\n        if (params.startRef) {\n          try {\n            const resolved = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n              ref: params.startRef,\n            });\n            if (resolved && resolved.success)\n              start = project({ x: resolved.center.x, y: resolved.center.y })!;\n          } catch {\n            // ignore\n          }\n        }\n        if (params.ref) {\n          try {\n            const resolved = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n              ref: params.ref,\n            });\n            if (resolved && resolved.success)\n              end = project({ x: resolved.center.x, y: resolved.center.y })!;\n          } catch {\n            // ignore\n          }\n        }\n        if (!start || !end) return createErrorResponse('Failed to resolve drag coordinates');\n        try {\n          await CDPHelper.attach(tab.id);\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseMoved',\n            x: start.x,\n            y: start.y,\n            button: 'none',\n            buttons: 0,\n          });\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mousePressed',\n            x: start.x,\n            y: start.y,\n            button: 'left',\n            buttons: 1,\n            clickCount: 1,\n          });\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseMoved',\n            x: end.x,\n            y: end.y,\n            button: 'left',\n            buttons: 1,\n          });\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseReleased',\n            x: end.x,\n            y: end.y,\n            button: 'left',\n            buttons: 0,\n            clickCount: 1,\n          });\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({ success: true, action: 'left_click_drag', start, end }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          return createErrorResponse(`Drag failed: ${e instanceof Error ? e.message : String(e)}`);\n        }\n      }\n      case 'scroll': {\n        if (!params.coordinates && !params.ref)\n          return createErrorResponse('Provide ref or coordinates for scroll');\n        let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);\n        if (params.ref) {\n          try {\n            await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n            const resolved = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n              ref: params.ref,\n            });\n            if (resolved && resolved.success)\n              coord = project({ x: resolved.center.x, y: resolved.center.y })!;\n          } catch {\n            // ignore\n          }\n        }\n        if (!coord) return createErrorResponse('Failed to resolve scroll coordinates');\n        {\n          const stale = ((): any => {\n            if (!params.coordinates) return null;\n            const getHostname = (url: string): string => {\n              try {\n                return new URL(url).hostname;\n              } catch {\n                return '';\n              }\n            };\n            const currentHostname = getHostname(tab.url || '');\n            const ctx = screenshotContextManager.getContext(tab.id!);\n            const contextHostname = (ctx as any)?.hostname as string | undefined;\n            if (contextHostname && contextHostname !== currentHostname) {\n              return createErrorResponse(\n                `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during scroll. Capture a new screenshot or use ref/selector.`,\n              );\n            }\n            return null;\n          })();\n          if (stale) return stale;\n        }\n        const direction = params.scrollDirection || 'down';\n        const amount = Math.max(1, Math.min(params.scrollAmount || 3, 10));\n        // Convert to deltas (~100px per tick)\n        const unit = 100;\n        let deltaX = 0,\n          deltaY = 0;\n        if (direction === 'up') deltaY = -amount * unit;\n        if (direction === 'down') deltaY = amount * unit;\n        if (direction === 'left') deltaX = -amount * unit;\n        if (direction === 'right') deltaX = amount * unit;\n        try {\n          await CDPHelper.attach(tab.id);\n          await CDPHelper.dispatchMouseEvent(tab.id, {\n            type: 'mouseWheel',\n            x: coord.x,\n            y: coord.y,\n            deltaX,\n            deltaY,\n          });\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'scroll',\n                  coordinates: coord,\n                  deltaX,\n                  deltaY,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          return createErrorResponse(\n            `Scroll failed: ${e instanceof Error ? e.message : String(e)}`,\n          );\n        }\n      }\n      case 'type': {\n        if (!params.text) return createErrorResponse('Text parameter is required for type action');\n        try {\n          // Optional focus via ref before typing\n          if (params.ref) {\n            await clickTool.execute({\n              ref: params.ref,\n              waitForNavigation: false,\n              timeout: TIMEOUTS.DEFAULT_WAIT * 5,\n            });\n          }\n          await CDPHelper.attach(tab.id);\n          // Use CDP insertText to avoid complex KeyboardEvent emulation for long text\n          await CDPHelper.insertText(tab.id, params.text);\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'type',\n                  length: params.text.length,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          // Fallback to DOM-based keyboard tool\n          const res = await keyboardTool.execute({\n            keys: params.text.split('').join(','),\n            delay: 0,\n            selector: undefined,\n          });\n          return res;\n        }\n      }\n      case 'fill': {\n        if (!params.ref && !params.selector) {\n          return createErrorResponse('Provide ref or selector and a value for fill');\n        }\n        // Reuse existing fill tool to leverage robust DOM event behavior\n        const res = await fillTool.execute({\n          selector: params.selector as any,\n          selectorType: params.selectorType as any,\n          ref: params.ref as any,\n          value: params.value as any,\n        } as any);\n        return res;\n      }\n      case 'fill_form': {\n        const elements = (params as any).elements as Array<{\n          ref: string;\n          value: string | number | boolean;\n        }>;\n        if (!Array.isArray(elements) || elements.length === 0) {\n          return createErrorResponse('elements must be a non-empty array for fill_form');\n        }\n        const results: Array<{ ref: string; ok: boolean; error?: string }> = [];\n        for (const item of elements) {\n          if (!item || !item.ref) {\n            results.push({ ref: String(item?.ref || ''), ok: false, error: 'missing ref' });\n            continue;\n          }\n          try {\n            const r = await fillTool.execute({\n              ref: item.ref as any,\n              value: item.value as any,\n            } as any);\n            const ok = !r.isError;\n            results.push({ ref: item.ref, ok, error: ok ? undefined : 'failed' });\n          } catch (e) {\n            results.push({\n              ref: item.ref,\n              ok: false,\n              error: String(e instanceof Error ? e.message : e),\n            });\n          }\n        }\n        const successCount = results.filter((r) => r.ok).length;\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({\n                success: true,\n                action: 'fill_form',\n                filled: successCount,\n                total: results.length,\n                results,\n              }),\n            },\n          ],\n          isError: false,\n        };\n      }\n      case 'key': {\n        if (!params.text)\n          return createErrorResponse(\n            'text is required for key action (e.g., \"Backspace Backspace Enter\" or \"cmd+a\")',\n          );\n        const tokens = params.text.trim().split(/\\s+/).filter(Boolean);\n        const repeat = params.repeat ?? 1;\n        if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100) {\n          return createErrorResponse('repeat must be an integer between 1 and 100 for key action');\n        }\n        try {\n          // Optional focus via ref before key events\n          if (params.ref) {\n            await clickTool.execute({\n              ref: params.ref,\n              waitForNavigation: false,\n              timeout: TIMEOUTS.DEFAULT_WAIT * 5,\n            });\n          }\n          await CDPHelper.attach(tab.id);\n          for (let i = 0; i < repeat; i++) {\n            for (const t of tokens) {\n              if (t.includes('+')) await CDPHelper.dispatchKeyChord(tab.id, t);\n              else await CDPHelper.dispatchSimpleKey(tab.id, t);\n            }\n          }\n          await CDPHelper.detach(tab.id);\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({ success: true, action: 'key', keys: tokens, repeat }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          // Fallback to DOM keyboard simulation (comma-separated combinations)\n          const keysStr = tokens.join(',');\n          const repeatedKeys =\n            repeat === 1 ? keysStr : Array.from({ length: repeat }, () => keysStr).join(',');\n          const res = await keyboardTool.execute({ keys: repeatedKeys });\n          return res;\n        }\n      }\n      case 'wait': {\n        const hasTextCondition =\n          typeof (params as any).text === 'string' && (params as any).text.trim().length > 0;\n        if (hasTextCondition) {\n          try {\n            // Conditional wait for text appearance/disappearance using content script\n            await this.injectContentScript(\n              tab.id,\n              ['inject-scripts/wait-helper.js'],\n              false,\n              'ISOLATED',\n              true,\n            );\n            const appear = (params as any).appear !== false; // default to true\n            const timeoutMs = Math.max(\n              0,\n              Math.min(((params as any).timeout as number) || 10000, 120000),\n            );\n            const resp = await this.sendMessageToTab(tab.id, {\n              action: TOOL_MESSAGE_TYPES.WAIT_FOR_TEXT,\n              text: (params as any).text,\n              appear,\n              timeout: timeoutMs,\n            });\n            if (!resp || resp.success !== true) {\n              return createErrorResponse(\n                resp && resp.reason === 'timeout'\n                  ? `wait_for timed out after ${timeoutMs}ms for text: ${(params as any).text}`\n                  : `wait_for failed: ${resp && resp.error ? resp.error : 'unknown error'}`,\n              );\n            }\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: JSON.stringify({\n                    success: true,\n                    action: 'wait_for',\n                    appear,\n                    text: (params as any).text,\n                    matched: resp.matched || null,\n                    tookMs: resp.tookMs,\n                  }),\n                },\n              ],\n              isError: false,\n            };\n          } catch (e) {\n            return createErrorResponse(\n              `wait_for failed: ${e instanceof Error ? e.message : String(e)}`,\n            );\n          }\n        } else {\n          const seconds = Math.max(0, Math.min((params as any).duration || 0, 30));\n          if (!seconds)\n            return createErrorResponse('Duration parameter is required and must be > 0');\n          await new Promise((r) => setTimeout(r, seconds * 1000));\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({ success: true, action: 'wait', duration: seconds }),\n              },\n            ],\n            isError: false,\n          };\n        }\n      }\n      case 'scroll_to': {\n        if (!params.ref) {\n          return createErrorResponse('ref is required for scroll_to action');\n        }\n        try {\n          await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n          const resp = await this.sendMessageToTab(tab.id, {\n            action: 'focusByRef',\n            ref: params.ref,\n          });\n          if (!resp || resp.success !== true) {\n            return createErrorResponse(resp?.error || 'scroll_to failed: element not found');\n          }\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'scroll_to',\n                  ref: params.ref,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          return createErrorResponse(\n            `scroll_to failed: ${e instanceof Error ? e.message : String(e)}`,\n          );\n        }\n      }\n      case 'zoom': {\n        const region = params.region;\n        if (!region) {\n          return createErrorResponse('region is required for zoom action');\n        }\n        const x0 = Number(region.x0);\n        const y0 = Number(region.y0);\n        const x1 = Number(region.x1);\n        const y1 = Number(region.y1);\n        if (![x0, y0, x1, y1].every(Number.isFinite)) {\n          return createErrorResponse('region must contain finite numbers (x0, y0, x1, y1)');\n        }\n        if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0) {\n          return createErrorResponse('Invalid region: require x0>=0, y0>=0 and x1>x0, y1>y0');\n        }\n\n        // Project coordinates from screenshot space to viewport space\n        const p0 = project({ x: x0, y: y0 })!;\n        const p1 = project({ x: x1, y: y1 })!;\n        const rx0 = Math.min(p0.x, p1.x);\n        const ry0 = Math.min(p0.y, p1.y);\n        const rx1 = Math.max(p0.x, p1.x);\n        const ry1 = Math.max(p0.y, p1.y);\n        const w = rx1 - rx0;\n        const h = ry1 - ry0;\n        if (w <= 0 || h <= 0) {\n          return createErrorResponse('Invalid region after projection');\n        }\n\n        // Security check: verify domain hasn't changed since last screenshot\n        {\n          const getHostname = (url: string): string => {\n            try {\n              return new URL(url).hostname;\n            } catch {\n              return '';\n            }\n          };\n          const ctx = screenshotContextManager.getContext(tab.id!);\n          const contextHostname = (ctx as any)?.hostname as string | undefined;\n          const currentHostname = getHostname(tab.url || '');\n          if (contextHostname && contextHostname !== currentHostname) {\n            return createErrorResponse(\n              `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during zoom. Capture a new screenshot first.`,\n            );\n          }\n        }\n\n        try {\n          await CDPHelper.attach(tab.id);\n          const metrics: any = await CDPHelper.send(tab.id, 'Page.getLayoutMetrics', {});\n          const viewport = metrics?.layoutViewport ||\n            metrics?.visualViewport || {\n              clientWidth: 800,\n              clientHeight: 600,\n              pageX: 0,\n              pageY: 0,\n            };\n          const vw = Math.round(Number(viewport.clientWidth || 800));\n          const vh = Math.round(Number(viewport.clientHeight || 600));\n          if (rx1 > vw || ry1 > vh) {\n            await CDPHelper.detach(tab.id);\n            return createErrorResponse(\n              `Region exceeds viewport boundaries (${vw}x${vh}). Choose a region within the visible viewport.`,\n            );\n          }\n          const pageX = Number(viewport.pageX || 0);\n          const pageY = Number(viewport.pageY || 0);\n\n          const shot: any = await CDPHelper.send(tab.id, 'Page.captureScreenshot', {\n            format: 'png',\n            captureBeyondViewport: false,\n            fromSurface: true,\n            clip: {\n              x: pageX + rx0,\n              y: pageY + ry0,\n              width: w,\n              height: h,\n              scale: 1,\n            },\n          });\n          await CDPHelper.detach(tab.id);\n\n          const base64Data = String(shot?.data || '');\n          if (!base64Data) {\n            return createErrorResponse('Failed to capture zoom screenshot via CDP');\n          }\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'zoom',\n                  mimeType: 'image/png',\n                  base64Data,\n                  region: { x0: rx0, y0: ry0, x1: rx1, y1: ry1 },\n                }),\n              },\n            ],\n            isError: false,\n          };\n        } catch (e) {\n          await CDPHelper.detach(tab.id);\n          return createErrorResponse(`zoom failed: ${e instanceof Error ? e.message : String(e)}`);\n        }\n      }\n      case 'screenshot': {\n        // Reuse existing screenshot tool; it already supports base64 save option\n        const result = await screenshotTool.execute({\n          name: 'computer',\n          storeBase64: true,\n          fullPage: false,\n        });\n        return result;\n      }\n      default:\n        return createErrorResponse(`Unsupported action: ${params.action}`);\n    }\n  }\n\n  /**\n   * DOM-based hover fallback when CDP is unavailable\n   * Tries ref-based approach first (works with iframes), falls back to coordinates\n   */\n  private async domHoverFallback(\n    tabId: number,\n    coord?: Coordinates,\n    resolvedBy?: 'ref' | 'selector' | 'coordinates',\n    ref?: string,\n  ): Promise<ToolResult> {\n    // Try ref-based approach first (handles iframes correctly)\n    if (ref) {\n      try {\n        const resp = await this.sendMessageToTab(tabId, {\n          action: TOOL_MESSAGE_TYPES.DISPATCH_HOVER_FOR_REF,\n          ref,\n        });\n        if (resp?.success) {\n          return {\n            content: [\n              {\n                type: 'text',\n                text: JSON.stringify({\n                  success: true,\n                  action: 'hover',\n                  resolvedBy: 'ref',\n                  transport: 'dom-ref',\n                  target: resp.target,\n                }),\n              },\n            ],\n            isError: false,\n          };\n        }\n      } catch (error) {\n        console.warn('[ComputerTool] DOM ref hover failed, falling back to coordinates', error);\n      }\n    }\n\n    // Fallback to coordinate-based approach\n    if (!coord) {\n      return createErrorResponse('Hover fallback requires coordinates or ref');\n    }\n\n    try {\n      const [injection] = await chrome.scripting.executeScript({\n        target: { tabId },\n        world: 'MAIN',\n        func: (point) => {\n          const target = document.elementFromPoint(point.x, point.y);\n          if (!target) {\n            return { success: false, error: 'No element found at coordinates' };\n          }\n\n          // Dispatch hover-related events\n          for (const type of ['mousemove', 'mouseover', 'mouseenter']) {\n            target.dispatchEvent(\n              new MouseEvent(type, {\n                bubbles: true,\n                cancelable: true,\n                clientX: point.x,\n                clientY: point.y,\n                view: window,\n              }),\n            );\n          }\n\n          return {\n            success: true,\n            target: {\n              tagName: target.tagName,\n              id: target.id,\n              className: target.className,\n              text: target.textContent?.trim()?.slice(0, 100) || '',\n            },\n          };\n        },\n        args: [coord],\n      });\n\n      const payload = injection?.result;\n      if (!payload?.success) {\n        return createErrorResponse(payload?.error || 'DOM hover fallback failed');\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              action: 'hover',\n              coordinates: coord,\n              resolvedBy,\n              transport: 'dom',\n              target: payload.target,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      return createErrorResponse(\n        `DOM hover fallback failed: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  /**\n   * Trigger GIF auto-capture after a successful action.\n   * This is a no-op if auto-capture is not active.\n   */\n  private async triggerAutoCapture(\n    tabId: number,\n    actionType: ActionType,\n    metadata?: Partial<ActionMetadata>,\n  ): Promise<void> {\n    if (!isAutoCaptureActive(tabId)) {\n      return;\n    }\n\n    try {\n      await captureFrameOnAction(tabId, {\n        type: actionType,\n        ...metadata,\n      });\n    } catch (error) {\n      // Log but don't fail the main action\n      console.warn('[ComputerTool] Auto-capture failed:', error);\n    }\n  }\n}\n\nexport const computerTool = new ComputerTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/console-buffer.ts",
    "content": "import { cdpSessionManager } from '@/utils/cdp-session-manager';\n\n/**\n * ConsoleBuffer - 持久化的控制台日志缓冲管理器\n *\n * 为每个 tab 维护一个滚动缓冲区，持续收集控制台事件。\n * 当 tab 导航到新域名时会自动清空缓冲，避免不同站点日志混淆。\n */\n\nconst DEFAULT_MAX_BUFFER_MESSAGES = 2000;\nconst DEFAULT_MAX_BUFFER_EXCEPTIONS = 500;\n\nexport interface BufferedConsoleMessage {\n  timestamp: number;\n  level: string;\n  text: string;\n  args?: unknown[];\n  source?: string;\n  url?: string;\n  lineNumber?: number;\n  stackTrace?: unknown;\n}\n\nexport interface BufferedConsoleException {\n  timestamp: number;\n  text: string;\n  url?: string;\n  lineNumber?: number;\n  columnNumber?: number;\n  stackTrace?: unknown;\n}\n\ninterface TabConsoleBufferState {\n  tabId: number;\n  tabUrl: string;\n  tabTitle: string;\n  hostname: string;\n  captureStartTime: number;\n  messages: BufferedConsoleMessage[];\n  exceptions: BufferedConsoleException[];\n  droppedMessageCount: number;\n  droppedExceptionCount: number;\n}\n\nexport interface ConsoleBufferReadOptions {\n  pattern?: RegExp;\n  onlyErrors?: boolean;\n  limit?: number;\n  includeExceptions?: boolean;\n}\n\nexport interface ConsoleBufferReadResult {\n  tabId: number;\n  tabUrl: string;\n  tabTitle: string;\n  captureStartTime: number;\n  captureEndTime: number;\n  totalDurationMs: number;\n  messages: BufferedConsoleMessage[];\n  exceptions: BufferedConsoleException[];\n  totalBufferedMessages: number;\n  totalBufferedExceptions: number;\n  messageCount: number;\n  exceptionCount: number;\n  messageLimitReached: boolean;\n  droppedMessageCount: number;\n  droppedExceptionCount: number;\n}\n\nfunction extractHostname(url?: string): string {\n  if (!url) return '';\n  try {\n    return new URL(url).hostname;\n  } catch {\n    return '';\n  }\n}\n\nfunction isErrorLevel(level?: string): boolean {\n  const normalized = (level || '').toLowerCase();\n  return normalized === 'error' || normalized === 'assert';\n}\n\nfunction matchesPattern(pattern: RegExp, text: string): boolean {\n  pattern.lastIndex = 0;\n  return pattern.test(text);\n}\n\nfunction formatConsoleArgs(args: unknown[]): string {\n  if (!args || args.length === 0) return '';\n\n  return args\n    .map((arg: unknown) => {\n      const a = arg as Record<string, unknown>;\n      if (a.type === 'string') return (a.value as string) || '';\n      if (a.type === 'number') return String(a.value ?? '');\n      if (a.type === 'boolean') return String(a.value ?? '');\n      if (a.type === 'object') return (a.description as string) || '[Object]';\n      if (a.type === 'undefined') return 'undefined';\n      if (a.type === 'function') return (a.description as string) || '[Function]';\n      return (a.description as string) || (a.value as string) || String(arg);\n    })\n    .join(' ');\n}\n\n/**\n * 从 CDP RemoteObject 提取安全的预览数据，丢弃 objectId 避免内存泄漏\n */\nfunction extractArgPreview(arg: unknown): unknown {\n  const a = arg as Record<string, unknown>;\n  if (!a || typeof a !== 'object') return arg;\n\n  // 只保留安全的字段，丢弃 objectId\n  const preview: Record<string, unknown> = {\n    type: a.type,\n  };\n\n  if ('value' in a) preview.value = a.value;\n  if ('unserializableValue' in a) preview.unserializableValue = a.unserializableValue;\n  if ('description' in a) preview.description = a.description;\n  if ('subtype' in a) preview.subtype = a.subtype;\n  if ('className' in a) preview.className = a.className;\n\n  return preview;\n}\n\nfunction safeTimestamp(value: unknown): number {\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return value;\n  }\n  return Date.now();\n}\n\nfunction safeString(value: unknown): string {\n  return typeof value === 'string' ? value : '';\n}\n\nfunction safeNumber(value: unknown): number | undefined {\n  return typeof value === 'number' ? value : undefined;\n}\n\nclass ConsoleBuffer {\n  private buffers = new Map<number, TabConsoleBufferState>();\n  private starting = new Map<number, Promise<void>>();\n  private static instance: ConsoleBuffer | null = null;\n\n  constructor() {\n    if (ConsoleBuffer.instance) {\n      return ConsoleBuffer.instance;\n    }\n    ConsoleBuffer.instance = this;\n\n    chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));\n    chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));\n    chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));\n    chrome.tabs.onUpdated.addListener(this.handleTabUpdated.bind(this));\n  }\n\n  /**\n   * 检查指定 tab 是否正在进行 buffer 模式的捕获\n   */\n  isCapturing(tabId: number): boolean {\n    return this.buffers.has(tabId);\n  }\n\n  /**\n   * 确保指定 tab 的 buffer 捕获已启动\n   */\n  async ensureStarted(tabId: number): Promise<void> {\n    if (this.buffers.has(tabId)) return;\n\n    const existing = this.starting.get(tabId);\n    if (existing) return existing;\n\n    const promise = this.startCapture(tabId).finally(() => {\n      this.starting.delete(tabId);\n    });\n    this.starting.set(tabId, promise);\n    return promise;\n  }\n\n  /**\n   * 清空指定 tab 的缓冲区\n   */\n  clear(\n    tabId: number,\n    reason: string = 'manual',\n  ): { clearedMessages: number; clearedExceptions: number } | null {\n    const state = this.buffers.get(tabId);\n    if (!state) return null;\n\n    const clearedMessages = state.messages.length;\n    const clearedExceptions = state.exceptions.length;\n\n    state.messages.length = 0;\n    state.exceptions.length = 0;\n    state.droppedMessageCount = 0;\n    state.droppedExceptionCount = 0;\n    state.captureStartTime = Date.now();\n\n    console.log(\n      `ConsoleBuffer: Cleared buffer for tab ${tabId} (reason=${reason}). ` +\n        `${clearedMessages} messages, ${clearedExceptions} exceptions.`,\n    );\n\n    return { clearedMessages, clearedExceptions };\n  }\n\n  /**\n   * 读取指定 tab 的缓冲区内容\n   */\n  read(tabId: number, options: ConsoleBufferReadOptions = {}): ConsoleBufferReadResult | null {\n    const state = this.buffers.get(tabId);\n    if (!state) return null;\n\n    const { pattern, onlyErrors = false, limit, includeExceptions = true } = options;\n\n    const totalBufferedMessages = state.messages.length;\n    const totalBufferedExceptions = state.exceptions.length;\n\n    // 过滤消息\n    let messages = state.messages;\n    if (onlyErrors) {\n      messages = messages.filter((m) => isErrorLevel(m.level));\n    }\n    if (pattern) {\n      messages = messages.filter((m) => matchesPattern(pattern, m.text || ''));\n    }\n\n    // 按时间排序\n    messages = [...messages].sort((a, b) => a.timestamp - b.timestamp);\n\n    // 应用 limit\n    let messageLimitReached = false;\n    const normalizedLimit =\n      typeof limit === 'number' && Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : null;\n    if (normalizedLimit !== null && messages.length > normalizedLimit) {\n      messageLimitReached = true;\n      // 保留最新的消息\n      messages = messages.slice(messages.length - normalizedLimit);\n    }\n\n    // 过滤异常\n    let exceptions: BufferedConsoleException[] = [];\n    if (includeExceptions) {\n      exceptions = state.exceptions;\n      if (pattern) {\n        exceptions = exceptions.filter((e) => matchesPattern(pattern, e.text || ''));\n      }\n      exceptions = [...exceptions].sort((a, b) => a.timestamp - b.timestamp);\n    }\n\n    const now = Date.now();\n\n    return {\n      tabId,\n      tabUrl: state.tabUrl,\n      tabTitle: state.tabTitle,\n      captureStartTime: state.captureStartTime,\n      captureEndTime: now,\n      totalDurationMs: now - state.captureStartTime,\n      messages,\n      exceptions,\n      totalBufferedMessages,\n      totalBufferedExceptions,\n      messageCount: messages.length,\n      exceptionCount: exceptions.length,\n      messageLimitReached,\n      droppedMessageCount: state.droppedMessageCount,\n      droppedExceptionCount: state.droppedExceptionCount,\n    };\n  }\n\n  private async startCapture(tabId: number): Promise<void> {\n    const tab = await chrome.tabs.get(tabId);\n    const url = tab.url || '';\n    const title = tab.title || '';\n    const hostname = extractHostname(url);\n\n    const state: TabConsoleBufferState = {\n      tabId,\n      tabUrl: url,\n      tabTitle: title,\n      hostname,\n      captureStartTime: Date.now(),\n      messages: [],\n      exceptions: [],\n      droppedMessageCount: 0,\n      droppedExceptionCount: 0,\n    };\n\n    this.buffers.set(tabId, state);\n\n    try {\n      await cdpSessionManager.attach(tabId, 'console-buffer');\n      await cdpSessionManager.sendCommand(tabId, 'Runtime.enable');\n      await cdpSessionManager.sendCommand(tabId, 'Log.enable');\n    } catch (error) {\n      this.buffers.delete(tabId);\n      await cdpSessionManager.detach(tabId, 'console-buffer').catch(() => {});\n      throw error;\n    }\n  }\n\n  private handleTabRemoved(tabId: number): void {\n    if (!this.buffers.has(tabId)) return;\n    void this.stopCapture(tabId, 'tab_closed');\n  }\n\n  private handleTabUpdated(\n    tabId: number,\n    changeInfo: chrome.tabs.TabChangeInfo,\n    tab: chrome.tabs.Tab,\n  ): void {\n    const state = this.buffers.get(tabId);\n    if (!state) return;\n\n    const nextUrl = changeInfo.url ?? tab.url;\n    const nextTitle = tab.title;\n\n    if (typeof nextUrl === 'string') {\n      const nextHost = extractHostname(nextUrl);\n      // 域名变化时清空缓冲\n      if (nextHost !== state.hostname) {\n        this.clear(tabId, 'domain_changed');\n        state.hostname = nextHost;\n      }\n      state.tabUrl = nextUrl;\n    }\n\n    if (typeof nextTitle === 'string') {\n      state.tabTitle = nextTitle;\n    }\n  }\n\n  private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {\n    if (typeof source.tabId !== 'number') return;\n    if (!this.buffers.has(source.tabId)) return;\n\n    console.log(\n      `ConsoleBuffer: Debugger detached from tab ${source.tabId} (reason=${reason}), cleaning up.`,\n    );\n\n    this.buffers.delete(source.tabId);\n    this.starting.delete(source.tabId);\n    cdpSessionManager.detach(source.tabId, 'console-buffer').catch(() => {});\n  }\n\n  private handleDebuggerEvent(\n    source: chrome.debugger.Debuggee,\n    method: string,\n    params?: unknown,\n  ): void {\n    const tabId = source.tabId;\n    if (typeof tabId !== 'number') return;\n\n    const state = this.buffers.get(tabId);\n    if (!state) return;\n\n    const p = params as Record<string, unknown>;\n\n    if (method === 'Log.entryAdded' && p?.entry) {\n      const entry = p.entry as Record<string, unknown>;\n      state.messages.push({\n        timestamp: safeTimestamp(entry.timestamp),\n        level: safeString(entry.level) || 'log',\n        text: safeString(entry.text),\n        source: safeString(entry.source),\n        url: safeString(entry.url),\n        lineNumber: safeNumber(entry.lineNumber),\n        stackTrace: entry.stackTrace,\n      });\n      this.trimMessages(state);\n      return;\n    }\n\n    if (method === 'Runtime.consoleAPICalled' && p) {\n      const stackTrace = p.stackTrace as Record<string, unknown[]> | undefined;\n      const callFrame = stackTrace?.callFrames?.[0] as Record<string, unknown> | undefined;\n      const rawArgs = (p.args as unknown[]) || [];\n\n      state.messages.push({\n        timestamp: safeTimestamp(p.timestamp),\n        level: safeString(p.type) || 'log',\n        text: formatConsoleArgs(rawArgs),\n        source: 'console-api',\n        url: safeString(callFrame?.url),\n        lineNumber: safeNumber(callFrame?.lineNumber),\n        stackTrace: stackTrace,\n        // 只存储安全的预览数据，避免内存泄漏\n        args: rawArgs.map(extractArgPreview),\n      });\n      this.trimMessages(state);\n      return;\n    }\n\n    if (method === 'Runtime.exceptionThrown' && p?.exceptionDetails) {\n      const exceptionDetails = p.exceptionDetails as Record<string, unknown>;\n      const exception = exceptionDetails.exception as Record<string, unknown> | undefined;\n      state.exceptions.push({\n        timestamp: Date.now(),\n        text:\n          safeString(exceptionDetails.text) ||\n          safeString(exception?.description) ||\n          'Unknown exception',\n        url: safeString(exceptionDetails.url),\n        lineNumber: safeNumber(exceptionDetails.lineNumber),\n        columnNumber: safeNumber(exceptionDetails.columnNumber),\n        stackTrace: exceptionDetails.stackTrace,\n      });\n      this.trimExceptions(state);\n    }\n  }\n\n  private trimMessages(state: TabConsoleBufferState): void {\n    const overflow = state.messages.length - DEFAULT_MAX_BUFFER_MESSAGES;\n    if (overflow <= 0) return;\n    state.messages.splice(0, overflow);\n    state.droppedMessageCount += overflow;\n  }\n\n  private trimExceptions(state: TabConsoleBufferState): void {\n    const overflow = state.exceptions.length - DEFAULT_MAX_BUFFER_EXCEPTIONS;\n    if (overflow <= 0) return;\n    state.exceptions.splice(0, overflow);\n    state.droppedExceptionCount += overflow;\n  }\n\n  private async stopCapture(tabId: number, reason: string): Promise<void> {\n    if (!this.buffers.has(tabId)) return;\n\n    this.buffers.delete(tabId);\n    this.starting.delete(tabId);\n\n    try {\n      await cdpSessionManager.sendCommand(tabId, 'Runtime.disable');\n    } catch {\n      // best effort\n    }\n    try {\n      await cdpSessionManager.sendCommand(tabId, 'Log.disable');\n    } catch {\n      // best effort\n    }\n    await cdpSessionManager.detach(tabId, 'console-buffer').catch(() => {});\n    console.log(`ConsoleBuffer: Stopped buffer for tab ${tabId} (reason=${reason}).`);\n  }\n}\n\nexport const consoleBuffer = new ConsoleBuffer();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/console.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport { consoleBuffer, BufferedConsoleMessage, BufferedConsoleException } from './console-buffer';\n\nconst DEFAULT_MAX_MESSAGES = 100;\n\ntype ConsoleMode = 'snapshot' | 'buffer';\n\ninterface ConsoleToolParams {\n  url?: string;\n  tabId?: number;\n  background?: boolean;\n  windowId?: number;\n  includeExceptions?: boolean;\n  maxMessages?: number;\n  // 新增参数\n  mode?: ConsoleMode;\n  buffer?: boolean; // mode=\"buffer\" 的别名\n  clear?: boolean; // 读取前清空\n  clearAfterRead?: boolean; // 读取后清空（mcp-tools.js 风格）\n  pattern?: string;\n  onlyErrors?: boolean;\n  limit?: number;\n}\n\ninterface ConsoleMessage {\n  timestamp: number;\n  level: string;\n  text: string;\n  args?: any[];\n  argsSerialized?: any[];\n  source?: string;\n  url?: string;\n  lineNumber?: number;\n  stackTrace?: any;\n}\n\ninterface ConsoleException {\n  timestamp: number;\n  text: string;\n  url?: string;\n  lineNumber?: number;\n  columnNumber?: number;\n  stackTrace?: any;\n}\n\ninterface ConsoleResult {\n  success: boolean;\n  message: string;\n  tabId: number;\n  tabUrl: string;\n  tabTitle: string;\n  captureStartTime: number;\n  captureEndTime: number;\n  totalDurationMs: number;\n  messages: ConsoleMessage[];\n  exceptions: ConsoleException[];\n  messageCount: number;\n  exceptionCount: number;\n  messageLimitReached: boolean;\n  droppedMessageCount: number;\n  droppedExceptionCount: number;\n}\n\n// 辅助函数\n\nfunction normalizeLimit(value: unknown, fallback: number): number {\n  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;\n  return Math.max(0, n);\n}\n\nfunction parseRegexPattern(pattern?: string): RegExp | undefined {\n  if (typeof pattern !== 'string') return undefined;\n  const trimmed = pattern.trim();\n  if (!trimmed) return undefined;\n  // 支持 /pattern/flags 语法\n  const match = trimmed.match(/^\\/(.+)\\/([gimsuy]*)$/);\n  try {\n    return match ? new RegExp(match[1], match[2]) : new RegExp(trimmed);\n  } catch (e: unknown) {\n    const msg = e instanceof Error ? e.message : String(e);\n    throw new Error(`Invalid regex pattern: ${msg}`);\n  }\n}\n\nfunction matchesPattern(pattern: RegExp, text: string): boolean {\n  pattern.lastIndex = 0;\n  return pattern.test(text);\n}\n\nfunction isErrorLevel(level?: string): boolean {\n  const normalized = (level || '').toLowerCase();\n  return normalized === 'error' || normalized === 'assert';\n}\n\nfunction applyResultFilters(\n  result: ConsoleResult,\n  options: { pattern?: RegExp; onlyErrors?: boolean; includeExceptions: boolean },\n): ConsoleResult {\n  const { pattern, onlyErrors = false, includeExceptions } = options;\n\n  let messages = result.messages;\n  if (onlyErrors) {\n    messages = messages.filter((m) => isErrorLevel(m.level));\n  }\n  if (pattern) {\n    messages = messages.filter((m) => matchesPattern(pattern, m.text || ''));\n  }\n\n  let exceptions = includeExceptions ? result.exceptions : [];\n  if (includeExceptions && pattern) {\n    exceptions = exceptions.filter((e) => matchesPattern(pattern, e.text || ''));\n  }\n\n  return {\n    ...result,\n    messages,\n    exceptions,\n    messageCount: messages.length,\n    exceptionCount: exceptions.length,\n  };\n}\n\nfunction isDebuggerConflictError(error: unknown): boolean {\n  const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();\n  return msg.includes('debugger is already attached') || msg.includes('another client');\n}\n\nfunction formatDebuggerConflictMessage(tabId: number, originalMessage: string): string {\n  return (\n    `Failed to attach Chrome Debugger to tab ${tabId}: another debugger client is already attached ` +\n    `(likely DevTools or another extension). Close DevTools for this tab or disable the conflicting extension, ` +\n    `then retry. Original error: ${originalMessage}`\n  );\n}\n\n/**\n * Tool for capturing console output from browser tabs\n */\nclass ConsoleTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.CONSOLE;\n\n  async execute(args: ConsoleToolParams): Promise<ToolResult> {\n    const {\n      url,\n      tabId,\n      windowId,\n      background = false,\n      includeExceptions = true,\n      maxMessages = DEFAULT_MAX_MESSAGES,\n      mode = 'snapshot',\n      buffer,\n      clear = false,\n      clearAfterRead = false,\n      pattern,\n      onlyErrors = false,\n      limit,\n    } = args;\n\n    let targetTab: chrome.tabs.Tab;\n    let targetTabId: number | undefined;\n\n    // 解析正则表达式\n    let compiledPattern: RegExp | undefined;\n    try {\n      compiledPattern = parseRegexPattern(pattern);\n    } catch (e: unknown) {\n      const msg = e instanceof Error ? e.message : String(e);\n      return createErrorResponse(msg);\n    }\n\n    try {\n      if (typeof tabId === 'number') {\n        // Use explicit tab\n        const t = await chrome.tabs.get(tabId);\n        if (!t?.id) return createErrorResponse('Failed to identify target tab.');\n        targetTab = t;\n      } else if (url) {\n        // Navigate to the specified URL\n        targetTab = await this.navigateToUrl(url, background === true, windowId);\n      } else {\n        // Use current active tab\n        const [activeTab] =\n          typeof windowId === 'number'\n            ? await chrome.tabs.query({ active: true, windowId })\n            : await chrome.tabs.query({ active: true, currentWindow: true });\n        if (!activeTab?.id) {\n          return createErrorResponse('No active tab found and no URL provided.');\n        }\n        targetTab = activeTab;\n      }\n\n      if (!targetTab?.id) {\n        return createErrorResponse('Failed to identify target tab.');\n      }\n\n      targetTabId = targetTab.id;\n\n      // 确定模式：buffer 参数是 mode=\"buffer\" 的别名\n      const resolvedMode: ConsoleMode =\n        mode === 'buffer' || buffer === true ? 'buffer' : 'snapshot';\n\n      // 计算有效的消息限制\n      const normalizedMaxMessages = normalizeLimit(maxMessages, DEFAULT_MAX_MESSAGES);\n      const effectiveLimit =\n        typeof limit === 'number'\n          ? normalizeLimit(limit, normalizedMaxMessages)\n          : normalizedMaxMessages;\n\n      // Buffer 模式\n      if (resolvedMode === 'buffer') {\n        try {\n          await consoleBuffer.ensureStarted(targetTabId);\n        } catch (error: unknown) {\n          const msg = error instanceof Error ? error.message : String(error);\n          if (isDebuggerConflictError(error)) {\n            return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg));\n          }\n          throw error;\n        }\n\n        // 处理读取前清空请求\n        let clearedBefore: { clearedMessages: number; clearedExceptions: number } | null = null;\n        if (clear === true) {\n          clearedBefore = consoleBuffer.clear(targetTabId, 'manual');\n        }\n\n        // 读取缓冲区\n        const read = consoleBuffer.read(targetTabId, {\n          pattern: compiledPattern,\n          onlyErrors,\n          limit: effectiveLimit,\n          includeExceptions,\n        });\n\n        if (!read) {\n          return createErrorResponse('Console buffer is not available for this tab.');\n        }\n\n        // 处理读取后清空请求（mcp-tools.js 风格，避免重复读取）\n        let clearedAfter: { clearedMessages: number; clearedExceptions: number } | null = null;\n        if (clearAfterRead === true) {\n          clearedAfter = consoleBuffer.clear(targetTabId, 'manual');\n        }\n\n        // 构建清空摘要\n        let clearedSummary = '';\n        if (clearedBefore) {\n          clearedSummary += ` Cleared ${clearedBefore.clearedMessages} messages and ${clearedBefore.clearedExceptions} exceptions before reading.`;\n        }\n        if (clearedAfter) {\n          clearedSummary += ` Cleared ${clearedAfter.clearedMessages} messages and ${clearedAfter.clearedExceptions} exceptions after reading.`;\n        }\n\n        const result: ConsoleResult = {\n          success: true,\n          message:\n            `Console buffer read for tab ${targetTabId}.` +\n            clearedSummary +\n            ` Returned ${read.messageCount} messages and ${read.exceptionCount} exceptions.`,\n          tabId: targetTabId,\n          tabUrl: read.tabUrl || '',\n          tabTitle: read.tabTitle || '',\n          captureStartTime: read.captureStartTime,\n          captureEndTime: read.captureEndTime,\n          totalDurationMs: read.totalDurationMs,\n          messages: read.messages as ConsoleMessage[],\n          exceptions: read.exceptions as ConsoleException[],\n          messageCount: read.messageCount,\n          exceptionCount: read.exceptionCount,\n          messageLimitReached: read.messageLimitReached,\n          droppedMessageCount: read.droppedMessageCount,\n          droppedExceptionCount: read.droppedExceptionCount,\n        };\n\n        return {\n          content: [{ type: 'text', text: JSON.stringify(result) }],\n          isError: false,\n        };\n      }\n\n      // Snapshot 模式（一次性捕获）\n      const result = await this.captureConsoleMessages(targetTabId, {\n        includeExceptions,\n        maxMessages: effectiveLimit,\n      });\n\n      // 应用过滤器\n      const filtered = applyResultFilters(result, {\n        pattern: compiledPattern,\n        onlyErrors,\n        includeExceptions,\n      });\n\n      return {\n        content: [{ type: 'text', text: JSON.stringify(filtered) }],\n        isError: false,\n      };\n    } catch (error: unknown) {\n      console.error('ConsoleTool: Critical error during execute:', error);\n      const msg = error instanceof Error ? error.message : String(error);\n      if (typeof targetTabId === 'number' && isDebuggerConflictError(error)) {\n        return createErrorResponse(formatDebuggerConflictMessage(targetTabId, msg));\n      }\n      return createErrorResponse(`Error in ConsoleTool: ${msg}`);\n    }\n  }\n\n  private async navigateToUrl(\n    url: string,\n    background = false,\n    windowId?: number,\n  ): Promise<chrome.tabs.Tab> {\n    // Check if URL is already open\n    const existingTabs = await chrome.tabs.query({ url });\n\n    if (existingTabs.length > 0 && existingTabs[0]?.id) {\n      const tab = existingTabs[0];\n      if (!background) {\n        // Activate the existing tab\n        await chrome.tabs.update(tab.id!, { active: true });\n        await chrome.windows.update(tab.windowId, { focused: true });\n      }\n      return tab;\n    } else {\n      // Create new tab with the URL\n      const createInfo: chrome.tabs.CreateProperties = { url, active: background ? false : true };\n      if (typeof windowId === 'number') createInfo.windowId = windowId;\n      const newTab = await chrome.tabs.create(createInfo);\n      // Wait for tab to be ready\n      await this.waitForTabReady(newTab.id!);\n      return newTab;\n    }\n  }\n\n  private async waitForTabReady(tabId: number): Promise<void> {\n    return new Promise((resolve) => {\n      const checkTab = async () => {\n        try {\n          const tab = await chrome.tabs.get(tabId);\n          if (tab.status === 'complete') {\n            resolve();\n          } else {\n            setTimeout(checkTab, 100);\n          }\n        } catch (error) {\n          // Tab might be closed, resolve anyway\n          resolve();\n        }\n      };\n      checkTab();\n    });\n  }\n\n  private formatConsoleArgs(args: any[]): string {\n    if (!args || args.length === 0) return '';\n\n    return args\n      .map((arg) => {\n        if (arg.type === 'string') {\n          return arg.value || '';\n        } else if (arg.type === 'number') {\n          return String(arg.value || '');\n        } else if (arg.type === 'boolean') {\n          return String(arg.value || '');\n        } else if (arg.type === 'object') {\n          return arg.description || '[Object]';\n        } else if (arg.type === 'undefined') {\n          return 'undefined';\n        } else if (arg.type === 'function') {\n          return arg.description || '[Function]';\n        } else {\n          return arg.description || arg.value || String(arg);\n        }\n      })\n      .join(' ');\n  }\n\n  private async captureConsoleMessages(\n    tabId: number,\n    options: {\n      includeExceptions: boolean;\n      maxMessages: number;\n    },\n  ): Promise<ConsoleResult> {\n    const { includeExceptions, maxMessages } = options;\n    const startTime = Date.now();\n    const messages: ConsoleMessage[] = [];\n    const exceptions: ConsoleException[] = [];\n    let limitReached = false;\n\n    try {\n      // Get tab information\n      const tab = await chrome.tabs.get(tabId);\n\n      // Attach via shared manager\n      await cdpSessionManager.attach(tabId, 'console');\n\n      // Set up event listener to collect messages\n      const collectedMessages: any[] = [];\n      const collectedExceptions: any[] = [];\n\n      const eventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => {\n        if (source.tabId !== tabId) return;\n\n        if (method === 'Log.entryAdded' && params?.entry) {\n          collectedMessages.push(params.entry);\n        } else if (method === 'Runtime.consoleAPICalled' && params) {\n          // Convert Runtime.consoleAPICalled to Log.entryAdded format\n          const logEntry = {\n            timestamp: params.timestamp,\n            level: params.type || 'log',\n            text: this.formatConsoleArgs(params.args || []),\n            source: 'console-api',\n            url: params.stackTrace?.callFrames?.[0]?.url,\n            lineNumber: params.stackTrace?.callFrames?.[0]?.lineNumber,\n            stackTrace: params.stackTrace,\n            args: params.args,\n          };\n          collectedMessages.push(logEntry);\n        } else if (\n          method === 'Runtime.exceptionThrown' &&\n          includeExceptions &&\n          params?.exceptionDetails\n        ) {\n          collectedExceptions.push(params.exceptionDetails);\n        }\n      };\n\n      chrome.debugger.onEvent.addListener(eventListener);\n\n      try {\n        // Enable Runtime domain first to capture console API calls and exceptions\n        await cdpSessionManager.sendCommand(tabId, 'Runtime.enable');\n\n        // Also enable Log domain to capture other log entries\n        await cdpSessionManager.sendCommand(tabId, 'Log.enable');\n\n        // Wait for all messages to be flushed\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n\n        // Process collected messages\n        // Helper to deeply serialize console arguments when possible\n        const serializeArg = async (arg: any): Promise<any> => {\n          try {\n            if (!arg) return arg;\n            if (Object.prototype.hasOwnProperty.call(arg, 'unserializableValue')) {\n              return arg.unserializableValue;\n            }\n            if (Object.prototype.hasOwnProperty.call(arg, 'value')) {\n              return arg.value;\n            }\n            if (arg.objectId) {\n              const resp = await cdpSessionManager.sendCommand(tabId, 'Runtime.callFunctionOn', {\n                objectId: arg.objectId,\n                functionDeclaration:\n                  'function(maxDepth, maxProps){\\n' +\n                  '  const seen=new WeakSet();\\n' +\n                  '  function S(v,d){\\n' +\n                  '    try{\\n' +\n                  '      if(d<0) return \"[MaxDepth]\";\\n' +\n                  '      if(v===null) return null;\\n' +\n                  '      const t=typeof v;\\n' +\n                  '      if(t!==\"object\"){\\n' +\n                  '        if(t===\"bigint\") return v.toString()+\"n\";\\n' +\n                  '        return v;\\n' +\n                  '      }\\n' +\n                  '      if(seen.has(v)) return \"[Circular]\";\\n' +\n                  '      seen.add(v);\\n' +\n                  '      if(Array.isArray(v)){\\n' +\n                  '        const out=[];\\n' +\n                  '        for(let i=0;i<v.length;i++){\\n' +\n                  '          if(i>=maxProps){ out.push(\"[...truncated]\"); break; }\\n' +\n                  '          out.push(S(v[i], d-1));\\n' +\n                  '        }\\n' +\n                  '        return out;\\n' +\n                  '      }\\n' +\n                  '      if(v instanceof Date) return {__type:\"Date\", value:v.toISOString()};\\n' +\n                  '      if(v instanceof RegExp) return {__type:\"RegExp\", value:String(v)};\\n' +\n                  '      if(v instanceof Map){\\n' +\n                  '        const out={__type:\"Map\", entries:[]}; let c=0;\\n' +\n                  '        for(const [k,val] of v.entries()){\\n' +\n                  '          if(c++>=maxProps){ out.entries.push([\"[...truncated]\",\"[...truncated]\"]); break; }\\n' +\n                  '          out.entries.push([S(k,d-1), S(val,d-1)]);\\n' +\n                  '        }\\n' +\n                  '        return out;\\n' +\n                  '      }\\n' +\n                  '      if(v instanceof Set){\\n' +\n                  '        const out={__type:\"Set\", values:[]}; let c=0;\\n' +\n                  '        for(const val of v.values()){\\n' +\n                  '          if(c++>=maxProps){ out.values.push(\"[...truncated]\"); break; }\\n' +\n                  '          out.values.push(S(val,d-1));\\n' +\n                  '        }\\n' +\n                  '        return out;\\n' +\n                  '      }\\n' +\n                  '      const out={}; let c=0;\\n' +\n                  '      for(const key in v){\\n' +\n                  '        if(c++>=maxProps){ out.__truncated__=true; break; }\\n' +\n                  '        try{ out[key]=S(v[key], d-1); }catch(e){ out[key]=\"[Thrown]\"; }\\n' +\n                  '      }\\n' +\n                  '      return out;\\n' +\n                  '    }catch(e){ return \"[Unserializable]\" }\\n' +\n                  '  }\\n' +\n                  '  return S(this, maxDepth);\\n' +\n                  '}',\n                arguments: [{ value: 3 }, { value: 100 }],\n                silent: true,\n                returnByValue: true,\n              });\n              return resp?.result?.value ?? '[Unavailable]';\n            }\n            return '[Unknown]';\n          } catch (e) {\n            return '[SerializeError]';\n          }\n        };\n\n        for (const entry of collectedMessages) {\n          if (messages.length >= maxMessages) {\n            limitReached = true;\n            break;\n          }\n\n          const message: ConsoleMessage = {\n            timestamp: entry.timestamp,\n            level: entry.level || 'log',\n            text: entry.text || '',\n            source: entry.source,\n            url: entry.url,\n            lineNumber: entry.lineNumber,\n          };\n\n          if (entry.stackTrace) {\n            message.stackTrace = entry.stackTrace;\n          }\n\n          if (entry.args && Array.isArray(entry.args)) {\n            message.args = entry.args;\n            // Attempt deep serialization for better fidelity\n            const serialized: any[] = [];\n            for (const a of entry.args) {\n              serialized.push(await serializeArg(a));\n            }\n            message.argsSerialized = serialized;\n          }\n\n          messages.push(message);\n        }\n\n        // Process collected exceptions\n        for (const exceptionDetails of collectedExceptions) {\n          const exception: ConsoleException = {\n            timestamp: Date.now(),\n            text:\n              exceptionDetails.text ||\n              exceptionDetails.exception?.description ||\n              'Unknown exception',\n            url: exceptionDetails.url,\n            lineNumber: exceptionDetails.lineNumber,\n            columnNumber: exceptionDetails.columnNumber,\n          };\n\n          if (exceptionDetails.stackTrace) {\n            exception.stackTrace = exceptionDetails.stackTrace;\n          }\n\n          exceptions.push(exception);\n        }\n      } finally {\n        // Clean up\n        chrome.debugger.onEvent.removeListener(eventListener);\n\n        // 如果 buffer 模式正在使用这个 tab，不要关闭 Runtime/Log 域\n        const keepDomainsEnabled = consoleBuffer.isCapturing(tabId);\n        if (!keepDomainsEnabled) {\n          try {\n            await cdpSessionManager.sendCommand(tabId, 'Runtime.disable');\n          } catch (e) {\n            console.warn(`ConsoleTool: Error disabling Runtime for tab ${tabId}:`, e);\n          }\n\n          try {\n            await cdpSessionManager.sendCommand(tabId, 'Log.disable');\n          } catch (e) {\n            console.warn(`ConsoleTool: Error disabling Log for tab ${tabId}:`, e);\n          }\n        }\n\n        try {\n          await cdpSessionManager.detach(tabId, 'console');\n        } catch (e) {\n          console.warn(`ConsoleTool: Error detaching debugger for tab ${tabId}:`, e);\n        }\n      }\n\n      const endTime = Date.now();\n\n      // Sort messages by timestamp\n      messages.sort((a, b) => a.timestamp - b.timestamp);\n      exceptions.sort((a, b) => a.timestamp - b.timestamp);\n\n      return {\n        success: true,\n        message: `Console capture completed for tab ${tabId}. ${messages.length} messages, ${exceptions.length} exceptions captured.`,\n        tabId,\n        tabUrl: tab.url || '',\n        tabTitle: tab.title || '',\n        captureStartTime: startTime,\n        captureEndTime: endTime,\n        totalDurationMs: endTime - startTime,\n        messages,\n        exceptions,\n        messageCount: messages.length,\n        exceptionCount: exceptions.length,\n        messageLimitReached: limitReached,\n        droppedMessageCount: 0,\n        droppedExceptionCount: 0,\n      };\n    } catch (error: any) {\n      console.error(`ConsoleTool: Error capturing console messages for tab ${tabId}:`, error);\n      throw error;\n    }\n  }\n}\n\nexport const consoleTool = new ConsoleTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/dialog.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\n\ninterface HandleDialogParams {\n  action: 'accept' | 'dismiss';\n  promptText?: string;\n}\n\n/**\n * Handle JavaScript dialogs (alert/confirm/prompt) via CDP Page.handleJavaScriptDialog\n */\nclass HandleDialogTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.HANDLE_DIALOG;\n\n  async execute(args: HandleDialogParams): Promise<ToolResult> {\n    const { action, promptText } = args || ({} as HandleDialogParams);\n    if (!action || (action !== 'accept' && action !== 'dismiss')) {\n      return createErrorResponse('action must be \"accept\" or \"dismiss\"');\n    }\n\n    try {\n      const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!activeTab?.id) return createErrorResponse('No active tab found');\n      const tabId = activeTab.id!;\n\n      // Use shared CDP session manager for safe attach/detach with refcount\n      await cdpSessionManager.withSession(tabId, 'dialog', async () => {\n        await cdpSessionManager.sendCommand(tabId, 'Page.enable');\n        await cdpSessionManager.sendCommand(tabId, 'Page.handleJavaScriptDialog', {\n          accept: action === 'accept',\n          promptText: action === 'accept' ? promptText : undefined,\n        });\n      });\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({ success: true, action, promptText: promptText || null }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      return createErrorResponse(\n        `Failed to handle dialog: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const handleDialogTool = new HandleDialogTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/download.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\n\ninterface HandleDownloadParams {\n  filenameContains?: string;\n  timeoutMs?: number; // default 60000\n  waitForComplete?: boolean; // default true\n}\n\n/**\n * Tool: wait for a download and return info\n */\nclass HandleDownloadTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD as any;\n\n  async execute(args: HandleDownloadParams): Promise<ToolResult> {\n    const filenameContains = String(args?.filenameContains || '').trim();\n    const waitForComplete = args?.waitForComplete !== false;\n    const timeoutMs = Math.max(1000, Math.min(Number(args?.timeoutMs ?? 60000), 300000));\n\n    try {\n      const result = await waitForDownload({ filenameContains, waitForComplete, timeoutMs });\n      return {\n        content: [{ type: 'text', text: JSON.stringify({ success: true, download: result }) }],\n        isError: false,\n      };\n    } catch (e: any) {\n      return createErrorResponse(`Handle download failed: ${e?.message || String(e)}`);\n    }\n  }\n}\n\nasync function waitForDownload(opts: {\n  filenameContains?: string;\n  waitForComplete: boolean;\n  timeoutMs: number;\n}) {\n  const { filenameContains, waitForComplete, timeoutMs } = opts;\n  return new Promise<any>((resolve, reject) => {\n    let timer: any = null;\n    const onError = (err: any) => {\n      cleanup();\n      reject(err instanceof Error ? err : new Error(String(err)));\n    };\n    const cleanup = () => {\n      try {\n        if (timer) clearTimeout(timer);\n      } catch {}\n      try {\n        chrome.downloads.onCreated.removeListener(onCreated);\n      } catch {}\n      try {\n        chrome.downloads.onChanged.removeListener(onChanged);\n      } catch {}\n    };\n    const matches = (item: chrome.downloads.DownloadItem) => {\n      if (!filenameContains) return true;\n      const name = (item.filename || '').split(/[/\\\\]/).pop() || '';\n      return name.includes(filenameContains) || (item.url || '').includes(filenameContains);\n    };\n    const fulfill = async (item: chrome.downloads.DownloadItem) => {\n      // try to fill more details via downloads.search\n      try {\n        const [found] = await chrome.downloads.search({ id: item.id });\n        const out = found || item;\n        cleanup();\n        resolve({\n          id: out.id,\n          filename: out.filename,\n          url: out.url,\n          mime: (out as any).mime || undefined,\n          fileSize: out.fileSize ?? out.totalBytes ?? undefined,\n          state: out.state,\n          danger: out.danger,\n          startTime: out.startTime,\n          endTime: (out as any).endTime || undefined,\n          exists: (out as any).exists,\n        });\n        return;\n      } catch {\n        cleanup();\n        resolve({ id: item.id, filename: item.filename, url: item.url, state: item.state });\n      }\n    };\n    const onCreated = (item: chrome.downloads.DownloadItem) => {\n      try {\n        if (!matches(item)) return;\n        if (!waitForComplete) {\n          fulfill(item);\n        }\n      } catch {}\n    };\n    const onChanged = (delta: chrome.downloads.DownloadDelta) => {\n      try {\n        if (!delta || typeof delta.id !== 'number') return;\n        // pull item and check\n        chrome.downloads\n          .search({ id: delta.id })\n          .then((arr) => {\n            const item = arr && arr[0];\n            if (!item) return;\n            if (!matches(item)) return;\n            if (waitForComplete && item.state === 'complete') fulfill(item);\n          })\n          .catch(() => {});\n      } catch {}\n    };\n    chrome.downloads.onCreated.addListener(onCreated);\n    chrome.downloads.onChanged.addListener(onChanged);\n    timer = setTimeout(() => onError(new Error('Download wait timed out')), timeoutMs);\n    // Try to find an already-running matching download\n    chrome.downloads\n      .search({ state: waitForComplete ? 'in_progress' : undefined })\n      .then((arr) => {\n        const hit = (arr || []).find((d) => matches(d));\n        if (hit && !waitForComplete) fulfill(hit);\n      })\n      .catch(() => {});\n  });\n}\n\nexport const handleDownloadTool = new HandleDownloadTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/element-picker.ts",
    "content": "/**\n * Element Picker Tool\n *\n * Implements chrome_request_element_selection - a human-in-the-loop tool that allows\n * users to manually select elements on the page when AI cannot reliably locate them.\n */\n\nimport { createErrorResponse, type ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { ERROR_MESSAGES } from '@/common/constants';\nimport {\n  TOOL_NAMES,\n  type ElementPickerRequest,\n  type ElementPickerResult,\n  type ElementPickerResultItem,\n  type PickedElement,\n} from 'chrome-mcp-shared';\n\n// ============================================================\n// Types\n// ============================================================\n\ninterface NormalizedRequest {\n  id: string;\n  name: string;\n  description?: string;\n}\n\ninterface ElementPickerToolParams {\n  requests: ElementPickerRequest[];\n  timeoutMs?: number;\n  tabId?: number;\n  windowId?: number;\n}\n\ninterface PickerUiEvent {\n  type: string;\n  sessionId: string;\n  event: 'cancel' | 'confirm' | 'set_active_request' | 'clear_selection';\n  requestId?: string;\n}\n\ninterface PickerFrameEvent {\n  type: string;\n  sessionId: string;\n  event: 'selected' | 'cancel';\n  requestId?: string;\n  element?: Omit<PickedElement, 'frameId'>;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes\nconst MAX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes\nconst MIN_TIMEOUT_MS = 10 * 1000; // 10 seconds\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\nfunction toTrimmedString(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : '';\n}\n\nfunction normalizeTimeoutMs(value: unknown): number {\n  if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS;\n  const n = Number(value);\n  if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;\n  return Math.min(Math.max(Math.floor(n), MIN_TIMEOUT_MS), MAX_TIMEOUT_MS);\n}\n\nfunction normalizeRequests(requests: ElementPickerRequest[]): NormalizedRequest[] {\n  const out: NormalizedRequest[] = [];\n  const seen = new Set<string>();\n\n  for (let i = 0; i < requests.length; i++) {\n    const r = requests[i] || ({} as ElementPickerRequest);\n    const name = toTrimmedString(r.name);\n    if (!name) continue;\n\n    // Generate or use provided ID, ensuring uniqueness\n    const baseId = toTrimmedString(r.id) || `req_${i + 1}`;\n    let id = baseId;\n    let suffix = 2;\n    while (seen.has(id)) {\n      id = `${baseId}_${suffix++}`;\n    }\n    seen.add(id);\n\n    const description = toTrimmedString(r.description);\n    out.push({ id, name, description: description || undefined });\n  }\n\n  return out;\n}\n\nfunction buildResultItems(\n  requests: NormalizedRequest[],\n  pickedById: Map<string, PickedElement>,\n): ElementPickerResultItem[] {\n  return requests.map((r) => ({\n    id: r.id,\n    name: r.name,\n    element: pickedById.get(r.id) || null,\n  }));\n}\n\nfunction listMissingRequestIds(\n  requests: NormalizedRequest[],\n  pickedById: Map<string, PickedElement>,\n): string[] {\n  const missing: string[] = [];\n  for (const r of requests) {\n    if (!pickedById.has(r.id)) missing.push(r.id);\n  }\n  return missing;\n}\n\n// ============================================================\n// Element Picker Tool\n// ============================================================\n\nclass ElementPickerTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION;\n\n  /**\n   * Inject picker scripts into all frames of the tab.\n   */\n  private async injectPickerScripts(tabId: number): Promise<void> {\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames: true },\n      files: ['inject-scripts/element-picker.js'],\n      world: 'ISOLATED',\n      injectImmediately: false,\n    } as any);\n  }\n\n  /**\n   * Call the picker API in all frames via scripting.executeScript.\n   */\n  private async callPickerApi(\n    tabId: number,\n    method: 'startSession' | 'stopSession' | 'setActiveRequest',\n    payload: Record<string, unknown>,\n  ): Promise<void> {\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames: true },\n      world: 'ISOLATED',\n      injectImmediately: false,\n      func: (methodName: string, data: Record<string, unknown>) => {\n        try {\n          const api = (\n            globalThis as unknown as {\n              __mcpElementPicker?: Record<string, (data: Record<string, unknown>) => void>;\n            }\n          ).__mcpElementPicker;\n          const fn = api && api[methodName];\n          if (typeof fn === 'function') {\n            fn(data);\n          }\n        } catch {\n          // Best-effort\n        }\n      },\n      args: [method, payload],\n    } as any);\n  }\n\n  async execute(args: ElementPickerToolParams): Promise<ToolResult> {\n    // Validate requests\n    const rawRequests = Array.isArray(args?.requests) ? args.requests : [];\n    if (rawRequests.length === 0) {\n      return createErrorResponse(`${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] is required`);\n    }\n\n    const requests = normalizeRequests(rawRequests);\n    if (requests.length === 0) {\n      return createErrorResponse(\n        `${ERROR_MESSAGES.INVALID_PARAMETERS}: requests[] must contain at least one non-empty name`,\n      );\n    }\n\n    const timeoutMs = normalizeTimeoutMs(args?.timeoutMs);\n    const sessionId = `ep_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;\n    const deadlineTs = Date.now() + timeoutMs;\n\n    // Resolve tab\n    let tab: chrome.tabs.Tab;\n    try {\n      const explicit = await this.tryGetTab(args?.tabId);\n      tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId));\n    } catch (error) {\n      return createErrorResponse(\n        `${ERROR_MESSAGES.TAB_NOT_FOUND}: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n    if (!tab.id) {\n      return createErrorResponse(`${ERROR_MESSAGES.TAB_NOT_FOUND}: Active tab has no ID`);\n    }\n    const tabId = tab.id;\n\n    // Focus the tab/window for user interaction\n    try {\n      await this.ensureFocus(tab, { activate: true, focusWindow: true });\n    } catch {\n      // Best-effort: some environments disallow focusing\n    }\n\n    // State tracking\n    const pickedById = new Map<string, PickedElement>();\n    let activeRequestId: string | null = requests[0]?.id || null;\n    let uiErrorMessage: string | null = null;\n    let uiAvailable = true;\n\n    let finished = false;\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let resolveResult: ((result: ElementPickerResult) => void) | null = null;\n\n    // Send UI update to content script\n    const sendUiUpdate = async (): Promise<void> => {\n      if (!uiAvailable) return;\n      try {\n        const selections: Record<string, PickedElement | null> = {};\n        for (const r of requests) {\n          selections[r.id] = pickedById.get(r.id) || null;\n        }\n        await this.sendMessageToTab(\n          tabId,\n          {\n            action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE,\n            sessionId,\n            activeRequestId,\n            selections,\n            deadlineTs,\n            errorMessage: uiErrorMessage,\n          },\n          0, // Top frame only for UI\n        );\n      } catch {\n        uiAvailable = false;\n      }\n    };\n\n    // Set the active request and notify all frames + UI\n    const setActiveRequest = async (requestId: string | null): Promise<void> => {\n      activeRequestId = requestId;\n      await this.callPickerApi(tabId, 'setActiveRequest', {\n        sessionId,\n        activeRequestId: requestId,\n      });\n      await sendUiUpdate();\n    };\n\n    // Finish the tool execution\n    const finish = async (final: {\n      success: boolean;\n      cancelled?: boolean;\n      timedOut?: boolean;\n    }): Promise<void> => {\n      if (finished) return;\n      finished = true;\n\n      if (timer !== null) {\n        clearTimeout(timer);\n        timer = null;\n      }\n\n      chrome.runtime.onMessage.removeListener(onRuntimeMessage);\n\n      // Cleanup: stop picker in all frames and hide UI\n      await Promise.allSettled([\n        this.callPickerApi(tabId, 'stopSession', { sessionId }),\n        uiAvailable\n          ? this.sendMessageToTab(\n              tabId,\n              { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },\n              0,\n            )\n          : Promise.resolve(),\n      ]);\n\n      const missing = listMissingRequestIds(requests, pickedById);\n      const result: ElementPickerResult = {\n        success: final.success,\n        sessionId,\n        timeoutMs,\n        cancelled: final.cancelled,\n        timedOut: final.timedOut,\n        missingRequestIds: missing.length > 0 ? missing : undefined,\n        results: buildResultItems(requests, pickedById),\n      };\n\n      resolveResult?.(result);\n    };\n\n    // Handle messages from content scripts\n    const onRuntimeMessage = (\n      message: unknown,\n      sender: chrome.runtime.MessageSender,\n      sendResponse: (response?: unknown) => void,\n    ): boolean | void => {\n      const senderTabId = sender?.tab?.id;\n      if (senderTabId !== tabId) return;\n\n      const msg = message as Partial<PickerUiEvent & PickerFrameEvent> | undefined;\n      if (!msg || msg.sessionId !== sessionId) return;\n\n      // Handle frame events (element selection)\n      if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_FRAME_EVENT) {\n        if (msg.event === 'cancel') {\n          void finish({ success: false, cancelled: true });\n          sendResponse?.({ success: true });\n          return true;\n        }\n\n        if (msg.event === 'selected') {\n          const requestId = toTrimmedString(msg.requestId);\n          const frameId = typeof sender.frameId === 'number' ? sender.frameId : 0;\n\n          // Validate request ID\n          const reqExists = requestId && requests.some((r) => r.id === requestId);\n          if (!reqExists) {\n            sendResponse?.({ success: false, error: 'Unknown requestId' });\n            return true;\n          }\n\n          // Validate element data\n          const raw = (msg.element || {}) as Partial<Omit<PickedElement, 'frameId'>>;\n          const ref = toTrimmedString(raw.ref);\n          if (!ref) {\n            sendResponse?.({ success: false, error: 'Missing element.ref' });\n            return true;\n          }\n\n          // Build picked element with frameId\n          const selector = toTrimmedString(raw.selector);\n          const rect = raw.rect as PickedElement['rect'] | undefined;\n          const center = raw.center as PickedElement['center'] | undefined;\n          const picked: PickedElement = {\n            ref,\n            selector,\n            selectorType: 'css',\n            rect: rect && typeof rect === 'object' ? rect : { x: 0, y: 0, width: 0, height: 0 },\n            center: center && typeof center === 'object' ? center : { x: 0, y: 0 },\n            text: typeof raw.text === 'string' ? raw.text : undefined,\n            tagName: typeof raw.tagName === 'string' ? raw.tagName : undefined,\n            frameId,\n          };\n\n          pickedById.set(requestId, picked);\n          uiErrorMessage = null;\n\n          // Auto-advance to next missing request\n          const missing = listMissingRequestIds(requests, pickedById);\n          const next = missing.length > 0 ? missing[0] : null;\n\n          void (async () => {\n            try {\n              if (next) {\n                await setActiveRequest(next);\n              } else {\n                // All selected: update UI (user still needs to confirm)\n                await sendUiUpdate();\n                // If UI is unavailable, auto-confirm\n                if (!uiAvailable) {\n                  await finish({ success: true });\n                }\n              }\n            } catch {\n              // Best-effort\n            }\n          })();\n\n          sendResponse?.({ success: true });\n          return true;\n        }\n      }\n\n      // Handle UI events (cancel, confirm, etc.)\n      if (msg.type === BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT) {\n        if (msg.event === 'cancel') {\n          void finish({ success: false, cancelled: true });\n          sendResponse?.({ success: true });\n          return true;\n        }\n\n        if (msg.event === 'confirm') {\n          const missing = listMissingRequestIds(requests, pickedById);\n          if (missing.length > 0) {\n            uiErrorMessage = `Please select all elements: missing ${missing.join(', ')}`;\n            void sendUiUpdate();\n            sendResponse?.({ success: false, error: 'missing_selections', missing });\n            return true;\n          }\n          void finish({ success: true });\n          sendResponse?.({ success: true });\n          return true;\n        }\n\n        if (msg.event === 'set_active_request') {\n          const requestId = toTrimmedString(msg.requestId);\n          if (!requestId || !requests.some((r) => r.id === requestId)) {\n            sendResponse?.({ success: false, error: 'Unknown requestId' });\n            return true;\n          }\n          void setActiveRequest(requestId);\n          sendResponse?.({ success: true });\n          return true;\n        }\n\n        if (msg.event === 'clear_selection') {\n          const requestId = toTrimmedString(msg.requestId);\n          if (!requestId || !requests.some((r) => r.id === requestId)) {\n            sendResponse?.({ success: false, error: 'Unknown requestId' });\n            return true;\n          }\n          pickedById.delete(requestId);\n          uiErrorMessage = null;\n          void setActiveRequest(requestId);\n          sendResponse?.({ success: true });\n          return true;\n        }\n      }\n\n      return;\n    };\n\n    try {\n      // Step 1: Ensure UI content script is ready (ping + inject fallback)\n      const ensureUiReady = async (): Promise<boolean> => {\n        // Try to ping UI content script with retries\n        const pingWithTimeout = async (timeoutMs = 500): Promise<boolean> => {\n          try {\n            const resp = await Promise.race([\n              this.sendMessageToTab(\n                tabId,\n                { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING },\n                0,\n              ),\n              new Promise<null>((_, reject) =>\n                setTimeout(() => reject(new Error('Ping timeout')), timeoutMs),\n              ),\n            ]);\n            return resp?.success === true;\n          } catch {\n            return false;\n          }\n        };\n\n        // First ping attempt (content script may already be loaded)\n        if (await pingWithTimeout()) return true;\n\n        // Try to inject UI content script as fallback\n        // Try multiple possible paths (production vs dev builds)\n        const possiblePaths = ['content-scripts/element-picker.js', 'element-picker.js'];\n\n        for (const path of possiblePaths) {\n          try {\n            await chrome.scripting.executeScript({\n              target: { tabId, frameIds: [0] },\n              files: [path],\n              injectImmediately: true,\n            } as any);\n            // Wait a bit for script to initialize\n            await new Promise((r) => setTimeout(r, 150));\n            // Check if injection worked\n            if (await pingWithTimeout(300)) return true;\n          } catch (e) {\n            // Try next path\n            console.debug(`[ElementPicker] Path ${path} failed:`, e);\n          }\n        }\n\n        // Final attempt with longer timeout (in case of slow page)\n        return pingWithTimeout(1000);\n      };\n\n      const uiReady = await ensureUiReady();\n      if (!uiReady) {\n        console.error('[ElementPicker] UI not available after all attempts');\n        return createErrorResponse(\n          `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Element Picker UI is not available. This may happen if: (1) The page blocks content scripts, (2) You're using dev mode - try restarting the dev server or use production build, (3) The page needs to be refreshed.`,\n        );\n      }\n\n      // Step 2: Show UI in top frame (must receive success:true)\n      try {\n        const showResp = await this.sendMessageToTab(\n          tabId,\n          {\n            action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW,\n            sessionId,\n            requests,\n            activeRequestId,\n            deadlineTs,\n          },\n          0,\n        );\n        if (showResp?.success !== true) {\n          throw new Error('UI did not acknowledge show message');\n        }\n      } catch (e) {\n        console.error('[ElementPicker] UI show failed:', e);\n        return createErrorResponse(\n          `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: Failed to show Element Picker UI. Please refresh the page and try again.`,\n        );\n      }\n\n      // Step 3: Inject picker scripts and start selection engine in all frames\n      await this.injectPickerScripts(tabId);\n      await this.callPickerApi(tabId, 'startSession', { sessionId, activeRequestId });\n\n      // Register message listener\n      chrome.runtime.onMessage.addListener(onRuntimeMessage);\n\n      // Create result promise\n      const resultPromise = new Promise<ElementPickerResult>((resolve) => {\n        resolveResult = resolve;\n      });\n\n      // Set timeout\n      timer = setTimeout(() => {\n        void finish({ success: false, timedOut: true });\n      }, timeoutMs);\n\n      // Initial UI update\n      void sendUiUpdate();\n\n      // Wait for result\n      const result = await resultPromise;\n      return { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };\n    } catch (error) {\n      console.error('Error in element picker tool:', error);\n      // Cleanup on error\n      try {\n        await Promise.allSettled([\n          this.callPickerApi(tabId, 'stopSession', { sessionId }),\n          this.sendMessageToTab(\n            tabId,\n            { action: TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE, sessionId },\n            0,\n          ),\n        ]);\n      } catch {\n        // Best-effort cleanup\n      }\n      return createErrorResponse(\n        `${ERROR_MESSAGES.TOOL_EXECUTION_FAILED}: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const elementPickerTool = new ElementPickerTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/file-upload.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\n\ninterface FileUploadToolParams {\n  selector: string; // CSS selector for the file input element\n  filePath?: string; // Local file path\n  fileUrl?: string; // URL to download file from\n  base64Data?: string; // Base64 encoded file data\n  fileName?: string; // Optional filename when using base64 or URL\n  multiple?: boolean; // Whether to allow multiple files\n  tabId?: number; // Target existing tab id\n  windowId?: number; // When no tabId, pick active tab from this window\n}\n\n/**\n * Tool for uploading files to web forms using Chrome DevTools Protocol\n * Similar to Playwright's setInputFiles implementation\n */\nclass FileUploadTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.FILE_UPLOAD;\n  constructor() {\n    super();\n  }\n\n  /**\n   * Execute file upload operation using Chrome DevTools Protocol\n   */\n  async execute(args: FileUploadToolParams): Promise<ToolResult> {\n    const { selector, filePath, fileUrl, base64Data, fileName, multiple = false } = args;\n\n    console.log(`Starting file upload operation with options:`, args);\n\n    // Validate input\n    if (!selector) {\n      return createErrorResponse('Selector is required for file upload');\n    }\n\n    if (!filePath && !fileUrl && !base64Data) {\n      return createErrorResponse('One of filePath, fileUrl, or base64Data must be provided');\n    }\n\n    try {\n      // Resolve tab\n      const explicit = await this.tryGetTab(args.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n      if (!tab.id) return createErrorResponse('No active tab found');\n      const tabId = tab.id;\n\n      // Prepare file paths\n      let files: string[] = [];\n\n      if (filePath) {\n        // Direct file path provided\n        files = [filePath];\n      } else if (fileUrl || base64Data) {\n        // For URL or base64, we need to use the native messaging host\n        // to download or save the file temporarily\n        const tempFilePath = await this.prepareFileFromRemote({\n          fileUrl,\n          base64Data,\n          fileName: fileName || 'uploaded-file',\n        });\n        if (!tempFilePath) {\n          return createErrorResponse('Failed to prepare file for upload');\n        }\n        files = [tempFilePath];\n      }\n\n      // Use shared CDP session manager to attach/do work/detach safely\n      await cdpSessionManager.withSession(tabId, 'file-upload', async () => {\n        // Enable necessary CDP domains\n        await cdpSessionManager.sendCommand(tabId, 'DOM.enable', {});\n        await cdpSessionManager.sendCommand(tabId, 'Runtime.enable', {});\n\n        // Get the document\n        const { root } = (await cdpSessionManager.sendCommand(tabId, 'DOM.getDocument', {\n          depth: -1,\n          pierce: true,\n        })) as { root: { nodeId: number } };\n\n        // Find the file input element using the selector\n        const { nodeId } = (await cdpSessionManager.sendCommand(tabId, 'DOM.querySelector', {\n          nodeId: root.nodeId,\n          selector: selector,\n        })) as { nodeId: number };\n\n        if (!nodeId || nodeId === 0) {\n          throw new Error(`Element with selector \"${selector}\" not found`);\n        }\n\n        // Verify it's actually a file input\n        const { node } = (await cdpSessionManager.sendCommand(tabId, 'DOM.describeNode', {\n          nodeId,\n        })) as { node: { nodeName: string; attributes?: string[] } };\n\n        if (node.nodeName !== 'INPUT') {\n          throw new Error(`Element with selector \"${selector}\" is not an input element`);\n        }\n\n        // Check if it's a file input by looking for type=\"file\" in attributes\n        const attributes = node.attributes || [];\n        let isFileInput = false;\n        for (let i = 0; i < attributes.length; i += 2) {\n          if (attributes[i] === 'type' && attributes[i + 1] === 'file') {\n            isFileInput = true;\n            break;\n          }\n        }\n\n        if (!isFileInput) {\n          throw new Error(`Element with selector \"${selector}\" is not a file input (type=\"file\")`);\n        }\n\n        // Set the files on the input element\n        await cdpSessionManager.sendCommand(tabId, 'DOM.setFileInputFiles', {\n          nodeId,\n          files,\n        });\n\n        // Trigger change event to ensure the page reacts to the file upload\n        await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', {\n          expression: `\n            (function() {\n              const element = document.querySelector('${selector.replace(/'/g, \"\\\\'\")}');\n              if (element) {\n                const event = new Event('change', { bubbles: true });\n                element.dispatchEvent(event);\n                return true;\n              }\n              return false;\n            })()\n          `,\n        });\n      });\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: 'File(s) uploaded successfully',\n              files: files,\n              selector: selector,\n              fileCount: files.length,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in file upload operation:', error);\n\n      // Session manager handles detach; nothing extra needed here\n\n      return createErrorResponse(\n        `Error uploading file: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  // All debugger attach/detach is centrally managed by cdpSessionManager\n\n  /**\n   * Prepare file from URL or base64 data using native messaging host\n   */\n  private async prepareFileFromRemote(options: {\n    fileUrl?: string;\n    base64Data?: string;\n    fileName: string;\n  }): Promise<string | null> {\n    const { fileUrl, base64Data, fileName } = options;\n\n    return new Promise((resolve) => {\n      const requestId = `file-upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;\n      const timeout = setTimeout(() => {\n        console.error('File preparation request timed out');\n        resolve(null);\n      }, 30000); // 30 second timeout\n\n      // Create listener for the response\n      const handleMessage = (message: any) => {\n        if (\n          message.type === 'file_operation_response' &&\n          message.responseToRequestId === requestId\n        ) {\n          clearTimeout(timeout);\n          chrome.runtime.onMessage.removeListener(handleMessage);\n\n          if (message.payload?.success && message.payload?.filePath) {\n            resolve(message.payload.filePath);\n          } else {\n            console.error(\n              'Native host failed to prepare file:',\n              message.error || message.payload?.error,\n            );\n            resolve(null);\n          }\n        }\n      };\n\n      // Add listener\n      chrome.runtime.onMessage.addListener(handleMessage);\n\n      // Send message to background script to forward to native host\n      chrome.runtime\n        .sendMessage({\n          type: 'forward_to_native',\n          message: {\n            type: 'file_operation',\n            requestId: requestId,\n            payload: {\n              action: 'prepareFile',\n              fileUrl,\n              base64Data,\n              fileName,\n            },\n          },\n        })\n        .catch((error) => {\n          console.error('Error sending message to background:', error);\n          clearTimeout(timeout);\n          chrome.runtime.onMessage.removeListener(handleMessage);\n          resolve(null);\n        });\n    });\n  }\n}\n\nexport const fileUploadTool = new FileUploadTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/gif-auto-capture.ts",
    "content": "/**\n * GIF Auto-Capture Hook System\n *\n * Provides automatic frame capture for GIF recording when browser actions succeed.\n * Tools like chrome_computer and chrome_navigate can trigger frame captures\n * after successful operations, creating smooth recordings of user interactions.\n *\n * Architecture:\n * - Centralized capture manager with per-tab recording state\n * - Hooks can be registered/unregistered per tab\n * - Configurable capture delay for UI stabilization\n * - Enhanced rendering overlays (click indicators, drag paths, labels)\n */\n\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport { OFFSCREEN_MESSAGE_TYPES, MessageTarget } from '@/common/message-types';\nimport { offscreenManager } from '@/utils/offscreen-manager';\nimport { createImageBitmapFromUrl } from '@/utils/image-utils';\nimport {\n  pruneActionEventsInPlace,\n  renderGifEnhancedOverlays,\n  resolveCapturePlanForAction,\n  resolveGifEnhancedRenderingConfig,\n  type ActionEvent,\n  type ActionMetadata,\n  type ActionType,\n  type GifEnhancedRenderingConfig,\n  type ResolvedGifEnhancedRenderingConfig,\n} from './gif-enhanced-renderer';\n\n// Re-export types for consumers\nexport type {\n  ActionMetadata,\n  ActionType,\n  GifEnhancedRenderingConfig,\n} from './gif-enhanced-renderer';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst CDP_SESSION_KEY = 'gif-auto-capture';\nconst DEFAULT_CAPTURE_DELAY_MS = 150;\nconst DEFAULT_WIDTH = 800;\nconst DEFAULT_HEIGHT = 600;\nconst DEFAULT_FRAME_DELAY_CS = 20; // 20 centiseconds = 200ms per frame\nconst DEFAULT_MAX_COLORS = 256;\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AutoCaptureConfig {\n  width: number;\n  height: number;\n  maxColors: number;\n  frameDelayCs: number;\n  captureDelayMs: number;\n  maxFrames: number;\n  enhancedRendering?: GifEnhancedRenderingConfig;\n}\n\ninterface TabCaptureState {\n  tabId: number;\n  config: AutoCaptureConfig;\n  rendering: ResolvedGifEnhancedRenderingConfig;\n  frameCount: number;\n  startTime: number;\n  canvas: OffscreenCanvas;\n  ctx: OffscreenCanvasRenderingContext2D;\n  pendingCapture: Promise<void> | null;\n  actions: ActionMetadata[];\n  actionEvents: ActionEvent[];\n  lastViewportWidth: number;\n  lastViewportHeight: number;\n}\n\n// ============================================================================\n// State Management\n// ============================================================================\n\nconst tabStates = new Map<number, TabCaptureState>();\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction normalizeActionMetadata(action: ActionMetadata, atMs: number): ActionMetadata {\n  const normalized: ActionMetadata = {\n    ...action,\n    timestampMs: atMs,\n    coordinateSpace: action.coordinateSpace ?? 'viewport',\n  };\n\n  // For drag, treat `coordinates` as end position (legacy) and also populate `endCoordinates`\n  if (normalized.type === 'drag') {\n    const end = normalized.endCoordinates ?? normalized.coordinates;\n    if (end) {\n      normalized.endCoordinates = end;\n      normalized.coordinates = end;\n    }\n  }\n\n  return normalized;\n}\n\n// ============================================================================\n// Offscreen Communication\n// ============================================================================\n\nasync function sendToOffscreen<T extends { success: boolean; error?: string }>(\n  type: string,\n  payload: Record<string, unknown> = {},\n): Promise<T> {\n  await offscreenManager.ensureOffscreenDocument();\n\n  const response = (await chrome.runtime.sendMessage({\n    target: MessageTarget.Offscreen,\n    type,\n    ...payload,\n  })) as T | undefined;\n\n  if (!response) {\n    throw new Error('No response from offscreen document');\n  }\n  if (!response.success) {\n    throw new Error(response.error || 'Unknown offscreen error');\n  }\n\n  return response;\n}\n\n// ============================================================================\n// Frame Capture\n// ============================================================================\n\nasync function captureFrameData(tabId: number, state: TabCaptureState): Promise<Uint8ClampedArray> {\n  const width = state.config.width;\n  const height = state.config.height;\n  const ctx = state.ctx;\n\n  // Get viewport metrics\n  const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } =\n    await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {});\n\n  const viewportWidth = metrics.layoutViewport?.clientWidth || width;\n  const viewportHeight = metrics.layoutViewport?.clientHeight || height;\n\n  // Store viewport dimensions for coordinate projection\n  state.lastViewportWidth = viewportWidth;\n  state.lastViewportHeight = viewportHeight;\n\n  // Capture screenshot\n  const screenshot: { data: string } = await cdpSessionManager.sendCommand(\n    tabId,\n    'Page.captureScreenshot',\n    {\n      format: 'png',\n      clip: {\n        x: 0,\n        y: 0,\n        width: viewportWidth,\n        height: viewportHeight,\n        scale: 1,\n      },\n    },\n  );\n\n  const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`);\n\n  // Scale to target dimensions\n  ctx.clearRect(0, 0, width, height);\n  ctx.drawImage(imageBitmap, 0, 0, width, height);\n  imageBitmap.close();\n\n  // Apply enhanced rendering overlays\n  if (state.rendering.enabled) {\n    const nowMs = Date.now();\n    renderGifEnhancedOverlays({\n      ctx,\n      outputWidth: width,\n      outputHeight: height,\n      viewportWidth,\n      viewportHeight,\n      nowMs,\n      events: state.actionEvents,\n      config: state.rendering,\n    });\n    pruneActionEventsInPlace(state.actionEvents, nowMs, state.rendering);\n  }\n\n  return ctx.getImageData(0, 0, width, height).data;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Start auto-capture for a tab. This initializes the GIF encoder\n * and prepares for automatic frame capture on tool actions.\n */\nexport async function startAutoCapture(\n  tabId: number,\n  config?: Partial<AutoCaptureConfig>,\n): Promise<{ success: boolean; error?: string }> {\n  if (tabStates.has(tabId)) {\n    return { success: false, error: 'Auto-capture already active for this tab' };\n  }\n\n  const finalConfig: AutoCaptureConfig = {\n    width: config?.width ?? DEFAULT_WIDTH,\n    height: config?.height ?? DEFAULT_HEIGHT,\n    maxColors: config?.maxColors ?? DEFAULT_MAX_COLORS,\n    frameDelayCs: config?.frameDelayCs ?? DEFAULT_FRAME_DELAY_CS,\n    captureDelayMs: config?.captureDelayMs ?? DEFAULT_CAPTURE_DELAY_MS,\n    maxFrames: config?.maxFrames ?? 100,\n    enhancedRendering: config?.enhancedRendering,\n  };\n\n  try {\n    // Attach CDP session\n    await cdpSessionManager.attach(tabId, CDP_SESSION_KEY);\n\n    // Reset offscreen encoder\n    await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});\n\n    // Create canvas\n    if (typeof OffscreenCanvas === 'undefined') {\n      throw new Error('OffscreenCanvas not available');\n    }\n\n    const canvas = new OffscreenCanvas(finalConfig.width, finalConfig.height);\n    const ctx = canvas.getContext('2d');\n    if (!ctx) {\n      throw new Error('Failed to get canvas context');\n    }\n\n    const state: TabCaptureState = {\n      tabId,\n      config: finalConfig,\n      rendering: resolveGifEnhancedRenderingConfig(finalConfig.enhancedRendering),\n      frameCount: 0,\n      startTime: Date.now(),\n      canvas,\n      ctx,\n      pendingCapture: null,\n      actions: [],\n      actionEvents: [],\n      lastViewportWidth: finalConfig.width,\n      lastViewportHeight: finalConfig.height,\n    };\n\n    tabStates.set(tabId, state);\n\n    return { success: true };\n  } catch (error) {\n    // Cleanup on failure\n    try {\n      await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);\n    } catch {\n      // Ignore\n    }\n\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : String(error),\n    };\n  }\n}\n\n/**\n * Stop auto-capture and finalize the GIF.\n * Returns the GIF data for saving/downloading.\n */\nexport async function stopAutoCapture(tabId: number): Promise<{\n  success: boolean;\n  gifData?: Uint8Array;\n  frameCount?: number;\n  durationMs?: number;\n  actions?: ActionMetadata[];\n  error?: string;\n}> {\n  const state = tabStates.get(tabId);\n  if (!state) {\n    return { success: false, error: 'No auto-capture active for this tab' };\n  }\n\n  try {\n    // Wait for any pending capture\n    if (state.pendingCapture) {\n      await state.pendingCapture;\n    }\n\n    const frameCount = state.frameCount;\n    const durationMs = Date.now() - state.startTime;\n    const actions = [...state.actions];\n\n    if (frameCount === 0) {\n      return {\n        success: false,\n        error: 'No frames captured',\n        frameCount: 0,\n        durationMs,\n        actions,\n      };\n    }\n\n    // Finalize GIF\n    const response = await sendToOffscreen<{\n      success: boolean;\n      gifData?: number[];\n      byteLength?: number;\n      error?: string;\n    }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {});\n\n    if (!response.gifData || response.gifData.length === 0) {\n      return {\n        success: false,\n        error: 'Failed to encode GIF',\n        frameCount,\n        durationMs,\n        actions,\n      };\n    }\n\n    return {\n      success: true,\n      gifData: new Uint8Array(response.gifData),\n      frameCount,\n      durationMs,\n      actions,\n    };\n  } catch (error) {\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : String(error),\n    };\n  } finally {\n    // Cleanup\n    tabStates.delete(tabId);\n    try {\n      await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);\n    } catch {\n      // Ignore\n    }\n  }\n}\n\n/**\n * Check if auto-capture is active for a tab.\n */\nexport function isAutoCaptureActive(tabId: number): boolean {\n  return tabStates.has(tabId);\n}\n\n/**\n * Get current auto-capture status for a tab.\n */\nexport function getAutoCaptureStatus(tabId: number): {\n  active: boolean;\n  frameCount?: number;\n  durationMs?: number;\n  actionsCount?: number;\n  enhancedRenderingEnabled?: boolean;\n} {\n  const state = tabStates.get(tabId);\n  if (!state) {\n    return { active: false };\n  }\n\n  return {\n    active: true,\n    frameCount: state.frameCount,\n    durationMs: Date.now() - state.startTime,\n    actionsCount: state.actions.length,\n    enhancedRenderingEnabled: state.rendering.enabled,\n  };\n}\n\n/**\n * Trigger a frame capture after a successful action.\n * This is the main hook that tools should call.\n *\n * @param tabId - The tab to capture\n * @param action - Optional action metadata for overlay rendering\n * @param immediate - If true, capture immediately without delay\n */\nexport async function captureFrameOnAction(\n  tabId: number,\n  action?: ActionMetadata,\n  immediate = false,\n): Promise<{ success: boolean; frameNumber?: number; error?: string }> {\n  const state = tabStates.get(tabId);\n  if (!state) {\n    // No auto-capture active - silently succeed (tools shouldn't fail because recording isn't active)\n    return { success: true };\n  }\n\n  // Check frame limit\n  if (state.frameCount >= state.config.maxFrames) {\n    return { success: false, error: 'Max frame limit reached' };\n  }\n\n  // Wait for any pending capture to complete\n  if (state.pendingCapture) {\n    try {\n      await state.pendingCapture;\n    } catch {\n      // Ignore errors from previous capture\n    }\n  }\n\n  // Verify state still exists (might have been stopped while awaiting)\n  const currentState = tabStates.get(tabId);\n  if (!currentState) {\n    return { success: true };\n  }\n\n  // Calculate delay for UI stabilization\n  const delayMs = immediate ? 0 : currentState.config.captureDelayMs;\n\n  // Normalize and record action metadata\n  let normalizedAction: ActionMetadata | undefined;\n  if (action) {\n    const atMs = Date.now() + delayMs;\n    normalizedAction = normalizeActionMetadata(action, atMs);\n    currentState.actions.push(normalizedAction);\n    currentState.actionEvents.push({ action: normalizedAction, atMs });\n  }\n\n  // Determine capture plan (may involve multiple frames for click animations)\n  const plan = resolveCapturePlanForAction(\n    currentState.rendering,\n    normalizedAction,\n    currentState.config.frameDelayCs,\n  );\n\n  const capturePromise = (async () => {\n    if (delayMs > 0) await sleep(delayMs);\n\n    for (let i = 0; i < plan.frames; i++) {\n      const activeState = tabStates.get(tabId);\n      if (!activeState) return;\n\n      if (activeState.frameCount >= activeState.config.maxFrames) return;\n\n      try {\n        const frameData = await captureFrameData(tabId, activeState);\n\n        // Use animation delay for intermediate frames, regular delay for final frame\n        const delayCs = i < plan.frames - 1 ? plan.delayCs : activeState.config.frameDelayCs;\n\n        await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {\n          imageData: Array.from(frameData),\n          width: activeState.config.width,\n          height: activeState.config.height,\n          delay: delayCs,\n          maxColors: activeState.config.maxColors,\n        });\n\n        activeState.frameCount += 1;\n      } catch (error) {\n        console.error('[GIF Auto-Capture] Frame capture failed:', error);\n        return;\n      }\n\n      // Wait between animation frames\n      if (i < plan.frames - 1 && plan.intervalMs > 0) {\n        await sleep(plan.intervalMs);\n      }\n    }\n  })();\n\n  state.pendingCapture = capturePromise;\n\n  try {\n    await capturePromise;\n    return { success: true, frameNumber: state.frameCount };\n  } catch (error) {\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : String(error),\n    };\n  } finally {\n    // Clean up reference to avoid holding completed Promise\n    const currentState = tabStates.get(tabId);\n    if (currentState?.pendingCapture === capturePromise) {\n      currentState.pendingCapture = null;\n    }\n  }\n}\n\n/**\n * Capture an initial frame immediately (useful for recording start state).\n */\nexport async function captureInitialFrame(\n  tabId: number,\n): Promise<{ success: boolean; error?: string }> {\n  return captureFrameOnAction(tabId, undefined, true);\n}\n\n/**\n * Clear all auto-capture state (useful for cleanup).\n */\nexport async function clearAllAutoCapture(): Promise<void> {\n  const tabIds = Array.from(tabStates.keys());\n  for (const tabId of tabIds) {\n    try {\n      await stopAutoCapture(tabId);\n    } catch {\n      // Ignore errors during cleanup\n      tabStates.delete(tabId);\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/gif-enhanced-renderer.ts",
    "content": "/**\n * GIF Enhanced Renderer\n *\n * Draws visual affordances (click indicators, drag paths, labels) onto a canvas\n * before encoding frames. This keeps the offscreen document focused on encoding\n * while the background capture pipeline handles compositing.\n *\n * Coordinates are expected to be in viewport CSS pixels. If a caller provides\n * screenshot-space coordinates, it should convert them to viewport space first.\n */\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type ActionType =\n  | 'click'\n  | 'double_click'\n  | 'triple_click'\n  | 'right_click'\n  | 'drag'\n  | 'scroll'\n  | 'type'\n  | 'key'\n  | 'navigate'\n  | 'hover'\n  | 'fill'\n  | 'annotation'\n  | 'other';\n\nexport type CoordinateSpace = 'viewport' | 'screenshot';\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport interface ActionMetadata {\n  type: ActionType;\n  coordinates?: Point;\n  startCoordinates?: Point;\n  endCoordinates?: Point;\n  text?: string;\n  url?: string;\n  ref?: string;\n\n  // Enhanced rendering hints\n  label?: string;\n  coordinateSpace?: CoordinateSpace;\n  timestampMs?: number;\n}\n\nexport interface GifEnhancedRenderingConfig {\n  enabled?: boolean;\n\n  clickIndicators?: {\n    enabled?: boolean;\n    color?: string;\n    fillColor?: string;\n    radiusPx?: number;\n    lineWidthPx?: number;\n    durationMs?: number;\n    // Capture-side animation hints (auto-capture mode only)\n    animationFrames?: number;\n    animationIntervalMs?: number;\n    animationFrameDelayCs?: number;\n  };\n\n  dragPaths?: {\n    enabled?: boolean;\n    color?: string;\n    lineWidthPx?: number;\n    durationMs?: number;\n    arrowSizePx?: number;\n    dash?: number[];\n    startDotRadiusPx?: number;\n    endDotRadiusPx?: number;\n  };\n\n  labels?: {\n    enabled?: boolean;\n    mode?: 'action' | 'annotation' | 'both';\n    showForClicks?: boolean;\n    font?: string;\n    maxLength?: number;\n    durationMs?: number;\n    backgroundColor?: string;\n    borderColor?: string;\n    textColor?: string;\n    paddingX?: number;\n    paddingY?: number;\n    radiusPx?: number;\n    offsetPx?: number;\n  };\n}\n\n// ============================================================================\n// Resolved Config Types\n// ============================================================================\n\nexport interface ResolvedClickIndicatorConfig {\n  enabled: boolean;\n  color: string;\n  fillColor: string;\n  radiusPx: number;\n  lineWidthPx: number;\n  durationMs: number;\n  animationFrames: number;\n  animationIntervalMs: number;\n  animationFrameDelayCs: number;\n}\n\nexport interface ResolvedDragPathConfig {\n  enabled: boolean;\n  color: string;\n  lineWidthPx: number;\n  durationMs: number;\n  arrowSizePx: number;\n  dash: number[];\n  startDotRadiusPx: number;\n  endDotRadiusPx: number;\n}\n\nexport interface ResolvedLabelsConfig {\n  enabled: boolean;\n  mode: 'action' | 'annotation' | 'both';\n  showForClicks: boolean;\n  font: string;\n  maxLength: number;\n  durationMs: number;\n  backgroundColor: string;\n  borderColor: string;\n  textColor: string;\n  paddingX: number;\n  paddingY: number;\n  radiusPx: number;\n  offsetPx: number;\n}\n\nexport interface ResolvedGifEnhancedRenderingConfig {\n  enabled: boolean;\n  clickIndicators: ResolvedClickIndicatorConfig;\n  dragPaths: ResolvedDragPathConfig;\n  labels: ResolvedLabelsConfig;\n}\n\nexport interface ActionEvent {\n  action: ActionMetadata;\n  atMs: number;\n}\n\nexport interface CapturePlan {\n  frames: number;\n  intervalMs: number;\n  delayCs: number;\n}\n\nexport interface RenderGifEnhancedOverlaysParams {\n  ctx: OffscreenCanvasRenderingContext2D;\n  outputWidth: number;\n  outputHeight: number;\n  viewportWidth: number;\n  viewportHeight: number;\n  nowMs: number;\n  events: readonly ActionEvent[];\n  config: ResolvedGifEnhancedRenderingConfig;\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst CLICK_ACTIONS: readonly ActionType[] = [\n  'click',\n  'double_click',\n  'triple_click',\n  'right_click',\n];\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction normalizePositiveNumber(\n  value: unknown,\n  fallback: number,\n  min: number,\n  max: number,\n): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;\n  return clamp(value, min, max);\n}\n\nfunction normalizePositiveInt(value: unknown, fallback: number, min: number, max: number): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;\n  return clamp(Math.floor(value), min, max);\n}\n\nfunction normalizeDash(value: unknown, fallback: number[]): number[] {\n  if (!Array.isArray(value)) return fallback;\n  const nums = value.filter((n) => typeof n === 'number' && Number.isFinite(n) && n > 0);\n  return nums.length >= 2 ? (nums as number[]) : fallback;\n}\n\nfunction easeOutCubic(t: number): number {\n  const x = clamp(t, 0, 1);\n  return 1 - Math.pow(1 - x, 3);\n}\n\nfunction projectPoint(\n  point: Point,\n  viewportWidth: number,\n  viewportHeight: number,\n  outputWidth: number,\n  outputHeight: number,\n): Point | null {\n  if (\n    typeof point.x !== 'number' ||\n    typeof point.y !== 'number' ||\n    !Number.isFinite(point.x) ||\n    !Number.isFinite(point.y)\n  ) {\n    return null;\n  }\n\n  const vw = viewportWidth > 0 ? viewportWidth : outputWidth;\n  const vh = viewportHeight > 0 ? viewportHeight : outputHeight;\n\n  return {\n    x: (point.x / vw) * outputWidth,\n    y: (point.y / vh) * outputHeight,\n  };\n}\n\nfunction buildRoundedRectPath(\n  ctx: OffscreenCanvasRenderingContext2D,\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  radius: number,\n): void {\n  const r = Math.max(0, Math.min(radius, Math.min(width, height) / 2));\n  const x2 = x + width;\n  const y2 = y + height;\n\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x2, y, x2, y2, r);\n  ctx.arcTo(x2, y2, x, y2, r);\n  ctx.arcTo(x, y2, x, y, r);\n  ctx.arcTo(x, y, x2, y, r);\n}\n\nfunction truncate(text: string, maxLength: number): string {\n  const trimmed = text.trim();\n  if (trimmed.length <= maxLength) return trimmed;\n  return `${trimmed.slice(0, Math.max(0, maxLength - 1))}…`;\n}\n\n// ============================================================================\n// Label Resolution\n// ============================================================================\n\nfunction resolveActionLabel(action: ActionMetadata, cfg: ResolvedLabelsConfig): string | null {\n  const explicit = typeof action.label === 'string' ? action.label.trim() : '';\n  const isExplicit = explicit.length > 0;\n\n  const mode = cfg.mode;\n  const canShowAction = mode === 'action' || mode === 'both';\n  const canShowAnnotation = mode === 'annotation' || mode === 'both';\n\n  if ((action.type === 'annotation' || isExplicit) && canShowAnnotation) {\n    const labelText = explicit || (typeof action.text === 'string' ? action.text.trim() : '');\n    return labelText.length > 0 ? truncate(labelText, cfg.maxLength) : null;\n  }\n\n  if (!canShowAction) return null;\n\n  switch (action.type) {\n    case 'click':\n    case 'double_click':\n    case 'triple_click':\n    case 'right_click':\n      if (!cfg.showForClicks) return null;\n      return action.type.replace('_', ' ').toUpperCase();\n    case 'drag':\n      return 'DRAG';\n    case 'scroll':\n      return 'SCROLL';\n    case 'hover':\n      return 'HOVER';\n    case 'navigate': {\n      if (!action.url) return 'NAVIGATE';\n      try {\n        const host = new URL(action.url).hostname;\n        return host ? `→ ${host}` : 'NAVIGATE';\n      } catch {\n        return 'NAVIGATE';\n      }\n    }\n    case 'type': {\n      const content = typeof action.text === 'string' ? action.text : '';\n      return content.trim().length > 0 ? `TYPE \"${truncate(content, cfg.maxLength)}\"` : 'TYPE';\n    }\n    case 'key': {\n      const content = typeof action.text === 'string' ? action.text : '';\n      return content.trim().length > 0 ? `KEY [${truncate(content, cfg.maxLength)}]` : 'KEY';\n    }\n    case 'fill': {\n      const content = typeof action.text === 'string' ? action.text : '';\n      return content.trim().length > 0 ? `FILL \"${truncate(content, cfg.maxLength)}\"` : 'FILL';\n    }\n    default:\n      return null;\n  }\n}\n\nfunction resolveAnchorPoint(action: ActionMetadata): Point | null {\n  if (action.type === 'drag') {\n    return action.endCoordinates || action.coordinates || action.startCoordinates || null;\n  }\n  return action.coordinates || action.endCoordinates || action.startCoordinates || null;\n}\n\n// ============================================================================\n// Drawing Functions\n// ============================================================================\n\nfunction drawClickIndicator(\n  ctx: OffscreenCanvasRenderingContext2D,\n  x: number,\n  y: number,\n  progress: number,\n  type: ActionType,\n  cfg: ResolvedClickIndicatorConfig,\n): void {\n  const t = clamp(progress, 0, 1);\n  const eased = easeOutCubic(t);\n\n  const base = cfg.radiusPx;\n  const radius = base * (0.35 + 0.95 * eased);\n  const alpha = 1 - eased;\n\n  ctx.save();\n  ctx.globalAlpha = alpha;\n\n  ctx.lineWidth = cfg.lineWidthPx;\n  ctx.strokeStyle = cfg.color;\n  ctx.fillStyle = cfg.fillColor;\n\n  ctx.shadowColor = 'rgba(0, 0, 0, 0.25)';\n  ctx.shadowBlur = 8;\n\n  ctx.beginPath();\n  ctx.arc(x, y, radius, 0, Math.PI * 2);\n  ctx.stroke();\n\n  ctx.shadowBlur = 0;\n\n  if (type === 'double_click' || type === 'triple_click') {\n    ctx.globalAlpha = 1;\n    ctx.fillStyle = cfg.color;\n    ctx.font = `700 ${Math.max(10, Math.round(base * 0.6))}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'middle';\n    ctx.fillText(type === 'double_click' ? '2×' : '3×', x, y);\n  } else {\n    ctx.beginPath();\n    ctx.arc(x, y, Math.max(2, base * 0.16), 0, Math.PI * 2);\n    ctx.fill();\n  }\n\n  ctx.restore();\n}\n\nfunction drawArrowHead(\n  ctx: OffscreenCanvasRenderingContext2D,\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n  size: number,\n): void {\n  const dx = x2 - x1;\n  const dy = y2 - y1;\n  const len = Math.hypot(dx, dy);\n  if (!Number.isFinite(len) || len < 1) return;\n\n  const ux = dx / len;\n  const uy = dy / len;\n  const px = -uy;\n  const py = ux;\n\n  const headLen = size;\n  const headWidth = size * 0.65;\n\n  const backX = x2 - ux * headLen;\n  const backY = y2 - uy * headLen;\n\n  ctx.beginPath();\n  ctx.moveTo(x2, y2);\n  ctx.lineTo(backX + px * headWidth, backY + py * headWidth);\n  ctx.lineTo(backX - px * headWidth, backY - py * headWidth);\n  ctx.closePath();\n  ctx.fill();\n}\n\nfunction drawDragPath(\n  ctx: OffscreenCanvasRenderingContext2D,\n  start: Point,\n  end: Point,\n  progress: number,\n  cfg: ResolvedDragPathConfig,\n): void {\n  const t = clamp(progress, 0, 1);\n  const alpha = 1 - easeOutCubic(t);\n\n  ctx.save();\n  ctx.globalAlpha = alpha;\n\n  ctx.strokeStyle = cfg.color;\n  ctx.fillStyle = cfg.color;\n  ctx.lineWidth = cfg.lineWidthPx;\n  ctx.lineCap = 'round';\n  ctx.lineJoin = 'round';\n  ctx.setLineDash(cfg.dash);\n\n  ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';\n  ctx.shadowBlur = 6;\n\n  ctx.beginPath();\n  ctx.moveTo(start.x, start.y);\n  ctx.lineTo(end.x, end.y);\n  ctx.stroke();\n\n  ctx.setLineDash([]);\n  ctx.shadowBlur = 0;\n\n  ctx.beginPath();\n  ctx.arc(start.x, start.y, cfg.startDotRadiusPx, 0, Math.PI * 2);\n  ctx.fill();\n\n  ctx.beginPath();\n  ctx.arc(end.x, end.y, cfg.endDotRadiusPx, 0, Math.PI * 2);\n  ctx.fill();\n\n  drawArrowHead(ctx, start.x, start.y, end.x, end.y, cfg.arrowSizePx);\n\n  ctx.restore();\n}\n\nfunction drawLabelPill(\n  ctx: OffscreenCanvasRenderingContext2D,\n  text: string,\n  anchor: Point | null,\n  alpha: number,\n  cfg: ResolvedLabelsConfig,\n  outputWidth: number,\n  outputHeight: number,\n): void {\n  ctx.save();\n  ctx.globalAlpha = clamp(alpha, 0, 1);\n\n  ctx.font = cfg.font;\n  ctx.textAlign = 'left';\n  ctx.textBaseline = 'middle';\n\n  const metrics = ctx.measureText(text);\n  const ascent = Number.isFinite(metrics.actualBoundingBoxAscent)\n    ? metrics.actualBoundingBoxAscent\n    : 10;\n  const descent = Number.isFinite(metrics.actualBoundingBoxDescent)\n    ? metrics.actualBoundingBoxDescent\n    : 4;\n  const textHeight = ascent + descent;\n  const pillWidth = Math.ceil(metrics.width + cfg.paddingX * 2);\n  const pillHeight = Math.ceil(textHeight + cfg.paddingY * 2);\n\n  const margin = 4;\n  const ax = anchor?.x ?? margin;\n  const ay = anchor?.y ?? margin;\n\n  let x = ax + cfg.offsetPx;\n  let y = ay - pillHeight / 2;\n\n  if (x + pillWidth > outputWidth - margin) x = ax - cfg.offsetPx - pillWidth;\n  if (y < margin) y = ay + cfg.offsetPx;\n  if (y + pillHeight > outputHeight - margin) y = outputHeight - margin - pillHeight;\n\n  x = clamp(x, margin, Math.max(margin, outputWidth - margin - pillWidth));\n  y = clamp(y, margin, Math.max(margin, outputHeight - margin - pillHeight));\n\n  ctx.fillStyle = cfg.backgroundColor;\n  ctx.strokeStyle = cfg.borderColor;\n  ctx.lineWidth = 1;\n\n  ctx.beginPath();\n  buildRoundedRectPath(ctx, x, y, pillWidth, pillHeight, cfg.radiusPx);\n  ctx.fill();\n  ctx.stroke();\n\n  ctx.fillStyle = cfg.textColor;\n  ctx.fillText(text, x + cfg.paddingX, y + pillHeight / 2);\n\n  ctx.restore();\n}\n\n// ============================================================================\n// Schema Input Normalization\n// ============================================================================\n\n/**\n * External schema input type that supports both shorthand (boolean) and full config.\n * This maps to what users pass via the MCP tool schema.\n */\ninterface SchemaEnhancedRenderingInput {\n  // Global toggle (Schema allows `true` to enable all defaults)\n  enabled?: boolean;\n\n  // Sub-configs can be boolean (enable/disable) or object (custom config)\n  clickIndicators?:\n    | boolean\n    | {\n        enabled?: boolean;\n        // Schema aliases (from tools.ts)\n        color?: string;\n        radius?: number; // alias for radiusPx\n        animationDurationMs?: number; // alias for durationMs\n        animationFrames?: number;\n        animationIntervalMs?: number;\n      };\n\n  dragPaths?:\n    | boolean\n    | {\n        enabled?: boolean;\n        color?: string;\n        lineWidth?: number; // alias for lineWidthPx\n        lineDash?: number[]; // alias for dash\n        arrowSize?: number; // alias for arrowSizePx\n      };\n\n  labels?:\n    | boolean\n    | {\n        enabled?: boolean;\n        font?: string;\n        textColor?: string;\n        bgColor?: string; // alias for backgroundColor\n        padding?: number; // alias for paddingX/paddingY\n        borderRadius?: number; // alias for radiusPx\n        offset?: { x?: number; y?: number } | number; // alias for offsetPx\n      };\n\n  durationMs?: number; // global fallback duration for all overlays\n}\n\nfunction normalizeSchemaInput(raw: unknown): GifEnhancedRenderingConfig | undefined {\n  // Handle `true` shorthand - enable with all defaults\n  if (raw === true) {\n    return { enabled: true };\n  }\n\n  // Handle `false` or falsy\n  if (!raw || typeof raw !== 'object') {\n    return undefined;\n  }\n\n  const input = raw as SchemaEnhancedRenderingInput;\n  const result: GifEnhancedRenderingConfig = {};\n\n  // Global enabled\n  result.enabled = input.enabled ?? true; // If object passed, default to enabled\n\n  // Global duration fallback\n  const globalDuration = typeof input.durationMs === 'number' ? input.durationMs : undefined;\n\n  // Normalize clickIndicators\n  if (input.clickIndicators === false) {\n    result.clickIndicators = { enabled: false };\n  } else if (input.clickIndicators === true) {\n    result.clickIndicators = { enabled: true };\n  } else if (typeof input.clickIndicators === 'object') {\n    const ci = input.clickIndicators;\n    result.clickIndicators = {\n      enabled: ci.enabled ?? true,\n      color: ci.color,\n      radiusPx: ci.radius,\n      durationMs: ci.animationDurationMs ?? globalDuration,\n      animationFrames: ci.animationFrames,\n      animationIntervalMs: ci.animationIntervalMs,\n    };\n  }\n\n  // Normalize dragPaths\n  if (input.dragPaths === false) {\n    result.dragPaths = { enabled: false };\n  } else if (input.dragPaths === true) {\n    result.dragPaths = { enabled: true };\n  } else if (typeof input.dragPaths === 'object') {\n    const dp = input.dragPaths;\n    result.dragPaths = {\n      enabled: dp.enabled ?? true,\n      color: dp.color,\n      lineWidthPx: dp.lineWidth,\n      dash: dp.lineDash,\n      arrowSizePx: dp.arrowSize,\n      durationMs: globalDuration,\n    };\n  }\n\n  // Normalize labels\n  if (input.labels === false) {\n    result.labels = { enabled: false };\n  } else if (input.labels === true) {\n    result.labels = { enabled: true };\n  } else if (typeof input.labels === 'object') {\n    const lb = input.labels;\n    const offset = lb.offset;\n    const offsetPx =\n      typeof offset === 'number' ? offset : typeof offset === 'object' ? offset.x : undefined;\n    result.labels = {\n      enabled: lb.enabled ?? true,\n      font: lb.font,\n      textColor: lb.textColor,\n      backgroundColor: lb.bgColor,\n      paddingX: typeof lb.padding === 'number' ? lb.padding : undefined,\n      paddingY: typeof lb.padding === 'number' ? lb.padding : undefined,\n      radiusPx: lb.borderRadius,\n      offsetPx,\n      durationMs: globalDuration,\n    };\n  }\n\n  return result;\n}\n\n// ============================================================================\n// Config Resolution\n// ============================================================================\n\nexport function resolveGifEnhancedRenderingConfig(\n  input?: GifEnhancedRenderingConfig | unknown,\n): ResolvedGifEnhancedRenderingConfig {\n  // Normalize schema input (handles `true`, boolean sub-configs, field aliases)\n  const normalized = normalizeSchemaInput(input) ?? (input as GifEnhancedRenderingConfig);\n  const enabled = normalized?.enabled ?? false;\n\n  const clickIntervalMs = normalizePositiveInt(\n    normalized?.clickIndicators?.animationIntervalMs,\n    80,\n    20,\n    500,\n  );\n  const clickDelayCsFallback = Math.max(1, Math.round(clickIntervalMs / 10));\n\n  return {\n    enabled,\n    clickIndicators: {\n      enabled: normalized?.clickIndicators?.enabled ?? true,\n      color: normalized?.clickIndicators?.color ?? '#FF6A00',\n      fillColor: normalized?.clickIndicators?.fillColor ?? 'rgba(255, 106, 0, 0.18)',\n      radiusPx: normalizePositiveNumber(normalized?.clickIndicators?.radiusPx, 18, 4, 96),\n      lineWidthPx: normalizePositiveNumber(normalized?.clickIndicators?.lineWidthPx, 3, 1, 16),\n      durationMs: normalizePositiveInt(normalized?.clickIndicators?.durationMs, 520, 120, 5000),\n      animationFrames: normalizePositiveInt(normalized?.clickIndicators?.animationFrames, 3, 1, 8),\n      animationIntervalMs: clickIntervalMs,\n      animationFrameDelayCs: normalizePositiveInt(\n        normalized?.clickIndicators?.animationFrameDelayCs,\n        clickDelayCsFallback,\n        1,\n        100,\n      ),\n    },\n    dragPaths: {\n      enabled: normalized?.dragPaths?.enabled ?? true,\n      color: normalized?.dragPaths?.color ?? '#FF2D55',\n      lineWidthPx: normalizePositiveNumber(normalized?.dragPaths?.lineWidthPx, 4, 1, 20),\n      durationMs: normalizePositiveInt(normalized?.dragPaths?.durationMs, 1000, 120, 8000),\n      arrowSizePx: normalizePositiveNumber(normalized?.dragPaths?.arrowSizePx, 10, 4, 40),\n      dash: normalizeDash(normalized?.dragPaths?.dash, [10, 8]),\n      startDotRadiusPx: normalizePositiveNumber(normalized?.dragPaths?.startDotRadiusPx, 4, 2, 24),\n      endDotRadiusPx: normalizePositiveNumber(normalized?.dragPaths?.endDotRadiusPx, 5, 2, 24),\n    },\n    labels: {\n      enabled: normalized?.labels?.enabled ?? false,\n      mode: normalized?.labels?.mode ?? 'both',\n      showForClicks: normalized?.labels?.showForClicks ?? false,\n      font:\n        normalized?.labels?.font ??\n        '600 13px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif',\n      maxLength: normalizePositiveInt(normalized?.labels?.maxLength, 48, 8, 200),\n      durationMs: normalizePositiveInt(normalized?.labels?.durationMs, 1200, 120, 12000),\n      backgroundColor: normalized?.labels?.backgroundColor ?? 'rgba(0, 0, 0, 0.72)',\n      borderColor: normalized?.labels?.borderColor ?? 'rgba(255, 255, 255, 0.14)',\n      textColor: normalized?.labels?.textColor ?? '#FFFFFF',\n      paddingX: normalizePositiveNumber(normalized?.labels?.paddingX, 10, 2, 40),\n      paddingY: normalizePositiveNumber(normalized?.labels?.paddingY, 6, 2, 30),\n      radiusPx: normalizePositiveNumber(normalized?.labels?.radiusPx, 10, 0, 30),\n      offsetPx: normalizePositiveNumber(normalized?.labels?.offsetPx, 12, 0, 80),\n    },\n  };\n}\n\n// ============================================================================\n// Capture Plan\n// ============================================================================\n\nexport function resolveCapturePlanForAction(\n  config: ResolvedGifEnhancedRenderingConfig,\n  action: ActionMetadata | undefined,\n  defaultFrameDelayCs: number,\n): CapturePlan {\n  const base: CapturePlan = { frames: 1, intervalMs: 0, delayCs: defaultFrameDelayCs };\n  if (!config.enabled || !action) return base;\n\n  if (config.clickIndicators.enabled && CLICK_ACTIONS.includes(action.type)) {\n    const frames = config.clickIndicators.animationFrames;\n    if (frames > 1) {\n      return {\n        frames,\n        intervalMs: config.clickIndicators.animationIntervalMs,\n        delayCs: config.clickIndicators.animationFrameDelayCs,\n      };\n    }\n  }\n\n  return base;\n}\n\n// ============================================================================\n// Main Render Function\n// ============================================================================\n\nexport function renderGifEnhancedOverlays(params: RenderGifEnhancedOverlaysParams): void {\n  const { ctx, outputWidth, outputHeight, viewportWidth, viewportHeight, nowMs, events, config } =\n    params;\n\n  if (!config.enabled || events.length === 0) return;\n\n  const clickCfg = config.clickIndicators;\n  const dragCfg = config.dragPaths;\n  const labelCfg = config.labels;\n\n  for (const event of events) {\n    const ageMs = nowMs - event.atMs;\n    if (!Number.isFinite(ageMs) || ageMs < 0) continue;\n\n    const action = event.action;\n\n    if (clickCfg.enabled && CLICK_ACTIONS.includes(action.type)) {\n      const anchor = resolveAnchorPoint(action);\n      if (anchor) {\n        const p = projectPoint(anchor, viewportWidth, viewportHeight, outputWidth, outputHeight);\n        if (p)\n          drawClickIndicator(ctx, p.x, p.y, ageMs / clickCfg.durationMs, action.type, clickCfg);\n      }\n    }\n\n    if (dragCfg.enabled && action.type === 'drag') {\n      const start = action.startCoordinates || null;\n      const end = action.endCoordinates || action.coordinates || null;\n      if (start && end) {\n        const p1 = projectPoint(start, viewportWidth, viewportHeight, outputWidth, outputHeight);\n        const p2 = projectPoint(end, viewportWidth, viewportHeight, outputWidth, outputHeight);\n        if (p1 && p2) drawDragPath(ctx, p1, p2, ageMs / dragCfg.durationMs, dragCfg);\n      }\n    }\n\n    // Render labels: always show annotation actions, respect labelCfg.enabled for other actions\n    const isAnnotation = action.type === 'annotation' || typeof action.label === 'string';\n    const shouldRenderLabel = labelCfg.enabled || isAnnotation;\n\n    if (shouldRenderLabel) {\n      const text = resolveActionLabel(action, labelCfg);\n      if (text) {\n        const anchor = resolveAnchorPoint(action);\n        const p = anchor\n          ? projectPoint(anchor, viewportWidth, viewportHeight, outputWidth, outputHeight)\n          : null;\n\n        const t = clamp(ageMs / labelCfg.durationMs, 0, 1);\n        const alpha = 1 - clamp((t - 0.75) / 0.25, 0, 1);\n\n        drawLabelPill(ctx, text, p, alpha, labelCfg, outputWidth, outputHeight);\n      }\n    }\n  }\n}\n\n// ============================================================================\n// Event Pruning\n// ============================================================================\n\nexport function pruneActionEventsInPlace(\n  events: ActionEvent[],\n  nowMs: number,\n  config: ResolvedGifEnhancedRenderingConfig,\n): void {\n  if (events.length === 0) return;\n\n  // Check if any events have annotations (which are always rendered)\n  const hasAnnotations = events.some(\n    (e) => e.action.type === 'annotation' || typeof e.action.label === 'string',\n  );\n\n  let maxLifetimeMs = 0;\n  if (config.enabled) {\n    if (config.clickIndicators.enabled)\n      maxLifetimeMs = Math.max(maxLifetimeMs, config.clickIndicators.durationMs);\n    if (config.dragPaths.enabled)\n      maxLifetimeMs = Math.max(maxLifetimeMs, config.dragPaths.durationMs);\n    if (config.labels.enabled) maxLifetimeMs = Math.max(maxLifetimeMs, config.labels.durationMs);\n  }\n\n  // Always account for label duration if there are annotations (they're always rendered)\n  if (hasAnnotations) {\n    maxLifetimeMs = Math.max(maxLifetimeMs, config.labels.durationMs);\n  }\n\n  if (maxLifetimeMs <= 0) {\n    events.length = 0;\n    return;\n  }\n\n  const cutoff = nowMs - maxLifetimeMs - 250;\n  let dropCount = 0;\n  while (dropCount < events.length && events[dropCount].atMs < cutoff) dropCount++;\n  if (dropCount > 0) events.splice(0, dropCount);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts",
    "content": "/**\n * GIF Recorder Tool\n *\n * Records browser tab activity as an animated GIF.\n *\n * Features:\n * - Two recording modes:\n *   1. Fixed FPS mode (start): Captures frames at regular intervals\n *   2. Auto-capture mode (auto_start): Captures frames on tool actions\n * - Configurable frame rate, duration, and dimensions\n * - Quality/size optimization options\n * - CDP-based screenshot capture for background recording\n * - Offscreen document encoding via gifenc\n */\n\nimport { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport {\n  MessageTarget,\n  OFFSCREEN_MESSAGE_TYPES,\n  OffscreenMessageType,\n} from '@/common/message-types';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport { offscreenManager } from '@/utils/offscreen-manager';\nimport { createImageBitmapFromUrl } from '@/utils/image-utils';\nimport {\n  startAutoCapture,\n  stopAutoCapture,\n  isAutoCaptureActive,\n  getAutoCaptureStatus,\n  captureFrameOnAction,\n  captureInitialFrame,\n  type ActionMetadata,\n  type GifEnhancedRenderingConfig,\n} from './gif-auto-capture';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst DEFAULT_FPS = 5;\nconst DEFAULT_DURATION_MS = 5000;\nconst DEFAULT_MAX_FRAMES = 50;\nconst DEFAULT_WIDTH = 800;\nconst DEFAULT_HEIGHT = 600;\nconst DEFAULT_MAX_COLORS = 256;\nconst CDP_SESSION_KEY = 'gif-recorder';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype GifRecorderAction =\n  | 'start'\n  | 'stop'\n  | 'status'\n  | 'auto_start'\n  | 'capture'\n  | 'clear'\n  | 'export';\n\ninterface GifRecorderParams {\n  action: GifRecorderAction;\n  tabId?: number;\n  fps?: number;\n  durationMs?: number;\n  maxFrames?: number;\n  width?: number;\n  height?: number;\n  maxColors?: number;\n  filename?: string;\n  // Auto-capture mode specific\n  captureDelayMs?: number;\n  frameDelayCs?: number;\n  enhancedRendering?: GifEnhancedRenderingConfig;\n  // Manual annotation for action=\"capture\"\n  annotation?: string;\n  // Export action specific\n  download?: boolean; // true to download, false to upload via drag&drop\n  coordinates?: { x: number; y: number }; // target position for drag&drop upload\n  ref?: string; // element ref for drag&drop upload (alternative to coordinates)\n  selector?: string; // CSS selector for drag&drop upload (alternative to coordinates)\n}\n\ninterface RecordingState {\n  isRecording: boolean;\n  isStopping: boolean;\n  tabId: number;\n  width: number;\n  height: number;\n  fps: number;\n  durationMs: number;\n  frameIntervalMs: number;\n  frameDelayCs: number;\n  maxFrames: number;\n  maxColors: number;\n  frameCount: number;\n  startTime: number;\n  captureTimer: ReturnType<typeof setTimeout> | null;\n  captureInProgress: Promise<void> | null;\n  canvas: OffscreenCanvas;\n  ctx: OffscreenCanvasRenderingContext2D;\n  filename?: string;\n}\n\ninterface GifResult {\n  success: boolean;\n  action: GifRecorderAction;\n  tabId?: number;\n  frameCount?: number;\n  durationMs?: number;\n  byteLength?: number;\n  downloadId?: number;\n  filename?: string;\n  fullPath?: string;\n  isRecording?: boolean;\n  mode?: 'fixed_fps' | 'auto_capture';\n  actionsCount?: number;\n  error?: string;\n  // Clear action specific\n  clearedAutoCapture?: boolean;\n  clearedFixedFps?: boolean;\n  clearedCache?: boolean;\n  // Export action specific (drag&drop upload)\n  uploadTarget?: {\n    x: number;\n    y: number;\n    tagName?: string;\n    id?: string;\n  };\n}\n\n// ============================================================================\n// Recording State Management\n// ============================================================================\n\nlet recordingState: RecordingState | null = null;\nlet stopPromise: Promise<GifResult> | null = null;\n\n// Auto-capture mode state\ninterface AutoCaptureMetadata {\n  tabId: number;\n  filename?: string;\n}\nlet autoCaptureMetadata: AutoCaptureMetadata | null = null;\n\n// Last recorded GIF cache for export\ninterface ExportableGif {\n  gifData: Uint8Array;\n  width: number;\n  height: number;\n  frameCount: number;\n  durationMs: number;\n  tabId: number;\n  filename?: string;\n  actionsCount?: number;\n  mode: 'fixed_fps' | 'auto_capture';\n  createdAt: number;\n}\nlet lastRecordedGif: ExportableGif | null = null;\n\n// Maximum cache lifetime for exportable GIF (5 minutes)\nconst EXPORT_CACHE_LIFETIME_MS = 5 * 60 * 1000;\n\n// ============================================================================\n// Offscreen Document Communication\n// ============================================================================\n\ntype OffscreenResponseBase = { success: boolean; error?: string };\n\nasync function sendToOffscreen<TResponse extends OffscreenResponseBase>(\n  type: OffscreenMessageType,\n  payload: Record<string, unknown> = {},\n): Promise<TResponse> {\n  await offscreenManager.ensureOffscreenDocument();\n\n  let lastError: unknown;\n  for (let attempt = 1; attempt <= 3; attempt++) {\n    try {\n      const response = (await chrome.runtime.sendMessage({\n        target: MessageTarget.Offscreen,\n        type,\n        ...payload,\n      })) as TResponse | undefined;\n\n      if (!response) {\n        throw new Error('No response received from offscreen document');\n      }\n      if (!response.success) {\n        throw new Error(response.error || 'Unknown offscreen error');\n      }\n\n      return response;\n    } catch (error) {\n      lastError = error;\n      if (attempt < 3) {\n        await new Promise((resolve) => setTimeout(resolve, 50 * attempt));\n        continue;\n      }\n      throw error;\n    }\n  }\n\n  throw lastError instanceof Error ? lastError : new Error(String(lastError));\n}\n\n// ============================================================================\n// Frame Capture\n// ============================================================================\n\nasync function captureFrame(\n  tabId: number,\n  width: number,\n  height: number,\n  ctx: OffscreenCanvasRenderingContext2D,\n): Promise<Uint8ClampedArray> {\n  // Get viewport metrics\n  const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } =\n    await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {});\n\n  const viewportWidth = metrics.layoutViewport?.clientWidth || width;\n  const viewportHeight = metrics.layoutViewport?.clientHeight || height;\n\n  // Capture screenshot\n  const screenshot: { data: string } = await cdpSessionManager.sendCommand(\n    tabId,\n    'Page.captureScreenshot',\n    {\n      format: 'png',\n      clip: {\n        x: 0,\n        y: 0,\n        width: viewportWidth,\n        height: viewportHeight,\n        scale: 1,\n      },\n    },\n  );\n\n  const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`);\n\n  // Scale image to target dimensions\n  ctx.clearRect(0, 0, width, height);\n  ctx.drawImage(imageBitmap, 0, 0, width, height);\n  imageBitmap.close();\n\n  const imageData = ctx.getImageData(0, 0, width, height);\n  return imageData.data;\n}\n\nasync function captureAndEncodeFrame(state: RecordingState): Promise<void> {\n  const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);\n\n  await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {\n    imageData: Array.from(frameData),\n    width: state.width,\n    height: state.height,\n    delay: state.frameDelayCs,\n    maxColors: state.maxColors,\n  });\n\n  if (recordingState === state && state.isRecording && !state.isStopping) {\n    state.frameCount += 1;\n  }\n}\n\nasync function captureTick(state: RecordingState): Promise<void> {\n  if (recordingState !== state || !state.isRecording || state.isStopping) {\n    return;\n  }\n\n  const elapsed = Date.now() - state.startTime;\n  if (elapsed >= state.durationMs || state.frameCount >= state.maxFrames) {\n    await stopRecording();\n    return;\n  }\n\n  const startedAt = Date.now();\n  state.captureInProgress = captureAndEncodeFrame(state);\n\n  try {\n    await state.captureInProgress;\n  } catch (error) {\n    console.error('Frame capture error:', error);\n  } finally {\n    if (recordingState === state) {\n      state.captureInProgress = null;\n    }\n  }\n\n  if (recordingState !== state || !state.isRecording || state.isStopping) {\n    return;\n  }\n\n  const elapsedAfter = Date.now() - state.startTime;\n  if (elapsedAfter >= state.durationMs || state.frameCount >= state.maxFrames) {\n    await stopRecording();\n    return;\n  }\n\n  const delayMs = Math.max(0, state.frameIntervalMs - (Date.now() - startedAt));\n  state.captureTimer = setTimeout(() => {\n    void captureTick(state).catch((error) => {\n      console.error('GIF recorder tick error:', error);\n    });\n  }, delayMs);\n}\n\n// ============================================================================\n// Recording Control\n// ============================================================================\n\nasync function startRecording(\n  tabId: number,\n  fps: number,\n  durationMs: number,\n  maxFrames: number,\n  width: number,\n  height: number,\n  maxColors: number,\n  filename?: string,\n): Promise<GifResult> {\n  if (stopPromise || recordingState?.isRecording || recordingState?.isStopping) {\n    return {\n      success: false,\n      action: 'start',\n      error: 'Recording already in progress',\n    };\n  }\n\n  try {\n    await cdpSessionManager.attach(tabId, CDP_SESSION_KEY);\n  } catch (error) {\n    return {\n      success: false,\n      action: 'start',\n      error: error instanceof Error ? error.message : String(error),\n    };\n  }\n\n  try {\n    await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});\n\n    if (typeof OffscreenCanvas === 'undefined') {\n      throw new Error('OffscreenCanvas not available in this context');\n    }\n\n    const canvas = new OffscreenCanvas(width, height);\n    const ctx = canvas.getContext('2d');\n    if (!ctx) {\n      throw new Error('Failed to get canvas context');\n    }\n\n    const frameIntervalMs = Math.round(1000 / fps);\n    const frameDelayCs = Math.max(1, Math.round(100 / fps));\n\n    const state: RecordingState = {\n      isRecording: true,\n      isStopping: false,\n      tabId,\n      width,\n      height,\n      fps,\n      durationMs,\n      frameIntervalMs,\n      frameDelayCs,\n      maxFrames,\n      maxColors,\n      frameCount: 0,\n      startTime: Date.now(),\n      captureTimer: null,\n      captureInProgress: null,\n      canvas,\n      ctx,\n      filename,\n    };\n\n    recordingState = state;\n\n    // Capture first frame eagerly so start() fails fast if capture/encoding is broken\n    await captureAndEncodeFrame(state);\n\n    state.captureTimer = setTimeout(() => {\n      void captureTick(state).catch((error) => {\n        console.error('GIF recorder tick error:', error);\n      });\n    }, frameIntervalMs);\n\n    return {\n      success: true,\n      action: 'start',\n      tabId,\n      isRecording: true,\n    };\n  } catch (error) {\n    recordingState = null;\n    try {\n      await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);\n    } catch {\n      // ignore\n    }\n    return {\n      success: false,\n      action: 'start',\n      error: error instanceof Error ? error.message : String(error),\n    };\n  }\n}\n\nasync function stopRecording(): Promise<GifResult> {\n  if (stopPromise) {\n    return stopPromise;\n  }\n\n  if (!recordingState || (!recordingState.isRecording && !recordingState.isStopping)) {\n    return {\n      success: false,\n      action: 'stop',\n      error: 'No recording in progress',\n    };\n  }\n\n  stopPromise = (async () => {\n    const state = recordingState!;\n    const tabId = state.tabId;\n\n    // Stop capture timer\n    if (state.captureTimer) {\n      clearTimeout(state.captureTimer);\n      state.captureTimer = null;\n    }\n\n    state.isStopping = true;\n    state.isRecording = false;\n\n    try {\n      await state.captureInProgress;\n    } catch {\n      // ignore\n    }\n\n    // Best-effort final frame capture to preserve end state\n    try {\n      const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);\n      await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {\n        imageData: Array.from(frameData),\n        width: state.width,\n        height: state.height,\n        delay: state.frameDelayCs,\n        maxColors: state.maxColors,\n      });\n      state.frameCount += 1;\n    } catch (error) {\n      console.warn('GIF recorder: Final frame capture error (non-fatal):', error);\n    }\n\n    const frameCount = state.frameCount;\n    const durationMs = Date.now() - state.startTime;\n    const filename = state.filename;\n\n    try {\n      if (frameCount <= 0) {\n        try {\n          await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});\n        } catch {\n          // ignore\n        }\n        return {\n          success: false,\n          action: 'stop' as const,\n          tabId,\n          frameCount,\n          durationMs,\n          error: 'No frames captured',\n        };\n      }\n\n      const response = await sendToOffscreen<{\n        success: boolean;\n        gifData?: number[];\n        byteLength?: number;\n      }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {});\n\n      if (!response.gifData || response.gifData.length === 0) {\n        return {\n          success: false,\n          action: 'stop' as const,\n          tabId,\n          frameCount,\n          durationMs,\n          error: 'No frames captured',\n        };\n      }\n\n      // Convert to Uint8Array and create blob\n      const gifBytes = new Uint8Array(response.gifData);\n\n      // Cache for later export\n      lastRecordedGif = {\n        gifData: gifBytes,\n        width: state.width,\n        height: state.height,\n        frameCount,\n        durationMs,\n        tabId,\n        filename,\n        mode: 'fixed_fps',\n        createdAt: Date.now(),\n      };\n\n      const blob = new Blob([gifBytes], { type: 'image/gif' });\n      const dataUrl = await blobToDataUrl(blob);\n\n      // Save GIF file\n      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n      const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;\n      const fullFilename = outputFilename.endsWith('.gif')\n        ? outputFilename\n        : `${outputFilename}.gif`;\n\n      const downloadId = await chrome.downloads.download({\n        url: dataUrl,\n        filename: fullFilename,\n        saveAs: false,\n      });\n\n      // Wait briefly to get download info\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      let fullPath: string | undefined;\n      try {\n        const [downloadItem] = await chrome.downloads.search({ id: downloadId });\n        fullPath = downloadItem?.filename;\n      } catch {\n        // Ignore path lookup errors\n      }\n\n      return {\n        success: true,\n        action: 'stop' as const,\n        tabId,\n        frameCount,\n        durationMs,\n        byteLength: response.byteLength ?? gifBytes.byteLength,\n        downloadId,\n        filename: fullFilename,\n        fullPath,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        action: 'stop' as const,\n        error: error instanceof Error ? error.message : String(error),\n      };\n    } finally {\n      try {\n        await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);\n      } catch {\n        // ignore\n      }\n      recordingState = null;\n    }\n  })();\n\n  return await stopPromise.finally(() => {\n    stopPromise = null;\n  });\n}\n\nfunction getRecordingStatus(): GifResult {\n  if (!recordingState) {\n    return {\n      success: true,\n      action: 'status',\n      isRecording: false,\n    };\n  }\n\n  return {\n    success: true,\n    action: 'status',\n    isRecording: recordingState.isRecording,\n    tabId: recordingState.tabId,\n    frameCount: recordingState.frameCount,\n    durationMs: Date.now() - recordingState.startTime,\n  };\n}\n\n// ============================================================================\n// Utilities\n// ============================================================================\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    reader.onload = () => resolve(reader.result as string);\n    reader.onerror = () => reject(new Error('Failed to read blob'));\n    reader.readAsDataURL(blob);\n  });\n}\n\nfunction normalizePositiveInt(value: unknown, fallback: number, max?: number): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    return fallback;\n  }\n  const result = Math.max(1, Math.floor(value));\n  return max !== undefined ? Math.min(result, max) : result;\n}\n\n// ============================================================================\n// Tool Implementation\n// ============================================================================\n\nclass GifRecorderTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.GIF_RECORDER;\n\n  async execute(args: GifRecorderParams): Promise<ToolResult> {\n    const action = args.action;\n    const validActions = ['start', 'stop', 'status', 'auto_start', 'capture', 'clear', 'export'];\n\n    if (!action || !validActions.includes(action)) {\n      return createErrorResponse(\n        `Parameter [action] is required and must be one of: ${validActions.join(', ')}`,\n      );\n    }\n\n    try {\n      switch (action) {\n        case 'start': {\n          // Fixed-FPS mode: captures frames at regular intervals\n          const tab = await this.resolveTargetTab(args.tabId);\n          if (!tab?.id) {\n            return createErrorResponse(\n              typeof args.tabId === 'number'\n                ? `Tab not found: ${args.tabId}`\n                : 'No active tab found',\n            );\n          }\n\n          if (this.isRestrictedUrl(tab.url)) {\n            return createErrorResponse(\n              'Cannot record special browser pages or web store pages due to security restrictions.',\n            );\n          }\n\n          // Check if auto-capture is active\n          if (isAutoCaptureActive(tab.id)) {\n            return createErrorResponse(\n              'Auto-capture mode is active for this tab. Use action=\"stop\" to stop it first.',\n            );\n          }\n\n          const fps = normalizePositiveInt(args.fps, DEFAULT_FPS, 30);\n          const durationMs = normalizePositiveInt(args.durationMs, DEFAULT_DURATION_MS, 60000);\n          const maxFrames = normalizePositiveInt(args.maxFrames, DEFAULT_MAX_FRAMES, 300);\n          const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);\n          const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);\n          const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);\n\n          const result = await startRecording(\n            tab.id,\n            fps,\n            durationMs,\n            maxFrames,\n            width,\n            height,\n            maxColors,\n            args.filename,\n          );\n\n          if (result.success) {\n            result.mode = 'fixed_fps';\n          }\n\n          return this.buildResponse(result);\n        }\n\n        case 'auto_start': {\n          // Auto-capture mode: captures frames when tools succeed\n          const tab = await this.resolveTargetTab(args.tabId);\n          if (!tab?.id) {\n            return createErrorResponse(\n              typeof args.tabId === 'number'\n                ? `Tab not found: ${args.tabId}`\n                : 'No active tab found',\n            );\n          }\n\n          if (this.isRestrictedUrl(tab.url)) {\n            return createErrorResponse(\n              'Cannot record special browser pages or web store pages due to security restrictions.',\n            );\n          }\n\n          // Check if fixed-FPS recording is active\n          if (recordingState?.isRecording && recordingState.tabId === tab.id) {\n            return createErrorResponse(\n              'Fixed-FPS recording is active for this tab. Use action=\"stop\" to stop it first.',\n            );\n          }\n\n          // Check if auto-capture is already active\n          if (isAutoCaptureActive(tab.id)) {\n            return createErrorResponse('Auto-capture is already active for this tab.');\n          }\n\n          const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);\n          const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);\n          const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);\n          const maxFrames = normalizePositiveInt(args.maxFrames, 100, 300);\n          const captureDelayMs = normalizePositiveInt(args.captureDelayMs, 150, 2000);\n          const frameDelayCs = normalizePositiveInt(args.frameDelayCs, 20, 100);\n\n          const startResult = await startAutoCapture(tab.id, {\n            width,\n            height,\n            maxColors,\n            maxFrames,\n            captureDelayMs,\n            frameDelayCs,\n            enhancedRendering: args.enhancedRendering,\n          });\n\n          if (!startResult.success) {\n            return this.buildResponse({\n              success: false,\n              action: 'auto_start',\n              tabId: tab.id,\n              error: startResult.error,\n            });\n          }\n\n          // Store metadata for stop\n          autoCaptureMetadata = {\n            tabId: tab.id,\n            filename: args.filename,\n          };\n\n          // Capture initial frame\n          await captureInitialFrame(tab.id);\n\n          return this.buildResponse({\n            success: true,\n            action: 'auto_start',\n            tabId: tab.id,\n            mode: 'auto_capture',\n            isRecording: true,\n          });\n        }\n\n        case 'capture': {\n          // Manual frame capture in auto mode\n          const tab = await this.resolveTargetTab(args.tabId);\n          if (!tab?.id) {\n            return createErrorResponse(\n              typeof args.tabId === 'number'\n                ? `Tab not found: ${args.tabId}`\n                : 'No active tab found',\n            );\n          }\n\n          if (!isAutoCaptureActive(tab.id)) {\n            return createErrorResponse(\n              'Auto-capture is not active for this tab. Use action=\"auto_start\" first.',\n            );\n          }\n\n          // Support optional annotation for manual captures\n          const annotation =\n            typeof args.annotation === 'string' && args.annotation.trim().length > 0\n              ? args.annotation.trim()\n              : undefined;\n\n          const action: ActionMetadata | undefined = annotation\n            ? { type: 'annotation', label: annotation }\n            : undefined;\n\n          const captureResult = await captureFrameOnAction(tab.id, action, true);\n\n          return this.buildResponse({\n            success: captureResult.success,\n            action: 'capture',\n            tabId: tab.id,\n            frameCount: captureResult.frameNumber,\n            error: captureResult.error,\n          });\n        }\n\n        case 'stop': {\n          // Stop either mode\n          // Check auto-capture first\n          const autoTab = autoCaptureMetadata?.tabId;\n          if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {\n            const stopResult = await stopAutoCapture(autoTab);\n            const filename = autoCaptureMetadata?.filename;\n            autoCaptureMetadata = null;\n\n            if (!stopResult.success || !stopResult.gifData) {\n              return this.buildResponse({\n                success: false,\n                action: 'stop',\n                tabId: autoTab,\n                mode: 'auto_capture',\n                frameCount: stopResult.frameCount,\n                durationMs: stopResult.durationMs,\n                actionsCount: stopResult.actions?.length,\n                error: stopResult.error || 'No GIF data generated',\n              });\n            }\n\n            // Cache for later export\n            lastRecordedGif = {\n              gifData: stopResult.gifData,\n              width: DEFAULT_WIDTH, // auto mode uses default dimensions\n              height: DEFAULT_HEIGHT,\n              frameCount: stopResult.frameCount ?? 0,\n              durationMs: stopResult.durationMs ?? 0,\n              tabId: autoTab,\n              filename,\n              actionsCount: stopResult.actions?.length,\n              mode: 'auto_capture',\n              createdAt: Date.now(),\n            };\n\n            // Save GIF file\n            const blob = new Blob([stopResult.gifData], { type: 'image/gif' });\n            const dataUrl = await blobToDataUrl(blob);\n\n            const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n            const outputFilename =\n              filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;\n            const fullFilename = outputFilename.endsWith('.gif')\n              ? outputFilename\n              : `${outputFilename}.gif`;\n\n            const downloadId = await chrome.downloads.download({\n              url: dataUrl,\n              filename: fullFilename,\n              saveAs: false,\n            });\n\n            await new Promise((resolve) => setTimeout(resolve, 100));\n\n            let fullPath: string | undefined;\n            try {\n              const [downloadItem] = await chrome.downloads.search({ id: downloadId });\n              fullPath = downloadItem?.filename;\n            } catch {\n              // Ignore\n            }\n\n            return this.buildResponse({\n              success: true,\n              action: 'stop',\n              tabId: autoTab,\n              mode: 'auto_capture',\n              frameCount: stopResult.frameCount,\n              durationMs: stopResult.durationMs,\n              byteLength: stopResult.gifData.byteLength,\n              actionsCount: stopResult.actions?.length,\n              downloadId,\n              filename: fullFilename,\n              fullPath,\n            });\n          }\n\n          // Fall back to fixed-FPS stop\n          const result = await stopRecording();\n          if (result.success) {\n            result.mode = 'fixed_fps';\n          }\n          return this.buildResponse(result);\n        }\n\n        case 'status': {\n          // Check auto-capture status first\n          const autoTab = autoCaptureMetadata?.tabId;\n          if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {\n            const status = getAutoCaptureStatus(autoTab);\n            return this.buildResponse({\n              success: true,\n              action: 'status',\n              tabId: autoTab,\n              isRecording: status.active,\n              mode: 'auto_capture',\n              frameCount: status.frameCount,\n              durationMs: status.durationMs,\n              actionsCount: status.actionsCount,\n            });\n          }\n\n          // Fall back to fixed-FPS status\n          const result = getRecordingStatus();\n          if (result.isRecording) {\n            result.mode = 'fixed_fps';\n          }\n          return this.buildResponse(result);\n        }\n\n        case 'clear': {\n          // Clear all recording state and cached GIF\n          let clearedAuto = false;\n          let clearedFixedFps = false;\n          let clearedCache = false;\n\n          // Stop auto-capture if active\n          const autoTab = autoCaptureMetadata?.tabId;\n          if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {\n            await stopAutoCapture(autoTab);\n            autoCaptureMetadata = null;\n            clearedAuto = true;\n          }\n\n          // Stop fixed-FPS recording if active or stopping\n          if (recordingState) {\n            // Cancel timer and cleanup without waiting for finish\n            if (recordingState.captureTimer) {\n              clearTimeout(recordingState.captureTimer);\n              recordingState.captureTimer = null;\n            }\n            try {\n              await recordingState.captureInProgress;\n            } catch {\n              // ignore\n            }\n            try {\n              await cdpSessionManager.detach(recordingState.tabId, CDP_SESSION_KEY);\n            } catch {\n              // ignore\n            }\n            const wasRecording = recordingState.isRecording || recordingState.isStopping;\n            recordingState = null;\n            stopPromise = null; // Clear any pending stop promise\n            if (wasRecording) {\n              clearedFixedFps = true;\n            }\n          }\n\n          // Reset offscreen encoder\n          try {\n            await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});\n          } catch {\n            // ignore\n          }\n\n          // Clear cached GIF\n          if (lastRecordedGif) {\n            lastRecordedGif = null;\n            clearedCache = true;\n          }\n\n          return this.buildResponse({\n            success: true,\n            action: 'clear',\n            clearedAutoCapture: clearedAuto,\n            clearedFixedFps,\n            clearedCache,\n          } as GifResult);\n        }\n\n        case 'export': {\n          // Export the last recorded GIF (download or drag&drop upload)\n\n          // Check if cache is valid\n          if (!lastRecordedGif) {\n            return createErrorResponse(\n              'No recorded GIF available for export. Use action=\"stop\" to finish a recording first.',\n            );\n          }\n\n          // Check cache expiration\n          if (Date.now() - lastRecordedGif.createdAt > EXPORT_CACHE_LIFETIME_MS) {\n            lastRecordedGif = null;\n            return createErrorResponse('Cached GIF has expired. Please record a new GIF.');\n          }\n\n          const download = args.download !== false; // Default to download\n\n          if (download) {\n            // Download mode\n            const blob = new Blob([lastRecordedGif.gifData], { type: 'image/gif' });\n            const dataUrl = await blobToDataUrl(blob);\n\n            const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n            const filename = args.filename ?? lastRecordedGif.filename;\n            const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `export_${timestamp}`;\n            const fullFilename = outputFilename.endsWith('.gif')\n              ? outputFilename\n              : `${outputFilename}.gif`;\n\n            const downloadId = await chrome.downloads.download({\n              url: dataUrl,\n              filename: fullFilename,\n              saveAs: false,\n            });\n\n            await new Promise((resolve) => setTimeout(resolve, 100));\n\n            let fullPath: string | undefined;\n            try {\n              const [downloadItem] = await chrome.downloads.search({ id: downloadId });\n              fullPath = downloadItem?.filename;\n            } catch {\n              // Ignore\n            }\n\n            return this.buildResponse({\n              success: true,\n              action: 'export',\n              mode: lastRecordedGif.mode,\n              frameCount: lastRecordedGif.frameCount,\n              durationMs: lastRecordedGif.durationMs,\n              byteLength: lastRecordedGif.gifData.byteLength,\n              downloadId,\n              filename: fullFilename,\n              fullPath,\n            });\n          } else {\n            // Drag&drop upload mode\n            const { coordinates, ref, selector } = args;\n\n            if (!coordinates && !ref && !selector) {\n              return createErrorResponse(\n                'For drag&drop upload, provide coordinates, ref, or selector to identify the drop target.',\n              );\n            }\n\n            // Resolve target tab\n            const tab = await this.resolveTargetTab(args.tabId);\n            if (!tab?.id) {\n              return createErrorResponse(\n                typeof args.tabId === 'number'\n                  ? `Tab not found: ${args.tabId}`\n                  : 'No active tab found',\n              );\n            }\n\n            // Security check\n            if (this.isRestrictedUrl(tab.url)) {\n              return createErrorResponse(\n                'Cannot upload to special browser pages or web store pages.',\n              );\n            }\n\n            // Prepare GIF data as base64\n            const gifBase64 = btoa(\n              Array.from(lastRecordedGif.gifData)\n                .map((b) => String.fromCharCode(b))\n                .join(''),\n            );\n\n            // Resolve drop target coordinates\n            let targetX: number | undefined;\n            let targetY: number | undefined;\n\n            if (ref) {\n              // Use the project's built-in ref resolution mechanism\n              try {\n                await this.injectContentScript(tab.id, [\n                  'inject-scripts/accessibility-tree-helper.js',\n                ]);\n                const resolved = await this.sendMessageToTab(tab.id, {\n                  action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n                  ref,\n                });\n                if (resolved?.success && resolved.center) {\n                  targetX = resolved.center.x;\n                  targetY = resolved.center.y;\n                } else {\n                  return createErrorResponse(`Could not resolve ref: ${ref}`);\n                }\n              } catch (err) {\n                return createErrorResponse(\n                  `Failed to resolve ref: ${err instanceof Error ? err.message : String(err)}`,\n                );\n              }\n            } else if (selector) {\n              // Use executeScript to get element center coordinates by CSS selector\n              try {\n                const [result] = await chrome.scripting.executeScript({\n                  target: { tabId: tab.id },\n                  func: (cssSelector: string) => {\n                    const el = document.querySelector(cssSelector);\n                    if (!el) return null;\n                    const rect = el.getBoundingClientRect();\n                    return {\n                      x: rect.left + rect.width / 2,\n                      y: rect.top + rect.height / 2,\n                    };\n                  },\n                  args: [selector],\n                });\n\n                if (result?.result) {\n                  targetX = result.result.x;\n                  targetY = result.result.y;\n                } else {\n                  return createErrorResponse(`Could not find element: ${selector}`);\n                }\n              } catch (err) {\n                return createErrorResponse(\n                  `Failed to resolve selector: ${err instanceof Error ? err.message : String(err)}`,\n                );\n              }\n            } else if (coordinates) {\n              targetX = coordinates.x;\n              targetY = coordinates.y;\n            }\n\n            if (typeof targetX !== 'number' || typeof targetY !== 'number') {\n              return createErrorResponse('Invalid drop target coordinates.');\n            }\n\n            // Execute drag&drop upload\n            try {\n              const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n              const filename =\n                args.filename ?? lastRecordedGif.filename ?? `recording_${timestamp}`;\n              const fullFilename = filename.endsWith('.gif') ? filename : `${filename}.gif`;\n\n              const [result] = await chrome.scripting.executeScript({\n                target: { tabId: tab.id },\n                func: (base64Data: string, x: number, y: number, fname: string) => {\n                  // Convert base64 to Blob\n                  const byteChars = atob(base64Data);\n                  const byteArray = new Uint8Array(byteChars.length);\n                  for (let i = 0; i < byteChars.length; i++) {\n                    byteArray[i] = byteChars.charCodeAt(i);\n                  }\n                  const blob = new Blob([byteArray], { type: 'image/gif' });\n                  const file = new File([blob], fname, { type: 'image/gif' });\n\n                  // Find drop target element\n                  const target = document.elementFromPoint(x, y);\n                  if (!target) {\n                    return { success: false, error: 'No element at drop coordinates' };\n                  }\n\n                  // Create DataTransfer with the file\n                  const dt = new DataTransfer();\n                  dt.items.add(file);\n\n                  // Dispatch drag events\n                  const events = ['dragenter', 'dragover', 'drop'] as const;\n                  for (const eventType of events) {\n                    const evt = new DragEvent(eventType, {\n                      bubbles: true,\n                      cancelable: true,\n                      dataTransfer: dt,\n                      clientX: x,\n                      clientY: y,\n                    });\n                    target.dispatchEvent(evt);\n                  }\n\n                  return {\n                    success: true,\n                    targetTagName: target.tagName,\n                    targetId: target.id || undefined,\n                  };\n                },\n                args: [gifBase64, targetX, targetY, fullFilename],\n              });\n\n              if (!result?.result?.success) {\n                return createErrorResponse(result?.result?.error || 'Drag&drop upload failed');\n              }\n\n              return this.buildResponse({\n                success: true,\n                action: 'export',\n                mode: lastRecordedGif.mode,\n                frameCount: lastRecordedGif.frameCount,\n                durationMs: lastRecordedGif.durationMs,\n                byteLength: lastRecordedGif.gifData.byteLength,\n                uploadTarget: {\n                  x: targetX,\n                  y: targetY,\n                  tagName: result.result.targetTagName,\n                  id: result.result.targetId,\n                },\n              } as GifResult);\n            } catch (err) {\n              return createErrorResponse(\n                `Drag&drop upload failed: ${err instanceof Error ? err.message : String(err)}`,\n              );\n            }\n          }\n        }\n\n        default:\n          return createErrorResponse(`Unknown action: ${action}`);\n      }\n    } catch (error) {\n      console.error('GifRecorderTool.execute error:', error);\n      return createErrorResponse(\n        `GIF recorder error: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  private isRestrictedUrl(url?: string): boolean {\n    if (!url) return false;\n    return (\n      url.startsWith('chrome://') ||\n      url.startsWith('edge://') ||\n      url.startsWith('https://chrome.google.com/webstore') ||\n      url.startsWith('https://microsoftedge.microsoft.com/')\n    );\n  }\n\n  private async resolveTargetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {\n    if (typeof tabId === 'number') {\n      return this.tryGetTab(tabId);\n    }\n    try {\n      return await this.getActiveTabOrThrow();\n    } catch {\n      return null;\n    }\n  }\n\n  private buildResponse(result: GifResult): ToolResult {\n    return {\n      content: [{ type: 'text', text: JSON.stringify(result) }],\n      isError: !result.success,\n    };\n  }\n}\n\nexport const gifRecorderTool = new GifRecorderTool();\n\n// Re-export auto-capture utilities for use by other tools (e.g., chrome_computer, chrome_navigate)\nexport {\n  captureFrameOnAction,\n  isAutoCaptureActive,\n  type ActionMetadata,\n  type ActionType,\n} from './gif-auto-capture';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/history.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport {\n  parseISO,\n  subDays,\n  subWeeks,\n  subMonths,\n  subYears,\n  startOfToday,\n  startOfYesterday,\n  isValid,\n  format,\n} from 'date-fns';\n\ninterface HistoryToolParams {\n  text?: string;\n  startTime?: string;\n  endTime?: string;\n  maxResults?: number;\n  excludeCurrentTabs?: boolean;\n}\n\ninterface HistoryItem {\n  id: string;\n  url?: string;\n  title?: string;\n  lastVisitTime?: number; // Timestamp in milliseconds\n  visitCount?: number;\n  typedCount?: number;\n}\n\ninterface HistoryResult {\n  items: HistoryItem[];\n  totalCount: number;\n  timeRange: {\n    startTime: number;\n    endTime: number;\n    startTimeFormatted: string;\n    endTimeFormatted: string;\n  };\n  query?: string;\n}\n\nclass HistoryTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.HISTORY;\n  private static readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;\n\n  /**\n   * Parse a date string into milliseconds since epoch.\n   * Returns null if the date string is invalid.\n   * Supports:\n   *  - ISO date strings (e.g., \"2023-10-31\", \"2023-10-31T14:30:00.000Z\")\n   *  - Relative times: \"1 day ago\", \"2 weeks ago\", \"3 months ago\", \"1 year ago\"\n   *  - Special keywords: \"now\", \"today\", \"yesterday\"\n   */\n  private parseDateString(dateStr: string | undefined | null): number | null {\n    if (!dateStr) {\n      // If an empty or null string is passed, it might mean \"no specific date\",\n      // depending on how you want to treat it. Returning null is safer.\n      return null;\n    }\n\n    const now = new Date();\n    const lowerDateStr = dateStr.toLowerCase().trim();\n\n    if (lowerDateStr === 'now') return now.getTime();\n    if (lowerDateStr === 'today') return startOfToday().getTime();\n    if (lowerDateStr === 'yesterday') return startOfYesterday().getTime();\n\n    const relativeMatch = lowerDateStr.match(\n      /^(\\d+)\\s+(day|days|week|weeks|month|months|year|years)\\s+ago$/,\n    );\n    if (relativeMatch) {\n      const amount = parseInt(relativeMatch[1], 10);\n      const unit = relativeMatch[2];\n      let resultDate: Date;\n      if (unit.startsWith('day')) resultDate = subDays(now, amount);\n      else if (unit.startsWith('week')) resultDate = subWeeks(now, amount);\n      else if (unit.startsWith('month')) resultDate = subMonths(now, amount);\n      else if (unit.startsWith('year')) resultDate = subYears(now, amount);\n      else return null; // Should not happen with the regex\n      return resultDate.getTime();\n    }\n\n    // Try parsing as ISO or other common date string formats\n    // Native Date constructor can be unreliable for non-standard formats.\n    // date-fns' parseISO is good for ISO 8601.\n    // For other formats, date-fns' parse function is more flexible.\n    let parsedDate = parseISO(dateStr); // Handles \"2023-10-31\" or \"2023-10-31T10:00:00\"\n    if (isValid(parsedDate)) {\n      return parsedDate.getTime();\n    }\n\n    // Fallback to new Date() for other potential formats, but with caution\n    parsedDate = new Date(dateStr);\n    if (isValid(parsedDate) && dateStr.includes(parsedDate.getFullYear().toString())) {\n      return parsedDate.getTime();\n    }\n\n    console.warn(`Could not parse date string: ${dateStr}`);\n    return null;\n  }\n\n  /**\n   * Format a timestamp as a human-readable date string\n   */\n  private formatDate(timestamp: number): string {\n    // Using date-fns for consistent and potentially localized formatting\n    return format(timestamp, 'yyyy-MM-dd HH:mm:ss');\n  }\n\n  async execute(args: HistoryToolParams): Promise<ToolResult> {\n    try {\n      console.log('Executing HistoryTool with args:', args);\n\n      const {\n        text = '',\n        maxResults = 100, // Default to 100 results\n        excludeCurrentTabs = false,\n      } = args;\n\n      const now = Date.now();\n      let startTimeMs: number;\n      let endTimeMs: number;\n\n      // Parse startTime\n      if (args.startTime) {\n        const parsedStart = this.parseDateString(args.startTime);\n        if (parsedStart === null) {\n          return createErrorResponse(\n            `Invalid format for start time: \"${args.startTime}\". Supported formats: ISO (YYYY-MM-DD), \"today\", \"yesterday\", \"X days/weeks/months/years ago\".`,\n          );\n        }\n        startTimeMs = parsedStart;\n      } else {\n        // Default to 24 hours ago if startTime is not provided\n        startTimeMs = now - HistoryTool.ONE_DAY_MS;\n      }\n\n      // Parse endTime\n      if (args.endTime) {\n        const parsedEnd = this.parseDateString(args.endTime);\n        if (parsedEnd === null) {\n          return createErrorResponse(\n            `Invalid format for end time: \"${args.endTime}\". Supported formats: ISO (YYYY-MM-DD), \"today\", \"yesterday\", \"X days/weeks/months/years ago\".`,\n          );\n        }\n        endTimeMs = parsedEnd;\n      } else {\n        // Default to current time if endTime is not provided\n        endTimeMs = now;\n      }\n\n      // Validate time range\n      if (startTimeMs > endTimeMs) {\n        return createErrorResponse('Start time cannot be after end time.');\n      }\n\n      console.log(\n        `Searching history from ${this.formatDate(startTimeMs)} to ${this.formatDate(endTimeMs)} for query \"${text}\"`,\n      );\n\n      const historyItems = await chrome.history.search({\n        text,\n        startTime: startTimeMs,\n        endTime: endTimeMs,\n        maxResults,\n      });\n\n      console.log(`Found ${historyItems.length} history items before filtering current tabs.`);\n\n      let filteredItems = historyItems;\n      if (excludeCurrentTabs && historyItems.length > 0) {\n        const currentTabs = await chrome.tabs.query({});\n        const openUrls = new Set<string>();\n\n        currentTabs.forEach((tab) => {\n          if (tab.url) {\n            openUrls.add(tab.url);\n          }\n        });\n\n        if (openUrls.size > 0) {\n          filteredItems = historyItems.filter((item) => !(item.url && openUrls.has(item.url)));\n          console.log(\n            `Filtered out ${historyItems.length - filteredItems.length} items that are currently open. ${filteredItems.length} items remaining.`,\n          );\n        }\n      }\n\n      const result: HistoryResult = {\n        items: filteredItems.map((item) => ({\n          id: item.id,\n          url: item.url,\n          title: item.title,\n          lastVisitTime: item.lastVisitTime,\n          visitCount: item.visitCount,\n          typedCount: item.typedCount,\n        })),\n        totalCount: filteredItems.length,\n        timeRange: {\n          startTime: startTimeMs,\n          endTime: endTimeMs,\n          startTimeFormatted: this.formatDate(startTimeMs),\n          endTimeFormatted: this.formatDate(endTimeMs),\n        },\n      };\n\n      if (text) {\n        result.query = text;\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result, null, 2),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in HistoryTool.execute:', error);\n      return createErrorResponse(\n        `Error retrieving browsing history: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const historyTool = new HistoryTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/index.ts",
    "content": "export { navigateTool, closeTabsTool, switchTabTool } from './common';\nexport { windowTool } from './window';\nexport { vectorSearchTabsContentTool as searchTabsContentTool } from './vector-search';\nexport { screenshotTool } from './screenshot';\nexport { webFetcherTool, getInteractiveElementsTool } from './web-fetcher';\nexport { clickTool, fillTool } from './interaction';\nexport { elementPickerTool } from './element-picker';\nexport { networkRequestTool } from './network-request';\nexport { networkCaptureTool } from './network-capture';\n// Legacy exports (for internal use by networkCaptureTool)\nexport { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';\nexport { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';\nexport { keyboardTool } from './keyboard';\nexport { historyTool } from './history';\nexport { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';\nexport { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';\nexport { javascriptTool } from './javascript';\nexport { consoleTool } from './console';\nexport { fileUploadTool } from './file-upload';\nexport { readPageTool } from './read-page';\nexport { computerTool } from './computer';\nexport { handleDialogTool } from './dialog';\nexport { handleDownloadTool } from './download';\nexport { userscriptTool } from './userscript';\nexport {\n  performanceStartTraceTool,\n  performanceStopTraceTool,\n  performanceAnalyzeInsightTool,\n} from './performance';\nexport { gifRecorderTool } from './gif-recorder';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/inject-script.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ExecutionWorld } from '@/common/constants';\n\ninterface InjectScriptParam {\n  url?: string;\n  tabId?: number;\n  windowId?: number;\n  background?: boolean;\n}\ninterface ScriptConfig {\n  type: ExecutionWorld;\n  jsScript: string;\n}\n\ninterface SendCommandToInjectScriptToolParam {\n  tabId?: number;\n  eventName: string;\n  payload?: string;\n}\n\nconst injectedTabs = new Map();\nclass InjectScriptTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.INJECT_SCRIPT;\n  async execute(args: InjectScriptParam & ScriptConfig): Promise<ToolResult> {\n    try {\n      const { url, type, jsScript, tabId, windowId, background } = args;\n      let tab: chrome.tabs.Tab | undefined;\n\n      if (!type || !jsScript) {\n        return createErrorResponse('Param [type] and [jsScript] is required');\n      }\n\n      if (typeof tabId === 'number') {\n        tab = await chrome.tabs.get(tabId);\n      } else if (url) {\n        // If URL is provided, check if it's already open\n        console.log(`Checking if URL is already open: ${url}`);\n        const allTabs = await chrome.tabs.query({});\n\n        // Find tab with matching URL\n        const matchingTabs = allTabs.filter((t) => {\n          // Normalize URLs for comparison (remove trailing slashes)\n          const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;\n          const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;\n          return tabUrl === targetUrl;\n        });\n\n        if (matchingTabs.length > 0) {\n          // Use existing tab\n          tab = matchingTabs[0];\n          console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);\n        } else {\n          // Create new tab with the URL\n          console.log(`No existing tab found with URL: ${url}, creating new tab`);\n          tab = await chrome.tabs.create({\n            url,\n            active: background === true ? false : true,\n            windowId,\n          });\n\n          // Wait for page to load\n          console.log('Waiting for page to load...');\n          await new Promise((resolve) => setTimeout(resolve, 3000));\n        }\n      } else {\n        // Use active tab (prefer the specified window)\n        const tabs =\n          typeof windowId === 'number'\n            ? await chrome.tabs.query({ active: true, windowId })\n            : await chrome.tabs.query({ active: true, currentWindow: true });\n        if (!tabs[0]) {\n          return createErrorResponse('No active tab found');\n        }\n        tab = tabs[0];\n      }\n\n      if (!tab.id) {\n        return createErrorResponse('Tab has no ID');\n      }\n\n      // Optionally bring tab/window to foreground based on background flag\n      if (background !== true) {\n        await chrome.tabs.update(tab.id, { active: true });\n        await chrome.windows.update(tab.windowId, { focused: true });\n      }\n\n      const res = await handleInject(tab.id!, { ...args });\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(res),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in InjectScriptTool.execute:', error);\n      return createErrorResponse(\n        `Inject script error: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nclass SendCommandToInjectScriptTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT;\n  async execute(args: SendCommandToInjectScriptToolParam): Promise<ToolResult> {\n    try {\n      const { tabId, eventName, payload } = args;\n\n      if (!eventName) {\n        return createErrorResponse('Param [eventName] is required');\n      }\n\n      if (tabId) {\n        const tabExists = await isTabExists(tabId);\n        if (!tabExists) {\n          return createErrorResponse('The tab:[tabId] is not exists');\n        }\n      }\n\n      let finalTabId: number | undefined = tabId;\n\n      if (finalTabId === undefined) {\n        // Use active tab\n        const tabs = await chrome.tabs.query({ active: true });\n        if (!tabs[0]) {\n          return createErrorResponse('No active tab found');\n        }\n        finalTabId = tabs[0].id;\n      }\n\n      if (!finalTabId) {\n        return createErrorResponse('No active tab found');\n      }\n\n      if (!injectedTabs.has(finalTabId)) {\n        throw new Error('No script injected in this tab.');\n      }\n      const result = await chrome.tabs.sendMessage(finalTabId, {\n        action: eventName,\n        payload,\n        targetWorld: injectedTabs.get(finalTabId).type, // The bridge uses this to decide whether to forward to MAIN world.\n      });\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in InjectScriptTool.execute:', error);\n      return createErrorResponse(\n        `Inject script error: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nasync function isTabExists(tabId: number) {\n  try {\n    await chrome.tabs.get(tabId);\n    return true;\n  } catch (error) {\n    // An error is thrown if the tab doesn't exist.\n    return false;\n  }\n}\n\n/**\n * @description Handles the injection of user scripts into a specific tab.\n * @param {number} tabId - The ID of the target tab.\n * @param {object} scriptConfig - The configuration object for the script.\n */\nasync function handleInject(tabId: number, scriptConfig: ScriptConfig) {\n  if (injectedTabs.has(tabId)) {\n    // If already injected, run cleanup first to ensure a clean state.\n    console.log(`Tab ${tabId} already has injections. Cleaning up first.`);\n    await handleCleanup(tabId);\n  }\n  const { type, jsScript } = scriptConfig;\n  const hasMain = type === ExecutionWorld.MAIN;\n\n  if (hasMain) {\n    // The bridge is essential for MAIN world communication and cleanup.\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: ['inject-scripts/inject-bridge.js'],\n      world: ExecutionWorld.ISOLATED,\n    });\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      func: (code) => new Function(code)(),\n      args: [jsScript],\n      world: ExecutionWorld.MAIN,\n    });\n  } else {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      func: (code) => new Function(code)(),\n      args: [jsScript],\n      world: ExecutionWorld.ISOLATED,\n    });\n  }\n  injectedTabs.set(tabId, scriptConfig);\n  console.log(`Scripts successfully injected into tab ${tabId}.`);\n  return { injected: true };\n}\n\n/**\n * @description Triggers the cleanup process in a specific tab.\n * @param {number} tabId - The ID of the target tab.\n */\nasync function handleCleanup(tabId: number) {\n  if (!injectedTabs.has(tabId)) return;\n  // Send cleanup signal. The bridge will forward it to the MAIN world.\n  chrome.tabs\n    .sendMessage(tabId, { type: 'chrome-mcp:cleanup' })\n    .catch((err) =>\n      console.warn(`Could not send cleanup message to tab ${tabId}. It might have been closed.`),\n    );\n\n  injectedTabs.delete(tabId);\n  console.log(`Cleanup signal sent to tab ${tabId}. State cleared.`);\n}\n\nexport const injectScriptTool = new InjectScriptTool();\nexport const sendCommandToInjectScriptTool = new SendCommandToInjectScriptTool();\n\n// --- Automatic Cleanup Listeners ---\nchrome.tabs.onRemoved.addListener((tabId) => {\n  if (injectedTabs.has(tabId)) {\n    console.log(`Tab ${tabId} closed. Cleaning up state.`);\n    injectedTabs.delete(tabId);\n  }\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/interaction.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';\n\ninterface Coordinates {\n  x: number;\n  y: number;\n}\n\ninterface ClickToolParams {\n  selector?: string; // CSS selector or XPath for the element to click\n  selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')\n  ref?: string; // Element ref from accessibility tree (window.__claudeElementMap)\n  coordinates?: Coordinates; // Coordinates to click at (x, y relative to viewport)\n  waitForNavigation?: boolean; // Whether to wait for navigation to complete after click\n  timeout?: number; // Timeout in milliseconds for waiting for the element or navigation\n  frameId?: number; // Target frame for ref/selector resolution\n  double?: boolean; // Perform double click when true\n  button?: 'left' | 'right' | 'middle';\n  bubbles?: boolean;\n  cancelable?: boolean;\n  modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean };\n  tabId?: number; // target existing tab id\n  windowId?: number; // when no tabId, pick active tab from this window\n}\n\n/**\n * Tool for clicking elements on web pages\n */\nclass ClickTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.CLICK;\n\n  /**\n   * Execute click operation\n   */\n  async execute(args: ClickToolParams): Promise<ToolResult> {\n    const {\n      selector,\n      selectorType = 'css',\n      coordinates,\n      waitForNavigation = false,\n      timeout = TIMEOUTS.DEFAULT_WAIT * 5,\n      frameId,\n      button,\n      bubbles,\n      cancelable,\n      modifiers,\n    } = args;\n\n    console.log(`Starting click operation with options:`, args);\n\n    if (!selector && !coordinates && !args.ref) {\n      return createErrorResponse(\n        ERROR_MESSAGES.INVALID_PARAMETERS + ': Provide ref or selector or coordinates',\n      );\n    }\n\n    try {\n      // Resolve tab\n      const explicit = await this.tryGetTab(args.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n      if (!tab.id) {\n        return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n      }\n\n      let finalRef = args.ref;\n      let finalSelector = selector;\n\n      // If selector is XPath, convert to ref first\n      if (selector && selectorType === 'xpath') {\n        await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n        try {\n          const resolved = await this.sendMessageToTab(\n            tab.id,\n            {\n              action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n              selector,\n              isXPath: true,\n            },\n            frameId,\n          );\n          if (resolved && resolved.success && resolved.ref) {\n            finalRef = resolved.ref;\n            finalSelector = undefined; // Use ref instead of selector\n          } else {\n            return createErrorResponse(\n              `Failed to resolve XPath selector: ${resolved?.error || 'unknown error'}`,\n            );\n          }\n        } catch (error) {\n          return createErrorResponse(\n            `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`,\n          );\n        }\n      }\n\n      await this.injectContentScript(tab.id, ['inject-scripts/click-helper.js']);\n\n      // Send click message to content script\n      const result = await this.sendMessageToTab(\n        tab.id,\n        {\n          action: TOOL_MESSAGE_TYPES.CLICK_ELEMENT,\n          selector: finalSelector,\n          coordinates,\n          ref: finalRef,\n          waitForNavigation,\n          timeout,\n          double: args.double === true,\n          button,\n          bubbles,\n          cancelable,\n          modifiers,\n        },\n        frameId,\n      );\n\n      // Determine actual click method used\n      let clickMethod: string;\n      if (coordinates) {\n        clickMethod = 'coordinates';\n      } else if (finalRef) {\n        clickMethod = 'ref';\n      } else if (finalSelector) {\n        clickMethod = 'selector';\n      } else {\n        clickMethod = 'unknown';\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: result.message || 'Click operation successful',\n              elementInfo: result.elementInfo,\n              navigationOccurred: result.navigationOccurred,\n              clickMethod,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in click operation:', error);\n      return createErrorResponse(\n        `Error performing click: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const clickTool = new ClickTool();\n\ninterface FillToolParams {\n  selector?: string;\n  selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')\n  ref?: string; // Element ref from accessibility tree\n  // Accept string | number | boolean for broader form input coverage\n  value: string | number | boolean;\n  frameId?: number;\n  tabId?: number; // target existing tab id\n  windowId?: number; // when no tabId, pick active tab from this window\n}\n\n/**\n * Tool for filling form elements on web pages\n */\nclass FillTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.FILL;\n\n  /**\n   * Execute fill operation\n   */\n  async execute(args: FillToolParams): Promise<ToolResult> {\n    const { selector, selectorType = 'css', ref, value, frameId } = args;\n\n    console.log(`Starting fill operation with options:`, args);\n\n    if (!selector && !ref) {\n      return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Provide ref or selector');\n    }\n\n    if (value === undefined || value === null) {\n      return createErrorResponse(ERROR_MESSAGES.INVALID_PARAMETERS + ': Value must be provided');\n    }\n\n    try {\n      const explicit = await this.tryGetTab(args.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n      if (!tab.id) {\n        return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n      }\n\n      let finalRef = ref;\n      let finalSelector = selector;\n\n      // If selector is XPath, convert to ref first\n      if (selector && selectorType === 'xpath') {\n        await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n        try {\n          const resolved = await this.sendMessageToTab(\n            tab.id,\n            {\n              action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n              selector,\n              isXPath: true,\n            },\n            frameId,\n          );\n          if (resolved && resolved.success && resolved.ref) {\n            finalRef = resolved.ref;\n            finalSelector = undefined; // Use ref instead of selector\n          } else {\n            return createErrorResponse(\n              `Failed to resolve XPath selector: ${resolved?.error || 'unknown error'}`,\n            );\n          }\n        } catch (error) {\n          return createErrorResponse(\n            `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`,\n          );\n        }\n      }\n\n      await this.injectContentScript(tab.id, ['inject-scripts/fill-helper.js']);\n\n      // Send fill message to content script\n      const result = await this.sendMessageToTab(\n        tab.id,\n        {\n          action: TOOL_MESSAGE_TYPES.FILL_ELEMENT,\n          selector: finalSelector,\n          ref: finalRef,\n          value,\n        },\n        frameId,\n      );\n\n      if (result && result.error) {\n        return createErrorResponse(result.error);\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: result.message || 'Fill operation successful',\n              elementInfo: result.elementInfo,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in fill operation:', error);\n      return createErrorResponse(\n        `Error filling element: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const fillTool = new FillTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/javascript.ts",
    "content": "/**\n * JavaScript Tool - CDP Runtime.evaluate with fallback\n *\n * Execute JavaScript in the browser tab and return the result.\n * - Primary: CDP Runtime.evaluate (supports awaitPromise + returnByValue)\n * - Fallback: chrome.scripting.executeScript (when debugger is busy)\n *\n * Features:\n * - Async code support (top-level await via async wrapper)\n * - Output sanitization (sensitive data redaction)\n * - Output truncation (configurable max bytes)\n * - Timeout handling\n * - Detailed error classification\n */\n\nimport { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport {\n  DEFAULT_MAX_OUTPUT_BYTES,\n  sanitizeAndLimitOutput,\n  sanitizeText,\n} from '@/utils/output-sanitizer';\n\n// ============================================================================\n// Constants\n// ============================================================================\n\nconst DEFAULT_TIMEOUT_MS = 15_000;\nconst CDP_SESSION_KEY = 'javascript';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype ExecutionEngine = 'cdp' | 'scripting';\n\ntype ErrorKind =\n  | 'debugger_conflict'\n  | 'timeout'\n  | 'syntax_error'\n  | 'runtime_error'\n  | 'cdp_error'\n  | 'scripting_error';\n\ninterface JavaScriptToolParams {\n  code: string;\n  tabId?: number;\n  timeoutMs?: number;\n  maxOutputBytes?: number;\n}\n\ninterface ExecutionError {\n  kind: ErrorKind;\n  message: string;\n  details?: {\n    url?: string;\n    lineNumber?: number;\n    columnNumber?: number;\n  };\n}\n\ninterface ExecutionMetrics {\n  elapsedMs: number;\n}\n\ninterface JavaScriptToolResult {\n  success: boolean;\n  tabId: number;\n  engine: ExecutionEngine;\n  result?: string;\n  truncated?: boolean;\n  redacted?: boolean;\n  warnings?: string[];\n  error?: ExecutionError;\n  metrics?: ExecutionMetrics;\n}\n\ninterface ExecutionOptions {\n  timeoutMs: number;\n  maxOutputBytes: number;\n}\n\n// Discriminated union for execution results\ntype ExecutionSuccess = {\n  ok: true;\n  engine: ExecutionEngine;\n  output: string;\n  truncated: boolean;\n  redacted: boolean;\n};\n\ntype ExecutionFailure = {\n  ok: false;\n  engine: ExecutionEngine;\n  error: ExecutionError;\n};\n\ntype ExecutionResult = ExecutionSuccess | ExecutionFailure;\n\n// ============================================================================\n// Timeout Error\n// ============================================================================\n\nclass TimeoutError extends Error {\n  constructor(timeoutMs: number) {\n    super(`Execution timed out after ${timeoutMs}ms`);\n    this.name = 'TimeoutError';\n  }\n}\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\nfunction normalizePositiveInt(value: unknown, fallback: number): number {\n  if (typeof value !== 'number' || !Number.isFinite(value)) {\n    return fallback;\n  }\n  return Math.max(1, Math.floor(value));\n}\n\nfunction withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {\n  return new Promise<T>((resolve, reject) => {\n    const timer = setTimeout(() => {\n      reject(new TimeoutError(timeoutMs));\n    }, timeoutMs);\n\n    promise\n      .then(resolve)\n      .catch(reject)\n      .finally(() => clearTimeout(timer));\n  });\n}\n\nfunction isTimeoutError(error: unknown): error is TimeoutError {\n  return error instanceof Error && error.name === 'TimeoutError';\n}\n\nfunction isDebuggerConflictError(error: unknown): boolean {\n  const message = error instanceof Error ? error.message : String(error);\n  return /Debugger is already attached|Another debugger is already attached|Cannot attach to this target/i.test(\n    message,\n  );\n}\n\n/**\n * Wrap user code in an async IIFE to support top-level await and return statements.\n */\nfunction wrapUserCode(code: string): string {\n  return `(async () => {\\n${code}\\n})()`;\n}\n\n// ============================================================================\n// CDP Execution\n// ============================================================================\n\ninterface CDPRemoteObject {\n  type?: string;\n  subtype?: string;\n  value?: unknown;\n  unserializableValue?: string;\n  description?: string;\n}\n\ninterface CDPExceptionDetails {\n  text?: string;\n  url?: string;\n  lineNumber?: number;\n  columnNumber?: number;\n  exception?: {\n    className?: string;\n    description?: string;\n    value?: string;\n  };\n}\n\ninterface CDPEvaluateResult {\n  result?: CDPRemoteObject;\n  exceptionDetails?: CDPExceptionDetails;\n}\n\nfunction extractReturnValue(remoteObject?: CDPRemoteObject): unknown {\n  if (!remoteObject) return undefined;\n\n  if ('value' in remoteObject) return remoteObject.value;\n  if ('unserializableValue' in remoteObject) return remoteObject.unserializableValue;\n  if (typeof remoteObject.description === 'string') return remoteObject.description;\n\n  return undefined;\n}\n\nfunction parseExceptionDetails(details: CDPExceptionDetails): ExecutionError {\n  const exceptionClassName = details.exception?.className ?? '';\n  const exceptionDescription = details.exception?.description ?? '';\n  const exceptionValue = details.exception?.value ?? '';\n  const text = details.text ?? '';\n\n  // Determine the raw error message\n  const rawMessage =\n    exceptionDescription || exceptionValue || text || 'JavaScript execution failed';\n\n  // Sanitize the message\n  const message = sanitizeText(rawMessage).text;\n\n  // Classify the error kind\n  const isSyntaxError = exceptionClassName === 'SyntaxError' || /SyntaxError/i.test(rawMessage);\n\n  return {\n    kind: isSyntaxError ? 'syntax_error' : 'runtime_error',\n    message,\n    details: {\n      url: details.url,\n      lineNumber: details.lineNumber,\n      columnNumber: details.columnNumber,\n    },\n  };\n}\n\nasync function executeViaCdp(\n  tabId: number,\n  code: string,\n  options: ExecutionOptions,\n): Promise<ExecutionResult> {\n  try {\n    const expression = wrapUserCode(code);\n\n    const response = await withTimeout(\n      cdpSessionManager.withSession(tabId, CDP_SESSION_KEY, async () => {\n        return (await cdpSessionManager.sendCommand(tabId, 'Runtime.evaluate', {\n          expression,\n          returnByValue: true,\n          awaitPromise: true,\n          // CDP 内置超时（毫秒），与外层 withTimeout 双重保障\n          timeout: options.timeoutMs,\n        })) as CDPEvaluateResult;\n      }),\n      // 外层超时稍长，给 CDP 一点余量处理超时响应\n      options.timeoutMs + 1000,\n    );\n\n    // Check for exception\n    if (response?.exceptionDetails) {\n      return {\n        ok: false,\n        engine: 'cdp',\n        error: parseExceptionDetails(response.exceptionDetails),\n      };\n    }\n\n    // Extract and sanitize the result\n    const value = extractReturnValue(response?.result);\n    const sanitized = sanitizeAndLimitOutput(value, { maxBytes: options.maxOutputBytes });\n\n    return {\n      ok: true,\n      engine: 'cdp',\n      output: sanitized.text,\n      truncated: sanitized.truncated,\n      redacted: sanitized.redacted,\n    };\n  } catch (error) {\n    if (isTimeoutError(error)) {\n      return {\n        ok: false,\n        engine: 'cdp',\n        error: { kind: 'timeout', message: error.message },\n      };\n    }\n\n    if (isDebuggerConflictError(error)) {\n      const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;\n      return {\n        ok: false,\n        engine: 'cdp',\n        error: { kind: 'debugger_conflict', message },\n      };\n    }\n\n    const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;\n    return {\n      ok: false,\n      engine: 'cdp',\n      error: { kind: 'cdp_error', message },\n    };\n  }\n}\n\n// ============================================================================\n// chrome.scripting.executeScript Fallback\n// ============================================================================\n\ninterface ScriptingExecutionResult {\n  ok: boolean;\n  value?: unknown;\n  error?: {\n    name?: string;\n    message?: string;\n    stack?: string;\n  };\n}\n\nasync function executeViaScripting(\n  tabId: number,\n  code: string,\n  options: ExecutionOptions,\n): Promise<ExecutionResult> {\n  const innerExecute = async (): Promise<ExecutionResult> => {\n    const results = await chrome.scripting.executeScript({\n      target: { tabId },\n      world: 'ISOLATED',\n      func: async (userCode: string): Promise<ScriptingExecutionResult> => {\n        try {\n          // Use AsyncFunction constructor to support top-level await\n\n          const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;\n          const fn = new AsyncFunction(userCode);\n          const value = await fn();\n          return { ok: true, value };\n        } catch (err: unknown) {\n          const error = err as Error;\n          return {\n            ok: false,\n            error: {\n              name: error?.name ?? undefined,\n              message: error?.message ?? String(err),\n              stack: error?.stack ?? undefined,\n            },\n          };\n        }\n      },\n      args: [code],\n    });\n\n    // Extract the first result\n    const firstFrame = results?.[0];\n    const result = (firstFrame as { result?: ScriptingExecutionResult })?.result;\n\n    if (!result || typeof result !== 'object') {\n      return {\n        ok: false,\n        engine: 'scripting',\n        error: { kind: 'scripting_error', message: 'No result returned from executeScript' },\n      };\n    }\n\n    if (!result.ok) {\n      const rawMessage = result.error?.message ?? 'JavaScript execution failed';\n      const rawStack = result.error?.stack;\n\n      const message = sanitizeText(rawMessage).text;\n      const sanitizedStack = rawStack ? sanitizeText(rawStack).text : undefined;\n\n      const isSyntaxError = result.error?.name === 'SyntaxError' || /SyntaxError/i.test(rawMessage);\n\n      return {\n        ok: false,\n        engine: 'scripting',\n        error: {\n          kind: isSyntaxError ? 'syntax_error' : 'runtime_error',\n          message: sanitizedStack ? `${message}\\n${sanitizedStack}` : message,\n        },\n      };\n    }\n\n    // Sanitize the successful result\n    const sanitized = sanitizeAndLimitOutput(result.value, { maxBytes: options.maxOutputBytes });\n\n    return {\n      ok: true,\n      engine: 'scripting',\n      output: sanitized.text,\n      truncated: sanitized.truncated,\n      redacted: sanitized.redacted,\n    };\n  };\n\n  try {\n    return await withTimeout(innerExecute(), options.timeoutMs);\n  } catch (error) {\n    if (isTimeoutError(error)) {\n      return {\n        ok: false,\n        engine: 'scripting',\n        error: { kind: 'timeout', message: error.message },\n      };\n    }\n\n    const message = sanitizeText(error instanceof Error ? error.message : String(error)).text;\n    return {\n      ok: false,\n      engine: 'scripting',\n      error: { kind: 'scripting_error', message },\n    };\n  }\n}\n\n// ============================================================================\n// Tool Implementation\n// ============================================================================\n\nclass JavaScriptTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.JAVASCRIPT;\n\n  async execute(args: JavaScriptToolParams): Promise<ToolResult> {\n    const startTime = performance.now();\n\n    try {\n      // Validate required parameter\n      const code = typeof args?.code === 'string' ? args.code.trim() : '';\n      if (!code) {\n        return createErrorResponse('Parameter [code] is required');\n      }\n\n      // Resolve target tab\n      const tab = await this.resolveTargetTab(args.tabId);\n      if (!tab) {\n        return createErrorResponse(\n          typeof args.tabId === 'number' ? `Tab not found: ${args.tabId}` : 'No active tab found',\n        );\n      }\n\n      if (!tab.id) {\n        return createErrorResponse('Tab has no ID');\n      }\n      const tabId = tab.id;\n\n      // Normalize options\n      const options: ExecutionOptions = {\n        timeoutMs: normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS),\n        maxOutputBytes: normalizePositiveInt(args.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),\n      };\n\n      const warnings: string[] = [];\n\n      // Try CDP execution first\n      const cdpResult = await executeViaCdp(tabId, code, options);\n\n      if (cdpResult.ok) {\n        return this.buildSuccessResponse(tabId, cdpResult, startTime);\n      }\n\n      // If not a debugger conflict, return the CDP error\n      if (cdpResult.error.kind !== 'debugger_conflict') {\n        return this.buildErrorResponse(tabId, cdpResult, startTime);\n      }\n\n      // Debugger conflict - fallback to scripting API\n      warnings.push(\n        'Debugger is busy (DevTools or another extension attached). Falling back to chrome.scripting.executeScript (runs in ISOLATED world, not page context).',\n      );\n\n      const scriptingResult = await executeViaScripting(tabId, code, options);\n\n      if (scriptingResult.ok) {\n        return this.buildSuccessResponse(tabId, scriptingResult, startTime, warnings);\n      }\n\n      return this.buildErrorResponse(tabId, scriptingResult, startTime, warnings);\n    } catch (error) {\n      console.error('JavaScriptTool.execute error:', error);\n      return createErrorResponse(\n        `JavaScript tool error: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  private async resolveTargetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {\n    if (typeof tabId === 'number') {\n      return this.tryGetTab(tabId);\n    }\n    try {\n      return await this.getActiveTabOrThrow();\n    } catch {\n      return null;\n    }\n  }\n\n  private buildSuccessResponse(\n    tabId: number,\n    result: ExecutionSuccess,\n    startTime: number,\n    warnings?: string[],\n  ): ToolResult {\n    const payload: JavaScriptToolResult = {\n      success: true,\n      tabId,\n      engine: result.engine,\n      result: result.output,\n      truncated: result.truncated || undefined,\n      redacted: result.redacted || undefined,\n      warnings: warnings?.length ? warnings : undefined,\n      metrics: { elapsedMs: Math.round(performance.now() - startTime) },\n    };\n\n    return {\n      content: [{ type: 'text', text: JSON.stringify(payload) }],\n      isError: false,\n    };\n  }\n\n  private buildErrorResponse(\n    tabId: number,\n    result: ExecutionFailure,\n    startTime: number,\n    warnings?: string[],\n  ): ToolResult {\n    const payload: JavaScriptToolResult = {\n      success: false,\n      tabId,\n      engine: result.engine,\n      error: result.error,\n      warnings: warnings?.length ? warnings : undefined,\n      metrics: { elapsedMs: Math.round(performance.now() - startTime) },\n    };\n\n    return {\n      content: [{ type: 'text', text: JSON.stringify(payload) }],\n      isError: true,\n    };\n  }\n}\n\nexport const javascriptTool = new JavaScriptTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/keyboard.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { TIMEOUTS, ERROR_MESSAGES } from '@/common/constants';\n\ninterface KeyboardToolParams {\n  keys: string; // Required: string representing keys or key combinations to simulate (e.g., \"Enter\", \"Ctrl+C\")\n  selector?: string; // Optional: CSS selector or XPath for target element to send keyboard events to\n  selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')\n  delay?: number; // Optional: delay between keystrokes in milliseconds\n  tabId?: number; // target existing tab id\n  windowId?: number; // when no tabId, pick active tab from this window\n  frameId?: number; // target frame id for iframe support\n}\n\n/**\n * Tool for simulating keyboard input on web pages\n */\nclass KeyboardTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.KEYBOARD;\n\n  /**\n   * Execute keyboard operation\n   */\n  async execute(args: KeyboardToolParams): Promise<ToolResult> {\n    const { keys, selector, selectorType = 'css', delay = TIMEOUTS.KEYBOARD_DELAY } = args;\n\n    console.log(`Starting keyboard operation with options:`, args);\n\n    if (!keys) {\n      return createErrorResponse(\n        ERROR_MESSAGES.INVALID_PARAMETERS + ': Keys parameter must be provided',\n      );\n    }\n\n    try {\n      const explicit = await this.tryGetTab(args.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n      if (!tab.id) {\n        return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n      }\n\n      let finalSelector = selector;\n      let refForFocus: string | undefined = undefined;\n\n      // Ensure helper is loaded for XPath or potential focus operations\n      await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);\n\n      // If selector is XPath, convert to ref then try to get CSS selector\n      if (selector && selectorType === 'xpath') {\n        try {\n          // First convert XPath to ref\n          const ensured = await this.sendMessageToTab(tab.id, {\n            action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n            selector,\n            isXPath: true,\n          });\n          if (!ensured || !ensured.success || !ensured.ref) {\n            return createErrorResponse(\n              `Failed to resolve XPath selector: ${ensured?.error || 'unknown error'}`,\n            );\n          }\n          refForFocus = ensured.ref;\n          // Try to resolve ref to CSS selector\n          const resolved = await this.sendMessageToTab(tab.id, {\n            action: TOOL_MESSAGE_TYPES.RESOLVE_REF,\n            ref: ensured.ref,\n          });\n          if (resolved && resolved.success && resolved.selector) {\n            finalSelector = resolved.selector;\n            refForFocus = undefined; // Prefer CSS selector if available\n          }\n          // If no CSS selector available, we'll use ref to focus below\n        } catch (error) {\n          return createErrorResponse(\n            `Error resolving XPath: ${error instanceof Error ? error.message : String(error)}`,\n          );\n        }\n      }\n\n      // If we have a ref but no CSS selector, focus the element via helper\n      if (refForFocus) {\n        const focusResult = await this.sendMessageToTab(tab.id, {\n          action: 'focusByRef',\n          ref: refForFocus,\n        });\n        if (focusResult && !focusResult.success) {\n          return createErrorResponse(\n            `Failed to focus element by ref: ${focusResult.error || 'unknown error'}`,\n          );\n        }\n        // Clear selector so keyboard events go to the focused element\n        finalSelector = undefined;\n      }\n\n      const frameIds = typeof args.frameId === 'number' ? [args.frameId] : undefined;\n      await this.injectContentScript(\n        tab.id,\n        ['inject-scripts/keyboard-helper.js'],\n        false,\n        'ISOLATED',\n        false,\n        frameIds,\n      );\n\n      // Send keyboard simulation message to content script\n      const result = await this.sendMessageToTab(\n        tab.id,\n        {\n          action: TOOL_MESSAGE_TYPES.SIMULATE_KEYBOARD,\n          keys,\n          selector: finalSelector,\n          delay,\n        },\n        args.frameId,\n      );\n\n      if (result.error) {\n        return createErrorResponse(result.error);\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: result.message || 'Keyboard operation successful',\n              targetElement: result.targetElement,\n              results: result.results,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in keyboard operation:', error);\n      return createErrorResponse(\n        `Error simulating keyboard events: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const keyboardTool = new KeyboardTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\nimport { NETWORK_FILTERS } from '@/common/constants';\n\ninterface NetworkDebuggerStartToolParams {\n  url?: string; // URL to navigate to or focus. If not provided, uses active tab.\n  maxCaptureTime?: number;\n  inactivityTimeout?: number; // Inactivity timeout (milliseconds)\n  includeStatic?: boolean; // if include static resources\n}\n\n// Network request object interface\ninterface NetworkRequestInfo {\n  requestId: string;\n  url: string;\n  method: string;\n  requestHeaders?: Record<string, string>; // Will be removed after common headers extraction\n  responseHeaders?: Record<string, string>; // Will be removed after common headers extraction\n  requestTime?: number; // Timestamp of the request\n  responseTime?: number; // Timestamp of the response\n  type: string; // Resource type (e.g., Document, XHR, Fetch, Script, Stylesheet)\n  status: string; // 'pending', 'complete', 'error'\n  statusCode?: number;\n  statusText?: string;\n  requestBody?: string;\n  responseBody?: string;\n  base64Encoded?: boolean; // For responseBody\n  encodedDataLength?: number; // Actual bytes received\n  errorText?: string; // If loading failed\n  canceled?: boolean; // If loading was canceled\n  mimeType?: string;\n  specificRequestHeaders?: Record<string, string>; // Headers unique to this request\n  specificResponseHeaders?: Record<string, string>; // Headers unique to this response\n  [key: string]: any; // Allow other properties from debugger events\n}\n\nconst DEBUGGER_PROTOCOL_VERSION = '1.3';\nconst MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB\nconst DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes\nconst DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute\n\n/**\n * Network capture start tool - uses Chrome Debugger API to start capturing network requests\n */\nclass NetworkDebuggerStartTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START;\n  private captureData: Map<number, any> = new Map(); // tabId -> capture data\n  private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer\n  private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer\n  private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last network activity\n  private pendingResponseBodies: Map<string, Promise<any>> = new Map(); // requestId -> promise for getResponseBody\n  private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests (after filtering)\n  private static MAX_REQUESTS_PER_CAPTURE = 100; // Max requests to store to prevent memory issues\n  public static instance: NetworkDebuggerStartTool | null = null;\n\n  constructor() {\n    super();\n    if (NetworkDebuggerStartTool.instance) {\n      return NetworkDebuggerStartTool.instance;\n    }\n    NetworkDebuggerStartTool.instance = this;\n\n    chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));\n    chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));\n    chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));\n    chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));\n  }\n\n  private handleTabRemoved(tabId: number) {\n    if (this.captureData.has(tabId)) {\n      console.log(`NetworkDebuggerStartTool: Tab ${tabId} was closed, cleaning up resources.`);\n      this.cleanupCapture(tabId);\n    }\n  }\n\n  /**\n   * Handle tab creation events\n   * If a new tab is opened from a tab that is currently capturing, automatically start capturing the new tab's requests\n   */\n  private async handleTabCreated(tab: chrome.tabs.Tab) {\n    try {\n      // Check if there are any tabs currently capturing\n      if (this.captureData.size === 0) return;\n\n      // Get the openerTabId of the new tab (ID of the tab that opened this tab)\n      const openerTabId = tab.openerTabId;\n      if (!openerTabId) return;\n\n      // Check if the opener tab is currently capturing\n      if (!this.captureData.has(openerTabId)) return;\n\n      // Get the new tab's ID\n      const newTabId = tab.id;\n      if (!newTabId) return;\n\n      console.log(\n        `NetworkDebuggerStartTool: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,\n      );\n\n      // Get the opener tab's capture settings\n      const openerCaptureInfo = this.captureData.get(openerTabId);\n      if (!openerCaptureInfo) return;\n\n      // Wait a short time to ensure the tab is ready\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      // Start capturing requests for the new tab\n      await this.startCaptureForTab(newTabId, {\n        maxCaptureTime: openerCaptureInfo.maxCaptureTime,\n        inactivityTimeout: openerCaptureInfo.inactivityTimeout,\n        includeStatic: openerCaptureInfo.includeStatic,\n      });\n\n      console.log(`NetworkDebuggerStartTool: Successfully extended capture to new tab ${newTabId}`);\n    } catch (error) {\n      console.error(`NetworkDebuggerStartTool: Error extending capture to new tab:`, error);\n    }\n  }\n\n  /**\n   * Start network request capture for specified tab\n   * @param tabId Tab ID\n   * @param options Capture options\n   */\n  private async startCaptureForTab(\n    tabId: number,\n    options: {\n      maxCaptureTime: number;\n      inactivityTimeout: number;\n      includeStatic: boolean;\n    },\n  ): Promise<void> {\n    const { maxCaptureTime, inactivityTimeout, includeStatic } = options;\n\n    // If already capturing, stop first\n    if (this.captureData.has(tabId)) {\n      console.log(\n        `NetworkDebuggerStartTool: Already capturing on tab ${tabId}. Stopping previous session.`,\n      );\n      await this.stopCapture(tabId);\n    }\n\n    try {\n      // Get tab information\n      const tab = await chrome.tabs.get(tabId);\n\n      // Attach via shared manager (handles conflicts and refcount)\n      await cdpSessionManager.attach(tabId, 'network-capture');\n\n      // Enable network tracking\n      try {\n        await cdpSessionManager.sendCommand(tabId, 'Network.enable');\n      } catch (error: any) {\n        await cdpSessionManager\n          .detach(tabId, 'network-capture')\n          .catch((e) => console.warn('Error detaching after failed enable:', e));\n        throw error;\n      }\n\n      // Initialize capture data\n      this.captureData.set(tabId, {\n        startTime: Date.now(),\n        tabUrl: tab.url,\n        tabTitle: tab.title,\n        maxCaptureTime,\n        inactivityTimeout,\n        includeStatic,\n        requests: {},\n        limitReached: false,\n      });\n\n      // Initialize request counter\n      this.requestCounters.set(tabId, 0);\n\n      // Update last activity time\n      this.updateLastActivityTime(tabId);\n\n      console.log(\n        `NetworkDebuggerStartTool: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,\n      );\n\n      // Set maximum capture time\n      if (maxCaptureTime > 0) {\n        this.captureTimers.set(\n          tabId,\n          setTimeout(async () => {\n            console.log(\n              `NetworkDebuggerStartTool: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,\n            );\n            await this.stopCapture(tabId, true); // Auto-stop due to max time\n          }, maxCaptureTime),\n        );\n      }\n    } catch (error: any) {\n      console.error(`NetworkDebuggerStartTool: Error starting capture for tab ${tabId}:`, error);\n\n      // Clean up resources\n      if (this.captureData.has(tabId)) {\n        await cdpSessionManager\n          .detach(tabId, 'network-capture')\n          .catch((e) => console.warn('Cleanup detach error:', e));\n        this.cleanupCapture(tabId);\n      }\n\n      throw error;\n    }\n  }\n\n  private handleDebuggerEvent(source: chrome.debugger.Debuggee, method: string, params?: any) {\n    if (!source.tabId) return;\n\n    const tabId = source.tabId;\n    const captureInfo = this.captureData.get(tabId);\n\n    if (!captureInfo) return; // Not capturing for this tab\n\n    // Update last activity time for any relevant network event\n    this.updateLastActivityTime(tabId);\n\n    switch (method) {\n      case 'Network.requestWillBeSent':\n        this.handleRequestWillBeSent(tabId, params);\n        break;\n      case 'Network.responseReceived':\n        this.handleResponseReceived(tabId, params);\n        break;\n      case 'Network.loadingFinished':\n        this.handleLoadingFinished(tabId, params);\n        break;\n      case 'Network.loadingFailed':\n        this.handleLoadingFailed(tabId, params);\n        break;\n    }\n  }\n\n  private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string) {\n    if (source.tabId && this.captureData.has(source.tabId)) {\n      console.log(\n        `NetworkDebuggerStartTool: Debugger detached from tab ${source.tabId}, reason: ${reason}. Cleaning up.`,\n      );\n      // Potentially inform the user or log the result if the detachment was unexpected\n      this.cleanupCapture(source.tabId); // Ensure cleanup happens\n    }\n  }\n\n  private updateLastActivityTime(tabId: number) {\n    this.lastActivityTime.set(tabId, Date.now());\n    const captureInfo = this.captureData.get(tabId);\n\n    if (captureInfo && captureInfo.inactivityTimeout > 0) {\n      if (this.inactivityTimers.has(tabId)) {\n        clearTimeout(this.inactivityTimers.get(tabId)!);\n      }\n      this.inactivityTimers.set(\n        tabId,\n        setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),\n      );\n    }\n  }\n\n  private checkInactivity(tabId: number) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; // Use startTime if no activity yet\n    const now = Date.now();\n    const inactiveTime = now - lastActivity;\n\n    if (inactiveTime >= captureInfo.inactivityTimeout) {\n      console.log(\n        `NetworkDebuggerStartTool: No activity for ${inactiveTime}ms (threshold: ${captureInfo.inactivityTimeout}ms), stopping capture for tab ${tabId}`,\n      );\n      this.stopCaptureByInactivity(tabId);\n    } else {\n      // Reschedule check for the remaining time, this handles system sleep or other interruptions\n      const remainingTime = Math.max(0, captureInfo.inactivityTimeout - inactiveTime);\n      this.inactivityTimers.set(\n        tabId,\n        setTimeout(() => this.checkInactivity(tabId), remainingTime),\n      );\n    }\n  }\n\n  private async stopCaptureByInactivity(tabId: number) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    console.log(`NetworkDebuggerStartTool: Stopping capture due to inactivity for tab ${tabId}.`);\n    // Potentially, we might want to notify the client/user that this happened.\n    // For now, just stop and make the results available if StopTool is called.\n    await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop\n  }\n\n  /**\n   * Check if URL should be filtered based on EXCLUDED_DOMAINS patterns.\n   * Uses full URL substring match to support patterns like 'facebook.com/tr'.\n   */\n  private shouldFilterRequestByUrl(url: string): boolean {\n    const normalizedUrl = String(url || '').toLowerCase();\n    if (!normalizedUrl) return false;\n    return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern));\n  }\n\n  private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean {\n    if (includeStatic) return false;\n\n    try {\n      const urlObj = new URL(url);\n      const path = urlObj.pathname.toLowerCase();\n      return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));\n    } catch {\n      return false;\n    }\n  }\n\n  private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {\n    if (!mimeType) return false;\n\n    // Never filter API MIME types\n    if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) {\n      return false;\n    }\n\n    // Filter static MIME types when not including static resources\n    if (!includeStatic) {\n      return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>\n        mimeType.startsWith(staticMime),\n      );\n    }\n\n    return false;\n  }\n\n  private handleRequestWillBeSent(tabId: number, params: any) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const { requestId, request, timestamp, type, loaderId, frameId } = params;\n\n    // Initial filtering by URL (ads, analytics) and extension (if !includeStatic)\n    if (\n      this.shouldFilterRequestByUrl(request.url) ||\n      this.shouldFilterRequestByExtension(request.url, captureInfo.includeStatic)\n    ) {\n      return;\n    }\n\n    const currentCount = this.requestCounters.get(tabId) || 0;\n    if (currentCount >= NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE) {\n      // console.log(`NetworkDebuggerStartTool: Request limit (${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${tabId}. Ignoring: ${request.url}`);\n      captureInfo.limitReached = true; // Mark that limit was hit\n      return;\n    }\n\n    // Store initial request info\n    // Ensure we don't overwrite if a redirect (same requestId) occurred, though usually loaderId changes\n    if (!captureInfo.requests[requestId]) {\n      // Or check based on loaderId as well if needed\n      captureInfo.requests[requestId] = {\n        requestId,\n        url: request.url,\n        method: request.method,\n        requestHeaders: request.headers, // Temporary, will be processed\n        requestTime: timestamp * 1000, // Convert seconds to milliseconds\n        type: type || 'Other',\n        status: 'pending', // Initial status\n        loaderId, // Useful for tracking redirects\n        frameId, // Useful for context\n      };\n\n      if (request.postData) {\n        captureInfo.requests[requestId].requestBody = request.postData;\n      }\n      // console.log(`NetworkDebuggerStartTool: Captured request for tab ${tabId}: ${request.method} ${request.url}`);\n    } else {\n      // This could be a redirect. Update URL and other relevant fields.\n      // Chrome often issues a new `requestWillBeSent` for redirects with the same `requestId` but a new `loaderId`.\n      // console.log(`NetworkDebuggerStartTool: Request ${requestId} updated (likely redirect) for tab ${tabId} to URL: ${request.url}`);\n      const existingRequest = captureInfo.requests[requestId];\n      existingRequest.url = request.url; // Update URL due to redirect\n      existingRequest.requestTime = timestamp * 1000; // Update time for the redirected request\n      if (request.headers) existingRequest.requestHeaders = request.headers;\n      if (request.postData) existingRequest.requestBody = request.postData;\n      else delete existingRequest.requestBody;\n    }\n  }\n\n  private handleResponseReceived(tabId: number, params: any) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const { requestId, response, timestamp, type } = params; // type here is resource type\n    const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];\n\n    if (!requestInfo) {\n      // console.warn(`NetworkDebuggerStartTool: Received response for unknown requestId ${requestId} on tab ${tabId}`);\n      return;\n    }\n\n    // Secondary filtering based on MIME type, now that we have it\n    if (this.shouldFilterByMimeType(response.mimeType, captureInfo.includeStatic)) {\n      // console.log(`NetworkDebuggerStartTool: Filtering request by MIME type (${response.mimeType}): ${requestInfo.url}`);\n      delete captureInfo.requests[requestId]; // Remove from captured data\n      // Note: We don't decrement requestCounter here as it's meant to track how many *potential* requests were processed up to MAX_REQUESTS.\n      // Or, if MAX_REQUESTS is strictly for *stored* requests, then decrement. For now, let's assume it's for stored.\n      // const currentCount = this.requestCounters.get(tabId) || 0;\n      // if (currentCount > 0) this.requestCounters.set(tabId, currentCount -1);\n      return;\n    }\n\n    // If not filtered by MIME, then increment actual stored request counter\n    const currentStoredCount = Object.keys(captureInfo.requests).length; // A bit inefficient but accurate\n    this.requestCounters.set(tabId, currentStoredCount);\n\n    requestInfo.status = response.status === 0 ? 'pending' : 'complete'; // status 0 can mean pending or blocked\n    requestInfo.statusCode = response.status;\n    requestInfo.statusText = response.statusText;\n    requestInfo.responseHeaders = response.headers; // Temporary\n    requestInfo.mimeType = response.mimeType;\n    requestInfo.responseTime = timestamp * 1000; // Convert seconds to milliseconds\n    if (type) requestInfo.type = type; // Update resource type if provided by this event\n\n    // console.log(`NetworkDebuggerStartTool: Received response for ${requestId} on tab ${tabId}: ${response.status}`);\n  }\n\n  private async handleLoadingFinished(tabId: number, params: any) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const { requestId, encodedDataLength } = params;\n    const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];\n\n    if (!requestInfo) {\n      // console.warn(`NetworkDebuggerStartTool: LoadingFinished for unknown requestId ${requestId} on tab ${tabId}`);\n      return;\n    }\n\n    requestInfo.encodedDataLength = encodedDataLength;\n    if (requestInfo.status === 'pending') requestInfo.status = 'complete'; // Mark as complete if not already\n    // requestInfo.responseTime is usually set by responseReceived, but this timestamp is later.\n    // timestamp here is when the resource finished loading. Could be useful for duration calculation.\n\n    if (this.shouldCaptureResponseBody(requestInfo)) {\n      try {\n        // console.log(`NetworkDebuggerStartTool: Attempting to get response body for ${requestId} (${requestInfo.url})`);\n        const responseBodyData = await this.getResponseBody(tabId, requestId);\n        if (responseBodyData) {\n          if (\n            responseBodyData.body &&\n            responseBodyData.body.length > MAX_RESPONSE_BODY_SIZE_BYTES\n          ) {\n            requestInfo.responseBody =\n              responseBodyData.body.substring(0, MAX_RESPONSE_BODY_SIZE_BYTES) +\n              `\\n\\n... [Response truncated, total size: ${responseBodyData.body.length} bytes] ...`;\n          } else {\n            requestInfo.responseBody = responseBodyData.body;\n          }\n          requestInfo.base64Encoded = responseBodyData.base64Encoded;\n          // console.log(`NetworkDebuggerStartTool: Successfully got response body for ${requestId}, size: ${requestInfo.responseBody?.length || 0} bytes`);\n        }\n      } catch (error) {\n        // console.warn(`NetworkDebuggerStartTool: Failed to get response body for ${requestId}:`, error);\n        requestInfo.errorText =\n          (requestInfo.errorText || '') +\n          ` Failed to get body: ${error instanceof Error ? error.message : String(error)}`;\n      }\n    }\n  }\n\n  private shouldCaptureResponseBody(requestInfo: NetworkRequestInfo): boolean {\n    const mimeType = requestInfo.mimeType || '';\n\n    // Prioritize API MIME types for body capture\n    if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {\n      return true;\n    }\n\n    // Heuristics for other potential API calls not perfectly matching MIME types\n    const url = requestInfo.url.toLowerCase();\n    if (\n      /\\/(api|service|rest|graphql|query|data|rpc|v[0-9]+)\\//i.test(url) ||\n      url.includes('.json') ||\n      url.includes('json=') ||\n      url.includes('format=json')\n    ) {\n      // If it looks like an API call by URL structure, try to get body,\n      // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path)\n      if (\n        mimeType &&\n        NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>\n          mimeType.startsWith(staticMime),\n        )\n      ) {\n        return false; // e.g. a CSS file served from an /api/ path\n      }\n      return true;\n    }\n\n    return false;\n  }\n\n  private handleLoadingFailed(tabId: number, params: any) {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const { requestId, errorText, canceled, type } = params;\n    const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];\n\n    if (!requestInfo) {\n      // console.warn(`NetworkDebuggerStartTool: LoadingFailed for unknown requestId ${requestId} on tab ${tabId}`);\n      return;\n    }\n\n    requestInfo.status = 'error';\n    requestInfo.errorText = errorText;\n    requestInfo.canceled = canceled;\n    if (type) requestInfo.type = type;\n    // timestamp here is when loading failed.\n    // console.log(`NetworkDebuggerStartTool: Loading failed for ${requestId} on tab ${tabId}: ${errorText}`);\n  }\n\n  private async getResponseBody(\n    tabId: number,\n    requestId: string,\n  ): Promise<{ body: string; base64Encoded: boolean } | null> {\n    const pendingKey = `${tabId}_${requestId}`;\n    if (this.pendingResponseBodies.has(pendingKey)) {\n      return this.pendingResponseBodies.get(pendingKey)!; // Return existing promise\n    }\n\n    const responseBodyPromise = (async () => {\n      try {\n        // Will attach temporarily if needed\n        const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', {\n          requestId,\n        })) as { body: string; base64Encoded: boolean };\n        return result;\n      } finally {\n        this.pendingResponseBodies.delete(pendingKey); // Clean up after promise resolves or rejects\n      }\n    })();\n\n    this.pendingResponseBodies.set(pendingKey, responseBodyPromise);\n    return responseBodyPromise;\n  }\n\n  private cleanupCapture(tabId: number) {\n    if (this.captureTimers.has(tabId)) {\n      clearTimeout(this.captureTimers.get(tabId)!);\n      this.captureTimers.delete(tabId);\n    }\n    if (this.inactivityTimers.has(tabId)) {\n      clearTimeout(this.inactivityTimers.get(tabId)!);\n      this.inactivityTimers.delete(tabId);\n    }\n\n    this.lastActivityTime.delete(tabId);\n    this.captureData.delete(tabId);\n    this.requestCounters.delete(tabId);\n\n    // Abort pending getResponseBody calls for this tab\n    // Note: Promises themselves cannot be \"aborted\" externally in a standard way once created.\n    // We can delete them from the map, so new calls won't use them,\n    // and the original promise will eventually resolve or reject.\n    const keysToDelete: string[] = [];\n    this.pendingResponseBodies.forEach((_, key) => {\n      if (key.startsWith(`${tabId}_`)) {\n        keysToDelete.push(key);\n      }\n    });\n    keysToDelete.forEach((key) => this.pendingResponseBodies.delete(key));\n\n    console.log(`NetworkDebuggerStartTool: Cleaned up resources for tab ${tabId}.`);\n  }\n\n  // isAutoStop is true if stop was triggered by timeout, false if by user/explicit call\n  async stopCapture(tabId: number, isAutoStop: boolean = false): Promise<any> {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) {\n      return { success: false, message: 'No capture in progress for this tab.' };\n    }\n\n    console.log(\n      `NetworkDebuggerStartTool: Stopping capture for tab ${tabId}. Auto-stop: ${isAutoStop}`,\n    );\n\n    try {\n      // Attempt to disable network and detach via manager; it will no-op if others own the session\n      try {\n        await cdpSessionManager.sendCommand(tabId, 'Network.disable');\n      } catch (e) {\n        console.warn(\n          `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,\n          e,\n        );\n      }\n      try {\n        await cdpSessionManager.detach(tabId, 'network-capture');\n      } catch (e) {\n        console.warn(\n          `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,\n          e,\n        );\n      }\n    } catch (error: any) {\n      // Catch errors from getTargets or general logic\n      console.error(\n        'NetworkDebuggerStartTool: Error during debugger interaction in stopCapture:',\n        error,\n      );\n      // Proceed to cleanup and data formatting\n    }\n\n    // Process data even if detach/disable failed, as some data might have been captured.\n    const allRequests = Object.values(captureInfo.requests) as NetworkRequestInfo[];\n    const commonRequestHeaders = this.analyzeCommonHeaders(allRequests, 'requestHeaders');\n    const commonResponseHeaders = this.analyzeCommonHeaders(allRequests, 'responseHeaders');\n\n    const processedRequests = allRequests.map((req) => {\n      const finalReq: Partial<NetworkRequestInfo> &\n        Pick<NetworkRequestInfo, 'requestId' | 'url' | 'method' | 'type' | 'status'> = { ...req };\n\n      if (finalReq.requestHeaders) {\n        finalReq.specificRequestHeaders = this.filterOutCommonHeaders(\n          finalReq.requestHeaders,\n          commonRequestHeaders,\n        );\n        delete finalReq.requestHeaders; // Remove original full headers\n      } else {\n        finalReq.specificRequestHeaders = {};\n      }\n\n      if (finalReq.responseHeaders) {\n        finalReq.specificResponseHeaders = this.filterOutCommonHeaders(\n          finalReq.responseHeaders,\n          commonResponseHeaders,\n        );\n        delete finalReq.responseHeaders; // Remove original full headers\n      } else {\n        finalReq.specificResponseHeaders = {};\n      }\n      return finalReq as NetworkRequestInfo; // Cast back to full type\n    });\n\n    // Sort requests by requestTime\n    processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));\n\n    const resultData = {\n      captureStartTime: captureInfo.startTime,\n      captureEndTime: Date.now(),\n      totalDurationMs: Date.now() - captureInfo.startTime,\n      commonRequestHeaders,\n      commonResponseHeaders,\n      requests: processedRequests,\n      requestCount: processedRequests.length, // Actual stored requests\n      totalRequestsReceivedBeforeLimit: captureInfo.limitReached\n        ? NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE\n        : processedRequests.length,\n      requestLimitReached: !!captureInfo.limitReached,\n      stoppedBy: isAutoStop\n        ? this.lastActivityTime.get(tabId)\n          ? 'inactivity_timeout'\n          : 'max_capture_time'\n        : 'user_request',\n      tabUrl: captureInfo.tabUrl,\n      tabTitle: captureInfo.tabTitle,\n    };\n\n    console.log(\n      `NetworkDebuggerStartTool: Capture stopped for tab ${tabId}. ${resultData.requestCount} requests processed. Limit reached: ${resultData.requestLimitReached}. Stopped by: ${resultData.stoppedBy}`,\n    );\n\n    this.cleanupCapture(tabId); // Final cleanup of all internal states for this tab\n\n    return {\n      success: true,\n      message: `Capture stopped. ${resultData.requestCount} requests.`,\n      data: resultData,\n    };\n  }\n\n  private analyzeCommonHeaders(\n    requests: NetworkRequestInfo[],\n    headerTypeKey: 'requestHeaders' | 'responseHeaders',\n  ): Record<string, string> {\n    if (!requests || requests.length === 0) return {};\n\n    const headerValueCounts = new Map<string, Map<string, number>>(); // headerName -> (headerValue -> count)\n    let requestsWithHeadersCount = 0;\n\n    for (const req of requests) {\n      const headers = req[headerTypeKey] as Record<string, string> | undefined;\n      if (headers && Object.keys(headers).length > 0) {\n        requestsWithHeadersCount++;\n        for (const name in headers) {\n          // Normalize header name to lowercase for consistent counting\n          const lowerName = name.toLowerCase();\n          const value = headers[name];\n          if (!headerValueCounts.has(lowerName)) {\n            headerValueCounts.set(lowerName, new Map());\n          }\n          const values = headerValueCounts.get(lowerName)!;\n          values.set(value, (values.get(value) || 0) + 1);\n        }\n      }\n    }\n\n    if (requestsWithHeadersCount === 0) return {};\n\n    const commonHeaders: Record<string, string> = {};\n    headerValueCounts.forEach((values, name) => {\n      values.forEach((count, value) => {\n        if (count === requestsWithHeadersCount) {\n          // This (name, value) pair is present in all requests that have this type of headers.\n          // We need to find the original casing for the header name.\n          // This is tricky as HTTP headers are case-insensitive. Let's pick the first encountered one.\n          // A more robust way would be to store original names, but lowercase comparison is standard.\n          // For simplicity, we'll use the lowercase name for commonHeaders keys.\n          // Or, find one original casing:\n          let originalName = name;\n          for (const req of requests) {\n            const hdrs = req[headerTypeKey] as Record<string, string> | undefined;\n            if (hdrs) {\n              const foundName = Object.keys(hdrs).find((k) => k.toLowerCase() === name);\n              if (foundName) {\n                originalName = foundName;\n                break;\n              }\n            }\n          }\n          commonHeaders[originalName] = value;\n        }\n      });\n    });\n    return commonHeaders;\n  }\n\n  private filterOutCommonHeaders(\n    headers: Record<string, string>,\n    commonHeaders: Record<string, string>,\n  ): Record<string, string> {\n    if (!headers || typeof headers !== 'object') return {};\n\n    const specificHeaders: Record<string, string> = {};\n    const commonHeadersLower: Record<string, string> = {};\n\n    // Use Object.keys to avoid ESLint no-prototype-builtins warning\n    Object.keys(commonHeaders).forEach((commonName) => {\n      commonHeadersLower[commonName.toLowerCase()] = commonHeaders[commonName];\n    });\n\n    // Use Object.keys to avoid ESLint no-prototype-builtins warning\n    Object.keys(headers).forEach((name) => {\n      const lowerName = name.toLowerCase();\n      // If the header (by name, case-insensitively) is not in commonHeaders OR\n      // if its value is different from the common one, then it's specific.\n      if (!(lowerName in commonHeadersLower) || headers[name] !== commonHeadersLower[lowerName]) {\n        specificHeaders[name] = headers[name];\n      }\n    });\n\n    return specificHeaders;\n  }\n\n  async execute(args: NetworkDebuggerStartToolParams): Promise<ToolResult> {\n    const {\n      url: targetUrl,\n      maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS,\n      inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS,\n      includeStatic = false,\n    } = args;\n\n    console.log(\n      `NetworkDebuggerStartTool: Executing with args: url=${targetUrl}, maxTime=${maxCaptureTime}, inactivityTime=${inactivityTimeout}, includeStatic=${includeStatic}`,\n    );\n\n    let tabToOperateOn: chrome.tabs.Tab | undefined;\n\n    try {\n      if (targetUrl) {\n        const existingTabs = await chrome.tabs.query({\n          url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`,\n        }); // More specific query\n        if (existingTabs.length > 0 && existingTabs[0]?.id) {\n          tabToOperateOn = existingTabs[0];\n          // Ensure window gets focus and tab is truly activated\n          await chrome.windows.update(tabToOperateOn.windowId, { focused: true });\n          await chrome.tabs.update(tabToOperateOn.id!, { active: true });\n        } else {\n          tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });\n          // Wait for tab to be somewhat ready. A better way is to listen to tabs.onUpdated status='complete'\n          // but for debugger attachment, it just needs the tabId.\n          await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay\n        }\n      } else {\n        const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });\n        if (activeTabs.length > 0 && activeTabs[0]?.id) {\n          tabToOperateOn = activeTabs[0];\n        } else {\n          return createErrorResponse('No active tab found and no URL provided.');\n        }\n      }\n\n      if (!tabToOperateOn?.id) {\n        return createErrorResponse('Failed to identify or create a target tab.');\n      }\n      const tabId = tabToOperateOn.id;\n\n      // Use startCaptureForTab method to start capture\n      try {\n        await this.startCaptureForTab(tabId, {\n          maxCaptureTime,\n          inactivityTimeout,\n          includeStatic,\n        });\n      } catch (error: any) {\n        return createErrorResponse(\n          `Failed to start capture for tab ${tabId}: ${error.message || String(error)}`,\n        );\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: `Network capture started on tab ${tabId}. Waiting for stop command or timeout.`,\n              tabId,\n              url: tabToOperateOn.url,\n              maxCaptureTime,\n              inactivityTimeout,\n              includeStatic,\n              maxRequests: NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error: any) {\n      console.error('NetworkDebuggerStartTool: Critical error during execute:', error);\n      // If a tabId was involved and debugger might be attached, try to clean up.\n      const tabIdToClean = tabToOperateOn?.id;\n      if (tabIdToClean && this.captureData.has(tabIdToClean)) {\n        await cdpSessionManager\n          .detach(tabIdToClean, 'network-capture')\n          .catch((e) => console.warn('Cleanup detach error:', e));\n        this.cleanupCapture(tabIdToClean);\n      }\n      return createErrorResponse(\n        `Error in NetworkDebuggerStartTool: ${error.message || String(error)}`,\n      );\n    }\n  }\n}\n\n/**\n * Network capture stop tool - stops capture and returns results for the active tab\n */\nclass NetworkDebuggerStopTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP;\n  public static instance: NetworkDebuggerStopTool | null = null;\n\n  constructor() {\n    super();\n    if (NetworkDebuggerStopTool.instance) {\n      return NetworkDebuggerStopTool.instance;\n    }\n    NetworkDebuggerStopTool.instance = this;\n  }\n\n  async execute(): Promise<ToolResult> {\n    console.log(`NetworkDebuggerStopTool: Executing command.`);\n\n    const startTool = NetworkDebuggerStartTool.instance;\n    if (!startTool) {\n      return createErrorResponse(\n        'NetworkDebuggerStartTool instance not available. Cannot stop capture.',\n      );\n    }\n\n    // Get all tabs currently capturing\n    const ongoingCaptures = Array.from(startTool['captureData'].keys());\n    console.log(\n      `NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,\n    );\n\n    if (ongoingCaptures.length === 0) {\n      return createErrorResponse('No active network captures found in any tab.');\n    }\n\n    // Get current active tab\n    const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const activeTabId = activeTabs[0]?.id;\n\n    // Determine the primary tab to stop\n    let primaryTabId: number;\n\n    if (activeTabId && startTool['captureData'].has(activeTabId)) {\n      // If current active tab is capturing, prioritize stopping it\n      primaryTabId = activeTabId;\n      console.log(\n        `NetworkDebuggerStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,\n      );\n    } else if (ongoingCaptures.length === 1) {\n      // If only one tab is capturing, stop it\n      primaryTabId = ongoingCaptures[0];\n      console.log(\n        `NetworkDebuggerStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,\n      );\n    } else {\n      // If multiple tabs are capturing but current active tab is not among them, stop the first one\n      primaryTabId = ongoingCaptures[0];\n      console.log(\n        `NetworkDebuggerStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,\n      );\n    }\n\n    // Stop capture for the primary tab\n    const result = await this.performStop(startTool, primaryTabId);\n\n    // If multiple tabs are capturing, stop other tabs\n    if (ongoingCaptures.length > 1) {\n      const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);\n      console.log(\n        `NetworkDebuggerStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,\n      );\n\n      for (const tabId of otherTabIds) {\n        try {\n          await startTool.stopCapture(tabId);\n        } catch (error) {\n          console.error(`NetworkDebuggerStopTool: Error stopping capture on tab ${tabId}:`, error);\n        }\n      }\n    }\n\n    return result;\n  }\n\n  private async performStop(\n    startTool: NetworkDebuggerStartTool,\n    tabId: number,\n  ): Promise<ToolResult> {\n    console.log(`NetworkDebuggerStopTool: Attempting to stop capture for tab ${tabId}.`);\n    const stopResult = await startTool.stopCapture(tabId);\n\n    if (!stopResult?.success) {\n      return createErrorResponse(\n        stopResult?.message ||\n          `Failed to stop network capture for tab ${tabId}. It might not have been capturing.`,\n      );\n    }\n\n    const resultData = stopResult.data || {};\n\n    // Get all tabs still capturing (there might be other tabs still capturing after stopping)\n    const remainingCaptures = Array.from(startTool['captureData'].keys());\n\n    // Sort requests by time\n    if (resultData.requests && Array.isArray(resultData.requests)) {\n      resultData.requests.sort(\n        (a: NetworkRequestInfo, b: NetworkRequestInfo) =>\n          (a.requestTime || 0) - (b.requestTime || 0),\n      );\n    }\n\n    return {\n      content: [\n        {\n          type: 'text',\n          text: JSON.stringify({\n            success: true,\n            message: `Capture for tab ${tabId} (${resultData.tabUrl || 'N/A'}) stopped. ${resultData.requestCount || 0} requests captured.`,\n            tabId: tabId,\n            tabUrl: resultData.tabUrl || 'N/A',\n            tabTitle: resultData.tabTitle || 'Unknown Tab',\n            requestCount: resultData.requestCount || 0,\n            commonRequestHeaders: resultData.commonRequestHeaders || {},\n            commonResponseHeaders: resultData.commonResponseHeaders || {},\n            requests: resultData.requests || [],\n            captureStartTime: resultData.captureStartTime,\n            captureEndTime: resultData.captureEndTime,\n            totalDurationMs: resultData.totalDurationMs,\n            settingsUsed: resultData.settingsUsed || {},\n            remainingCaptures: remainingCaptures,\n            totalRequestsReceived: resultData.totalRequestsReceived || resultData.requestCount || 0,\n            requestLimitReached: resultData.requestLimitReached || false,\n          }),\n        },\n      ],\n      isError: false,\n    };\n  }\n}\n\nexport const networkDebuggerStartTool = new NetworkDebuggerStartTool();\nexport const networkDebuggerStopTool = new NetworkDebuggerStopTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/network-capture-web-request.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { LIMITS, NETWORK_FILTERS } from '@/common/constants';\n\n// Static resource file extensions\nconst STATIC_RESOURCE_EXTENSIONS = [\n  '.jpg',\n  '.jpeg',\n  '.png',\n  '.gif',\n  '.svg',\n  '.webp',\n  '.ico',\n  '.bmp', // Images\n  '.css',\n  '.scss',\n  '.less', // Styles\n  '.js',\n  '.jsx',\n  '.ts',\n  '.tsx', // Scripts\n  '.woff',\n  '.woff2',\n  '.ttf',\n  '.eot',\n  '.otf', // Fonts\n  '.mp3',\n  '.mp4',\n  '.avi',\n  '.mov',\n  '.wmv',\n  '.flv',\n  '.ogg',\n  '.wav', // Media\n  '.pdf',\n  '.doc',\n  '.docx',\n  '.xls',\n  '.xlsx',\n  '.ppt',\n  '.pptx', // Documents\n];\n\n// Ad and analytics domain list\nconst AD_ANALYTICS_DOMAINS = NETWORK_FILTERS.EXCLUDED_DOMAINS;\n\ninterface NetworkCaptureStartToolParams {\n  url?: string; // URL to navigate to or focus. If not provided, uses active tab.\n  maxCaptureTime?: number; // Maximum capture time (milliseconds)\n  inactivityTimeout?: number; // Inactivity timeout (milliseconds)\n  includeStatic?: boolean; // Whether to include static resources\n}\n\ninterface NetworkRequestInfo {\n  requestId: string;\n  url: string;\n  method: string;\n  type: string;\n  requestTime: number;\n  requestHeaders?: Record<string, string>;\n  requestBody?: string;\n  responseHeaders?: Record<string, string>;\n  responseTime?: number;\n  status?: number;\n  statusText?: string;\n  responseSize?: number;\n  responseType?: string;\n  responseBody?: string;\n  errorText?: string;\n  specificRequestHeaders?: Record<string, string>;\n  specificResponseHeaders?: Record<string, string>;\n  mimeType?: string; // Response MIME type\n}\n\ninterface CaptureInfo {\n  tabId: number;\n  tabUrl: string;\n  tabTitle: string;\n  startTime: number;\n  endTime?: number;\n  requests: Record<string, NetworkRequestInfo>;\n  maxCaptureTime: number;\n  inactivityTimeout: number;\n  includeStatic: boolean;\n  limitReached?: boolean; // Whether request count limit is reached\n}\n\n/**\n * Network Capture Start Tool V2 - Uses Chrome webRequest API to start capturing network requests\n */\nclass NetworkCaptureStartTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START;\n  public static instance: NetworkCaptureStartTool | null = null;\n  public captureData: Map<number, CaptureInfo> = new Map(); // tabId -> capture data\n  private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer\n  private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer\n  private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last activity\n  private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests\n  public static MAX_REQUESTS_PER_CAPTURE = LIMITS.MAX_NETWORK_REQUESTS; // Maximum capture request count\n  private listeners: { [key: string]: (details: any) => void } = {};\n\n  // Static resource MIME types list (for filtering)\n  private static STATIC_MIME_TYPES_TO_FILTER = [\n    'image/', // All image types\n    'font/', // All font types\n    'audio/', // All audio types\n    'video/', // All video types\n    'text/css',\n    'text/javascript',\n    'application/javascript',\n    'application/x-javascript',\n    'application/pdf',\n    'application/zip',\n    'application/octet-stream', // Usually for downloads or generic binary data\n  ];\n\n  // API response MIME types list (these types are usually not filtered)\n  private static API_MIME_TYPES = [\n    'application/json',\n    'application/xml',\n    'text/xml',\n    'application/x-www-form-urlencoded',\n    'application/graphql',\n    'application/grpc',\n    'application/protobuf',\n    'application/x-protobuf',\n    'application/x-json',\n    'application/ld+json',\n    'application/problem+json',\n    'application/problem+xml',\n    'application/soap+xml',\n    'application/vnd.api+json',\n  ];\n\n  constructor() {\n    super();\n    if (NetworkCaptureStartTool.instance) {\n      return NetworkCaptureStartTool.instance;\n    }\n    NetworkCaptureStartTool.instance = this;\n\n    // Listen for tab close events\n    chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));\n    // Listen for tab creation events\n    chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));\n  }\n\n  /**\n   * Handle tab close events\n   */\n  private handleTabRemoved(tabId: number) {\n    if (this.captureData.has(tabId)) {\n      console.log(`NetworkCaptureV2: Tab ${tabId} was closed, cleaning up resources.`);\n      this.cleanupCapture(tabId);\n    }\n  }\n\n  /**\n   * Handle tab creation events\n   * If a new tab is opened from a tab being captured, automatically start capturing the new tab's requests\n   */\n  private async handleTabCreated(tab: chrome.tabs.Tab) {\n    try {\n      // Check if there are any tabs currently capturing\n      if (this.captureData.size === 0) return;\n\n      // Get the openerTabId of the new tab (ID of the tab that opened this tab)\n      const openerTabId = tab.openerTabId;\n      if (!openerTabId) return;\n\n      // Check if the opener tab is currently capturing\n      if (!this.captureData.has(openerTabId)) return;\n\n      // Get the new tab's ID\n      const newTabId = tab.id;\n      if (!newTabId) return;\n\n      console.log(\n        `NetworkCaptureV2: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,\n      );\n\n      // Get the opener tab's capture settings\n      const openerCaptureInfo = this.captureData.get(openerTabId);\n      if (!openerCaptureInfo) return;\n\n      // Wait a short time to ensure the tab is ready\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      // Start capturing requests for the new tab\n      await this.startCaptureForTab(newTabId, {\n        maxCaptureTime: openerCaptureInfo.maxCaptureTime,\n        inactivityTimeout: openerCaptureInfo.inactivityTimeout,\n        includeStatic: openerCaptureInfo.includeStatic,\n      });\n\n      console.log(`NetworkCaptureV2: Successfully extended capture to new tab ${newTabId}`);\n    } catch (error) {\n      console.error(`NetworkCaptureV2: Error extending capture to new tab:`, error);\n    }\n  }\n\n  /**\n   * Determine whether a request should be filtered (based on URL)\n   * Uses full URL substring match to support patterns like 'facebook.com/tr'\n   */\n  private shouldFilterRequest(url: string, includeStatic: boolean): boolean {\n    const normalizedUrl = String(url || '').toLowerCase();\n    if (!normalizedUrl) return false;\n\n    // Check if it's an ad or analytics domain (full URL substring match)\n    if (AD_ANALYTICS_DOMAINS.some((pattern) => normalizedUrl.includes(pattern))) {\n      return true;\n    }\n\n    // If not including static resources, check extensions\n    if (!includeStatic) {\n      try {\n        const urlObj = new URL(url);\n        const path = urlObj.pathname.toLowerCase();\n        if (STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext))) {\n          return true;\n        }\n      } catch {\n        return false;\n      }\n    }\n\n    return false;\n  }\n\n  /**\n   * Filter based on MIME type\n   */\n  private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {\n    if (!mimeType) return false;\n\n    // Always keep API response types\n    if (NetworkCaptureStartTool.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {\n      return false;\n    }\n\n    // If not including static resources, filter out static resource MIME types\n    if (!includeStatic) {\n      // Filter static resource MIME types\n      if (\n        NetworkCaptureStartTool.STATIC_MIME_TYPES_TO_FILTER.some((type) =>\n          mimeType.startsWith(type),\n        )\n      ) {\n        console.log(`NetworkCaptureV2: Filtering static resource by MIME type: ${mimeType}`);\n        return true;\n      }\n\n      // Filter all MIME types starting with text/ (except those already in API_MIME_TYPES)\n      if (mimeType.startsWith('text/')) {\n        console.log(`NetworkCaptureV2: Filtering text response: ${mimeType}`);\n        return true;\n      }\n    }\n\n    return false;\n  }\n\n  /**\n   * Update last activity time and reset inactivity timer\n   */\n  private updateLastActivityTime(tabId: number): void {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    this.lastActivityTime.set(tabId, Date.now());\n\n    // Reset inactivity timer\n    if (this.inactivityTimers.has(tabId)) {\n      clearTimeout(this.inactivityTimers.get(tabId)!);\n    }\n\n    if (captureInfo.inactivityTimeout > 0) {\n      this.inactivityTimers.set(\n        tabId,\n        setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),\n      );\n    }\n  }\n\n  /**\n   * Check for inactivity\n   */\n  private checkInactivity(tabId: number): void {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime;\n    const now = Date.now();\n    const inactiveTime = now - lastActivity;\n\n    if (inactiveTime >= captureInfo.inactivityTimeout) {\n      console.log(\n        `NetworkCaptureV2: No activity for ${inactiveTime}ms, stopping capture for tab ${tabId}`,\n      );\n      this.stopCaptureByInactivity(tabId);\n    } else {\n      // If inactivity time hasn't been reached yet, continue checking\n      const remainingTime = captureInfo.inactivityTimeout - inactiveTime;\n      this.inactivityTimers.set(\n        tabId,\n        setTimeout(() => this.checkInactivity(tabId), remainingTime),\n      );\n    }\n  }\n\n  /**\n   * Stop capture due to inactivity\n   */\n  private async stopCaptureByInactivity(tabId: number): Promise<void> {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) return;\n\n    console.log(`NetworkCaptureV2: Stopping capture due to inactivity for tab ${tabId}`);\n    await this.stopCapture(tabId);\n  }\n\n  /**\n   * Clean up capture resources\n   */\n  private cleanupCapture(tabId: number): void {\n    // Clear timers\n    if (this.captureTimers.has(tabId)) {\n      clearTimeout(this.captureTimers.get(tabId)!);\n      this.captureTimers.delete(tabId);\n    }\n\n    if (this.inactivityTimers.has(tabId)) {\n      clearTimeout(this.inactivityTimers.get(tabId)!);\n      this.inactivityTimers.delete(tabId);\n    }\n\n    // Remove data\n    this.lastActivityTime.delete(tabId);\n    this.captureData.delete(tabId);\n    this.requestCounters.delete(tabId);\n\n    console.log(`NetworkCaptureV2: Cleaned up all resources for tab ${tabId}`);\n  }\n\n  /**\n   * Set up request listeners (idempotent - won't add duplicate listeners)\n   */\n  private setupListeners(): void {\n    // Skip if listeners are already set up\n    if (this.listeners.onBeforeRequest) {\n      return;\n    }\n\n    // Before request is sent\n    this.listeners.onBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {\n      const captureInfo = this.captureData.get(details.tabId);\n      if (!captureInfo) return;\n\n      if (this.shouldFilterRequest(details.url, captureInfo.includeStatic)) {\n        return;\n      }\n\n      const currentCount = this.requestCounters.get(details.tabId) || 0;\n      if (currentCount >= NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE) {\n        console.log(\n          `NetworkCaptureV2: Request limit (${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${details.tabId}, ignoring new request: ${details.url}`,\n        );\n        captureInfo.limitReached = true;\n        return;\n      }\n\n      this.requestCounters.set(details.tabId, currentCount + 1);\n      this.updateLastActivityTime(details.tabId);\n\n      if (!captureInfo.requests[details.requestId]) {\n        captureInfo.requests[details.requestId] = {\n          requestId: details.requestId,\n          url: details.url,\n          method: details.method,\n          type: details.type,\n          requestTime: details.timeStamp,\n        };\n\n        if (details.requestBody) {\n          const requestBody = this.processRequestBody(details.requestBody);\n          if (requestBody) {\n            captureInfo.requests[details.requestId].requestBody = requestBody;\n          }\n        }\n\n        console.log(\n          `NetworkCaptureV2: Captured request ${currentCount + 1}/${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE} for tab ${details.tabId}: ${details.method} ${details.url}`,\n        );\n      }\n    };\n\n    // Send request headers\n    this.listeners.onSendHeaders = (details: chrome.webRequest.WebRequestHeadersDetails) => {\n      const captureInfo = this.captureData.get(details.tabId);\n      if (!captureInfo || !captureInfo.requests[details.requestId]) return;\n\n      if (details.requestHeaders) {\n        const headers: Record<string, string> = {};\n        details.requestHeaders.forEach((header) => {\n          headers[header.name] = header.value || '';\n        });\n        captureInfo.requests[details.requestId].requestHeaders = headers;\n      }\n    };\n\n    // Receive response headers\n    this.listeners.onHeadersReceived = (details: chrome.webRequest.WebResponseHeadersDetails) => {\n      const captureInfo = this.captureData.get(details.tabId);\n      if (!captureInfo || !captureInfo.requests[details.requestId]) return;\n\n      const requestInfo = captureInfo.requests[details.requestId];\n\n      requestInfo.status = details.statusCode;\n      requestInfo.statusText = details.statusLine;\n      requestInfo.responseTime = details.timeStamp;\n      requestInfo.mimeType = details.responseHeaders?.find(\n        (h) => h.name.toLowerCase() === 'content-type',\n      )?.value;\n\n      // Secondary filtering based on MIME type\n      if (\n        requestInfo.mimeType &&\n        this.shouldFilterByMimeType(requestInfo.mimeType, captureInfo.includeStatic)\n      ) {\n        delete captureInfo.requests[details.requestId];\n\n        const currentCount = this.requestCounters.get(details.tabId) || 0;\n        if (currentCount > 0) {\n          this.requestCounters.set(details.tabId, currentCount - 1);\n        }\n\n        console.log(\n          `NetworkCaptureV2: Filtered request by MIME type (${requestInfo.mimeType}): ${requestInfo.url}`,\n        );\n        return;\n      }\n\n      if (details.responseHeaders) {\n        const headers: Record<string, string> = {};\n        details.responseHeaders.forEach((header) => {\n          headers[header.name] = header.value || '';\n        });\n        requestInfo.responseHeaders = headers;\n      }\n\n      this.updateLastActivityTime(details.tabId);\n    };\n\n    // Request completed\n    this.listeners.onCompleted = (details: chrome.webRequest.WebResponseCacheDetails) => {\n      const captureInfo = this.captureData.get(details.tabId);\n      if (!captureInfo || !captureInfo.requests[details.requestId]) return;\n\n      const requestInfo = captureInfo.requests[details.requestId];\n      if ('responseSize' in details) {\n        requestInfo.responseSize = details.fromCache ? 0 : (details as any).responseSize;\n      }\n\n      this.updateLastActivityTime(details.tabId);\n    };\n\n    // Request failed\n    this.listeners.onErrorOccurred = (details: chrome.webRequest.WebResponseErrorDetails) => {\n      const captureInfo = this.captureData.get(details.tabId);\n      if (!captureInfo || !captureInfo.requests[details.requestId]) return;\n\n      const requestInfo = captureInfo.requests[details.requestId];\n      requestInfo.errorText = details.error;\n\n      this.updateLastActivityTime(details.tabId);\n    };\n\n    // Register all listeners\n    chrome.webRequest.onBeforeRequest.addListener(\n      this.listeners.onBeforeRequest,\n      { urls: ['<all_urls>'] },\n      ['requestBody'],\n    );\n\n    chrome.webRequest.onSendHeaders.addListener(\n      this.listeners.onSendHeaders,\n      { urls: ['<all_urls>'] },\n      ['requestHeaders'],\n    );\n\n    chrome.webRequest.onHeadersReceived.addListener(\n      this.listeners.onHeadersReceived,\n      { urls: ['<all_urls>'] },\n      ['responseHeaders'],\n    );\n\n    chrome.webRequest.onCompleted.addListener(this.listeners.onCompleted, { urls: ['<all_urls>'] });\n\n    chrome.webRequest.onErrorOccurred.addListener(this.listeners.onErrorOccurred, {\n      urls: ['<all_urls>'],\n    });\n  }\n\n  /**\n   * Remove all request listeners\n   * Only remove listeners when all tab captures have stopped\n   */\n  private removeListeners(): void {\n    // Don't remove listeners if there are still tabs being captured\n    if (this.captureData.size > 0) {\n      console.log(\n        `NetworkCaptureV2: Still capturing on ${this.captureData.size} tabs, not removing listeners.`,\n      );\n      return;\n    }\n\n    console.log(`NetworkCaptureV2: No more active captures, removing all listeners.`);\n\n    if (this.listeners.onBeforeRequest) {\n      chrome.webRequest.onBeforeRequest.removeListener(this.listeners.onBeforeRequest);\n    }\n\n    if (this.listeners.onSendHeaders) {\n      chrome.webRequest.onSendHeaders.removeListener(this.listeners.onSendHeaders);\n    }\n\n    if (this.listeners.onHeadersReceived) {\n      chrome.webRequest.onHeadersReceived.removeListener(this.listeners.onHeadersReceived);\n    }\n\n    if (this.listeners.onCompleted) {\n      chrome.webRequest.onCompleted.removeListener(this.listeners.onCompleted);\n    }\n\n    if (this.listeners.onErrorOccurred) {\n      chrome.webRequest.onErrorOccurred.removeListener(this.listeners.onErrorOccurred);\n    }\n\n    // Clear listener object\n    this.listeners = {};\n  }\n\n  /**\n   * Process request body data\n   */\n  private processRequestBody(requestBody: chrome.webRequest.WebRequestBody): string | undefined {\n    if (requestBody.raw && requestBody.raw.length > 0) {\n      return '[Binary data]';\n    } else if (requestBody.formData) {\n      return JSON.stringify(requestBody.formData);\n    }\n    return undefined;\n  }\n\n  /**\n   * Start network request capture for specified tab\n   * @param tabId Tab ID\n   * @param options Capture options\n   */\n  private async startCaptureForTab(\n    tabId: number,\n    options: {\n      maxCaptureTime: number;\n      inactivityTimeout: number;\n      includeStatic: boolean;\n    },\n  ): Promise<void> {\n    const { maxCaptureTime, inactivityTimeout, includeStatic } = options;\n\n    // If already capturing, stop first\n    if (this.captureData.has(tabId)) {\n      console.log(\n        `NetworkCaptureV2: Already capturing on tab ${tabId}. Stopping previous session.`,\n      );\n      await this.stopCapture(tabId);\n    }\n\n    try {\n      // Get tab information\n      const tab = await chrome.tabs.get(tabId);\n\n      // Initialize capture data\n      this.captureData.set(tabId, {\n        tabId: tabId,\n        tabUrl: tab.url || '',\n        tabTitle: tab.title || '',\n        startTime: Date.now(),\n        requests: {},\n        maxCaptureTime,\n        inactivityTimeout,\n        includeStatic,\n        limitReached: false,\n      });\n\n      // Initialize request counter\n      this.requestCounters.set(tabId, 0);\n\n      // Set up listeners\n      this.setupListeners();\n\n      // Update last activity time\n      this.updateLastActivityTime(tabId);\n\n      console.log(\n        `NetworkCaptureV2: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,\n      );\n\n      // Set maximum capture time\n      if (maxCaptureTime > 0) {\n        this.captureTimers.set(\n          tabId,\n          setTimeout(async () => {\n            console.log(\n              `NetworkCaptureV2: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,\n            );\n            await this.stopCapture(tabId);\n          }, maxCaptureTime),\n        );\n      }\n    } catch (error: any) {\n      console.error(`NetworkCaptureV2: Error starting capture for tab ${tabId}:`, error);\n\n      // Clean up resources\n      if (this.captureData.has(tabId)) {\n        this.cleanupCapture(tabId);\n      }\n\n      throw error;\n    }\n  }\n\n  /**\n   * Stop capture\n   * @param tabId Tab ID\n   */\n  public async stopCapture(\n    tabId: number,\n  ): Promise<{ success: boolean; message?: string; data?: any }> {\n    const captureInfo = this.captureData.get(tabId);\n    if (!captureInfo) {\n      console.log(`NetworkCaptureV2: No capture in progress for tab ${tabId}`);\n      return { success: false, message: `No capture in progress for tab ${tabId}` };\n    }\n\n    try {\n      // Record end time\n      captureInfo.endTime = Date.now();\n\n      // Extract common request and response headers\n      const requestsArray = Object.values(captureInfo.requests);\n      const commonRequestHeaders = this.analyzeCommonHeaders(requestsArray, 'requestHeaders');\n      const commonResponseHeaders = this.analyzeCommonHeaders(requestsArray, 'responseHeaders');\n\n      // Process request data, remove common headers\n      const processedRequests = requestsArray.map((req) => {\n        const finalReq: NetworkRequestInfo = { ...req };\n\n        if (finalReq.requestHeaders) {\n          finalReq.specificRequestHeaders = this.filterOutCommonHeaders(\n            finalReq.requestHeaders,\n            commonRequestHeaders,\n          );\n          delete finalReq.requestHeaders;\n        } else {\n          finalReq.specificRequestHeaders = {};\n        }\n\n        if (finalReq.responseHeaders) {\n          finalReq.specificResponseHeaders = this.filterOutCommonHeaders(\n            finalReq.responseHeaders,\n            commonResponseHeaders,\n          );\n          delete finalReq.responseHeaders;\n        } else {\n          finalReq.specificResponseHeaders = {};\n        }\n\n        return finalReq;\n      });\n\n      // Sort by time\n      processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));\n\n      // Remove listeners\n      this.removeListeners();\n\n      // Prepare result data\n      const resultData = {\n        captureStartTime: captureInfo.startTime,\n        captureEndTime: captureInfo.endTime,\n        totalDurationMs: captureInfo.endTime - captureInfo.startTime,\n        settingsUsed: {\n          maxCaptureTime: captureInfo.maxCaptureTime,\n          inactivityTimeout: captureInfo.inactivityTimeout,\n          includeStatic: captureInfo.includeStatic,\n          maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,\n        },\n        commonRequestHeaders,\n        commonResponseHeaders,\n        requests: processedRequests,\n        requestCount: processedRequests.length,\n        totalRequestsReceived: this.requestCounters.get(tabId) || 0,\n        requestLimitReached: captureInfo.limitReached || false,\n        tabUrl: captureInfo.tabUrl,\n        tabTitle: captureInfo.tabTitle,\n      };\n\n      // Clean up resources\n      this.cleanupCapture(tabId);\n\n      return {\n        success: true,\n        data: resultData,\n      };\n    } catch (error: any) {\n      console.error(`NetworkCaptureV2: Error stopping capture for tab ${tabId}:`, error);\n\n      // Ensure resources are cleaned up\n      this.cleanupCapture(tabId);\n\n      return {\n        success: false,\n        message: `Error stopping capture: ${error.message || String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Analyze common request or response headers\n   */\n  private analyzeCommonHeaders(\n    requests: NetworkRequestInfo[],\n    headerType: 'requestHeaders' | 'responseHeaders',\n  ): Record<string, string> {\n    if (!requests || requests.length === 0) return {};\n\n    // Find headers that are included in all requests\n    const commonHeaders: Record<string, string> = {};\n    const firstRequestWithHeaders = requests.find(\n      (req) => req[headerType] && Object.keys(req[headerType] || {}).length > 0,\n    );\n\n    if (!firstRequestWithHeaders || !firstRequestWithHeaders[headerType]) {\n      return {};\n    }\n\n    // Get all headers from the first request\n    const headers = firstRequestWithHeaders[headerType] as Record<string, string>;\n    const headerNames = Object.keys(headers);\n\n    // Check if each header exists in all requests with the same value\n    for (const name of headerNames) {\n      const value = headers[name];\n      const isCommon = requests.every((req) => {\n        const reqHeaders = req[headerType] as Record<string, string>;\n        return reqHeaders && reqHeaders[name] === value;\n      });\n\n      if (isCommon) {\n        commonHeaders[name] = value;\n      }\n    }\n\n    return commonHeaders;\n  }\n\n  /**\n   * Filter out common headers\n   */\n  private filterOutCommonHeaders(\n    headers: Record<string, string>,\n    commonHeaders: Record<string, string>,\n  ): Record<string, string> {\n    if (!headers || typeof headers !== 'object') return {};\n\n    const specificHeaders: Record<string, string> = {};\n    // Use Object.keys to avoid ESLint no-prototype-builtins warning\n    Object.keys(headers).forEach((name) => {\n      if (!(name in commonHeaders) || headers[name] !== commonHeaders[name]) {\n        specificHeaders[name] = headers[name];\n      }\n    });\n\n    return specificHeaders;\n  }\n\n  async execute(args: NetworkCaptureStartToolParams): Promise<ToolResult> {\n    const {\n      url: targetUrl,\n      maxCaptureTime = 3 * 60 * 1000, // Default 3 minutes\n      inactivityTimeout = 60 * 1000, // Default 1 minute of inactivity before auto-stop\n      includeStatic = false, // Default: don't include static resources\n    } = args;\n\n    console.log(`NetworkCaptureStartTool: Executing with args:`, args);\n\n    try {\n      // Get current tab or create new tab\n      let tabToOperateOn: chrome.tabs.Tab;\n\n      if (targetUrl) {\n        // Find tabs matching the URL\n        const matchingTabs = await chrome.tabs.query({ url: targetUrl });\n\n        if (matchingTabs.length > 0) {\n          // Use existing tab\n          tabToOperateOn = matchingTabs[0];\n          console.log(`NetworkCaptureV2: Found existing tab with URL: ${targetUrl}`);\n        } else {\n          // Create new tab\n          console.log(`NetworkCaptureV2: Creating new tab with URL: ${targetUrl}`);\n          tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });\n\n          // Wait for page to load\n          await new Promise((resolve) => setTimeout(resolve, 1000));\n        }\n      } else {\n        // Use current active tab\n        const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n        if (!tabs[0]) {\n          return createErrorResponse('No active tab found');\n        }\n        tabToOperateOn = tabs[0];\n      }\n\n      if (!tabToOperateOn?.id) {\n        return createErrorResponse('Failed to identify or create a tab');\n      }\n\n      // Use startCaptureForTab method to start capture\n      try {\n        await this.startCaptureForTab(tabToOperateOn.id, {\n          maxCaptureTime,\n          inactivityTimeout,\n          includeStatic,\n        });\n      } catch (error: any) {\n        return createErrorResponse(\n          `Failed to start capture for tab ${tabToOperateOn.id}: ${error.message || String(error)}`,\n        );\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: 'Network capture V2 started successfully, waiting for stop command.',\n              tabId: tabToOperateOn.id,\n              url: tabToOperateOn.url,\n              maxCaptureTime,\n              inactivityTimeout,\n              includeStatic,\n              maxRequests: NetworkCaptureStartTool.MAX_REQUESTS_PER_CAPTURE,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error: any) {\n      console.error('NetworkCaptureStartTool: Critical error:', error);\n      return createErrorResponse(\n        `Error in NetworkCaptureStartTool: ${error.message || String(error)}`,\n      );\n    }\n  }\n}\n\n/**\n * Network capture stop tool V2 - Stop webRequest API capture and return results\n */\nclass NetworkCaptureStopTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP;\n  public static instance: NetworkCaptureStopTool | null = null;\n\n  constructor() {\n    super();\n    if (NetworkCaptureStopTool.instance) {\n      return NetworkCaptureStopTool.instance;\n    }\n    NetworkCaptureStopTool.instance = this;\n  }\n\n  async execute(): Promise<ToolResult> {\n    console.log(`NetworkCaptureStopTool: Executing`);\n\n    try {\n      const startTool = NetworkCaptureStartTool.instance;\n\n      if (!startTool) {\n        return createErrorResponse('Network capture V2 start tool instance not found');\n      }\n\n      // Get all tabs currently capturing\n      const ongoingCaptures = Array.from(startTool.captureData.keys());\n      console.log(\n        `NetworkCaptureStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,\n      );\n\n      if (ongoingCaptures.length === 0) {\n        return createErrorResponse('No active network captures found in any tab.');\n      }\n\n      // Get current active tab\n      const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const activeTabId = activeTabs[0]?.id;\n\n      // Determine the primary tab to stop\n      let primaryTabId: number;\n\n      if (activeTabId && startTool.captureData.has(activeTabId)) {\n        // If current active tab is capturing, prioritize stopping it\n        primaryTabId = activeTabId;\n        console.log(\n          `NetworkCaptureStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,\n        );\n      } else if (ongoingCaptures.length === 1) {\n        // If only one tab is capturing, stop it\n        primaryTabId = ongoingCaptures[0];\n        console.log(\n          `NetworkCaptureStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,\n        );\n      } else {\n        // If multiple tabs are capturing but current active tab is not among them, stop the first one\n        primaryTabId = ongoingCaptures[0];\n        console.log(\n          `NetworkCaptureStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,\n        );\n      }\n\n      const stopResult = await startTool.stopCapture(primaryTabId);\n\n      if (!stopResult.success) {\n        return createErrorResponse(\n          stopResult.message || `Failed to stop network capture for tab ${primaryTabId}`,\n        );\n      }\n\n      // If multiple tabs are capturing, stop other tabs\n      if (ongoingCaptures.length > 1) {\n        const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);\n        console.log(\n          `NetworkCaptureStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,\n        );\n\n        for (const tabId of otherTabIds) {\n          try {\n            await startTool.stopCapture(tabId);\n          } catch (error) {\n            console.error(`NetworkCaptureStopTool: Error stopping capture on tab ${tabId}:`, error);\n          }\n        }\n      }\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: `Capture complete. ${stopResult.data?.requestCount || 0} requests captured.`,\n              tabId: primaryTabId,\n              tabUrl: stopResult.data?.tabUrl || 'N/A',\n              tabTitle: stopResult.data?.tabTitle || 'Unknown Tab',\n              requestCount: stopResult.data?.requestCount || 0,\n              commonRequestHeaders: stopResult.data?.commonRequestHeaders || {},\n              commonResponseHeaders: stopResult.data?.commonResponseHeaders || {},\n              requests: stopResult.data?.requests || [],\n              captureStartTime: stopResult.data?.captureStartTime,\n              captureEndTime: stopResult.data?.captureEndTime,\n              totalDurationMs: stopResult.data?.totalDurationMs,\n              settingsUsed: stopResult.data?.settingsUsed || {},\n              totalRequestsReceived: stopResult.data?.totalRequestsReceived || 0,\n              requestLimitReached: stopResult.data?.requestLimitReached || false,\n              remainingCaptures: Array.from(startTool.captureData.keys()),\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error: any) {\n      console.error('NetworkCaptureStopTool: Critical error:', error);\n      return createErrorResponse(\n        `Error in NetworkCaptureStopTool: ${error.message || String(error)}`,\n      );\n    }\n  }\n}\n\nexport const networkCaptureStartTool = new NetworkCaptureStartTool();\nexport const networkCaptureStopTool = new NetworkCaptureStopTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/network-capture.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { networkCaptureStartTool, networkCaptureStopTool } from './network-capture-web-request';\nimport { networkDebuggerStartTool, networkDebuggerStopTool } from './network-capture-debugger';\n\ntype NetworkCaptureBackend = 'webRequest' | 'debugger';\n\ninterface NetworkCaptureToolParams {\n  action: 'start' | 'stop';\n  needResponseBody?: boolean;\n  url?: string;\n  maxCaptureTime?: number;\n  inactivityTimeout?: number;\n  includeStatic?: boolean;\n}\n\n/**\n * Extract text content from ToolResult\n */\nfunction getFirstText(result: ToolResult): string | undefined {\n  const first = result.content?.[0];\n  return first && first.type === 'text' ? first.text : undefined;\n}\n\n/**\n * Decorate JSON result with additional fields\n */\nfunction decorateJsonResult(result: ToolResult, extra: Record<string, unknown>): ToolResult {\n  const text = getFirstText(result);\n  if (typeof text !== 'string') return result;\n\n  try {\n    const parsed = JSON.parse(text);\n    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n      return {\n        ...result,\n        content: [{ type: 'text', text: JSON.stringify({ ...parsed, ...extra }) }],\n      };\n    }\n  } catch {\n    // If the underlying tool didn't return JSON, keep it as-is\n  }\n  return result;\n}\n\n/**\n * Check if debugger-based capture is active\n */\nfunction isDebuggerCaptureActive(): boolean {\n  const captureData = (\n    networkDebuggerStartTool as unknown as { captureData?: Map<number, unknown> }\n  ).captureData;\n  return captureData instanceof Map && captureData.size > 0;\n}\n\n/**\n * Check if webRequest-based capture is active\n */\nfunction isWebRequestCaptureActive(): boolean {\n  return networkCaptureStartTool.captureData.size > 0;\n}\n\n/**\n * Unified Network Capture Tool\n *\n * Provides a single entry point for network capture, automatically selecting\n * the appropriate backend based on the `needResponseBody` parameter:\n * - needResponseBody=false (default): uses webRequest API (lightweight, no debugger conflict)\n * - needResponseBody=true: uses Debugger API (captures response body, may conflict with DevTools)\n */\nclass NetworkCaptureTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_CAPTURE;\n\n  async execute(args: NetworkCaptureToolParams): Promise<ToolResult> {\n    const action = args?.action;\n    if (action !== 'start' && action !== 'stop') {\n      return createErrorResponse('Parameter [action] is required and must be one of: start, stop');\n    }\n\n    const wantBody = args?.needResponseBody === true;\n    const debuggerActive = isDebuggerCaptureActive();\n    const webActive = isWebRequestCaptureActive();\n\n    if (action === 'start') {\n      return this.handleStart(args, wantBody, debuggerActive, webActive);\n    }\n\n    return this.handleStop(args, debuggerActive, webActive);\n  }\n\n  private async handleStart(\n    args: NetworkCaptureToolParams,\n    wantBody: boolean,\n    debuggerActive: boolean,\n    webActive: boolean,\n  ): Promise<ToolResult> {\n    // Prevent any capture conflict (cross-mode or same-mode)\n    if (debuggerActive || webActive) {\n      const activeMode = debuggerActive ? 'debugger' : 'webRequest';\n      return createErrorResponse(\n        `Network capture is already active in ${activeMode} mode. Stop it before starting a new capture.`,\n      );\n    }\n\n    const delegate = wantBody ? networkDebuggerStartTool : networkCaptureStartTool;\n    const backend: NetworkCaptureBackend = wantBody ? 'debugger' : 'webRequest';\n\n    const result = await delegate.execute({\n      url: args.url,\n      maxCaptureTime: args.maxCaptureTime,\n      inactivityTimeout: args.inactivityTimeout,\n      includeStatic: args.includeStatic,\n    });\n\n    return decorateJsonResult(result, { backend, needResponseBody: wantBody });\n  }\n\n  private async handleStop(\n    args: NetworkCaptureToolParams,\n    debuggerActive: boolean,\n    webActive: boolean,\n  ): Promise<ToolResult> {\n    // Determine which backend to stop\n    let backendToStop: NetworkCaptureBackend | null = null;\n\n    // If user explicitly specified needResponseBody, try to stop that specific backend\n    if (args?.needResponseBody === true) {\n      backendToStop = debuggerActive ? 'debugger' : null;\n    } else if (args?.needResponseBody === false) {\n      backendToStop = webActive ? 'webRequest' : null;\n    }\n\n    // If no explicit preference or the specified backend isn't active, auto-detect\n    if (!backendToStop) {\n      if (debuggerActive) {\n        backendToStop = 'debugger';\n      } else if (webActive) {\n        backendToStop = 'webRequest';\n      }\n    }\n\n    if (!backendToStop) {\n      return createErrorResponse('No active network captures found in any tab.');\n    }\n\n    const delegateStop =\n      backendToStop === 'debugger' ? networkDebuggerStopTool : networkCaptureStopTool;\n    const result = await delegateStop.execute();\n\n    return decorateJsonResult(result, {\n      backend: backendToStop,\n      needResponseBody: backendToStop === 'debugger',\n    });\n  }\n}\n\nexport const networkCaptureTool = new NetworkCaptureTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/network-request.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\nconst DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script\n\ninterface NetworkRequestToolParams {\n  url: string; // URL is always required\n  method?: string; // Defaults to GET\n  headers?: Record<string, string>; // User-provided headers\n  body?: any; // User-provided body\n  timeout?: number; // Timeout for the network request itself\n  // Optional multipart/form-data descriptor. When provided, overrides body and lets the helper build FormData.\n  // Shape: { fields?: Record<string, string|number|boolean>, files?: Array<{ name: string, fileUrl?: string, filePath?: string, base64Data?: string, filename?: string, contentType?: string }> }\n  // Or a compact array: [ [name, fileSpec, filename?], ... ] where fileSpec can be 'url:...', 'file:/abs/path', 'base64:...'\n  formData?: any;\n}\n\n/**\n * NetworkRequestTool - Sends network requests based on provided parameters.\n */\nclass NetworkRequestTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.NETWORK_REQUEST;\n\n  async execute(args: NetworkRequestToolParams): Promise<ToolResult> {\n    const {\n      url,\n      method = 'GET',\n      headers = {},\n      body,\n      timeout = DEFAULT_NETWORK_REQUEST_TIMEOUT,\n    } = args;\n\n    console.log(`NetworkRequestTool: Executing with options:`, args);\n\n    if (!url) {\n      return createErrorResponse('URL parameter is required.');\n    }\n\n    try {\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!tabs[0]?.id) {\n        return createErrorResponse('No active tab found or tab has no ID.');\n      }\n      const activeTabId = tabs[0].id;\n\n      // Ensure content script is available in the target tab\n      await this.injectContentScript(activeTabId, ['inject-scripts/network-helper.js']);\n\n      console.log(\n        `NetworkRequestTool: Sending to content script: URL=${url}, Method=${method}, Headers=${Object.keys(headers).join(',')}, BodyType=${typeof body}`,\n      );\n\n      const resultFromContentScript = await this.sendMessageToTab(activeTabId, {\n        action: TOOL_MESSAGE_TYPES.NETWORK_SEND_REQUEST,\n        url: url,\n        method: method,\n        headers: headers,\n        body: body,\n        formData: args.formData || null,\n        timeout: timeout,\n      });\n\n      console.log(`NetworkRequestTool: Response from content script:`, resultFromContentScript);\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(resultFromContentScript),\n          },\n        ],\n        isError: !resultFromContentScript?.success,\n      };\n    } catch (error: any) {\n      console.error('NetworkRequestTool: Error sending network request:', error);\n      return createErrorResponse(\n        `Error sending network request: ${error.message || String(error)}`,\n      );\n    }\n  }\n}\n\nexport const networkRequestTool = new NetworkRequestTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/performance.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\n\ntype OwnerTag = 'performance';\n\ninterface StartTraceParams {\n  reload?: boolean; // whether to reload the page after starting trace\n  autoStop?: boolean; // whether to auto stop after a short duration\n  durationMs?: number; // custom duration when autoStop is true (default 5000)\n}\n\ninterface StopTraceParams {\n  saveToDownloads?: boolean; // save trace to Downloads as JSON (default true)\n  filenamePrefix?: string; // filename prefix (default 'performance_trace')\n}\n\ninterface AnalyzeInsightParams {\n  insightName?: string; // placeholder for future deep insights\n}\n\ntype DebuggeeEvent = (source: chrome.debugger.Debuggee, method: string, params?: any) => void;\n\ninterface TraceSessionState {\n  recording: boolean;\n  events: any[];\n  startedAt: number;\n  pageUrl?: string;\n  listener: DebuggeeEvent;\n  stopResolver?: (value: { completed: boolean }) => void;\n  stopPromise?: Promise<{ completed: boolean }>;\n}\n\nconst sessions = new Map<number, TraceSessionState>();\nconst LAST_RESULTS = new Map<\n  number,\n  {\n    events: any[];\n    startedAt: number;\n    endedAt: number;\n    tabUrl: string;\n    saved?: { downloadId?: number; filename?: string; fullPath?: string };\n    metrics?: Record<string, number>;\n  }\n>();\n\nfunction tracingCategories(): string[] {\n  // Keep broadly consistent with other project\n  return [\n    '-*',\n    'blink.console',\n    'blink.user_timing',\n    'devtools.timeline',\n    'disabled-by-default-devtools.screenshot',\n    'disabled-by-default-devtools.timeline',\n    'disabled-by-default-devtools.timeline.invalidationTracking',\n    'disabled-by-default-devtools.timeline.frame',\n    'disabled-by-default-devtools.timeline.stack',\n    'disabled-by-default-v8.cpu_profiler',\n    'disabled-by-default-v8.cpu_profiler.hires',\n    'latencyInfo',\n    'loading',\n    'disabled-by-default-lighthouse',\n    'v8.execute',\n    'v8',\n  ];\n}\n\nasync function enablePerformanceMetrics(tabId: number): Promise<Record<string, number>> {\n  try {\n    await cdpSessionManager.sendCommand(tabId, 'Performance.enable');\n    const result = (await cdpSessionManager.sendCommand(tabId, 'Performance.getMetrics')) as {\n      metrics: Array<{ name: string; value: number }>;\n    };\n    await cdpSessionManager.sendCommand(tabId, 'Performance.disable');\n    const map: Record<string, number> = {};\n    for (const m of result.metrics || []) map[m.name] = m.value;\n    return map;\n  } catch (e) {\n    return {};\n  }\n}\n\nasync function saveTraceToDownloads(\n  json: string,\n  filenamePrefix = 'performance_trace',\n): Promise<{ downloadId?: number; filename?: string; fullPath?: string }> {\n  try {\n    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n    const filename = `${filenamePrefix}_${timestamp}.json`;\n    const dataUrl = `data:application/json;base64,${btoa(unescape(encodeURIComponent(json)))}`;\n    const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false });\n    // Attempt to resolve full path\n    try {\n      await new Promise((r) => setTimeout(r, 120));\n      const [item] = await chrome.downloads.search({ id: downloadId });\n      return { downloadId, filename, fullPath: item?.filename };\n    } catch {\n      return { downloadId, filename };\n    }\n  } catch {\n    return {};\n  }\n}\n\nasync function saveTraceToNativeTemp(\n  json: string,\n  filenamePrefix = 'performance_trace',\n): Promise<{ filename?: string; fullPath?: string } | undefined> {\n  try {\n    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n    const filename = `${filenamePrefix}_${timestamp}.json`;\n    const base64 = btoa(unescape(encodeURIComponent(json)));\n\n    const requestId = `trace-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n    const timeoutMs = 30000;\n    const resp = await new Promise<any>((resolve, reject) => {\n      const timer = setTimeout(() => {\n        chrome.runtime.onMessage.removeListener(listener);\n        reject(new Error('Native temp save timed out'));\n      }, timeoutMs);\n      const listener = (message: any) => {\n        if (\n          message &&\n          message.type === 'file_operation_response' &&\n          message.responseToRequestId === requestId\n        ) {\n          clearTimeout(timer);\n          chrome.runtime.onMessage.removeListener(listener);\n          resolve(message.payload);\n        }\n      };\n      chrome.runtime.onMessage.addListener(listener);\n      chrome.runtime\n        .sendMessage({\n          type: 'forward_to_native',\n          message: {\n            type: 'file_operation',\n            requestId,\n            payload: {\n              action: 'prepareFile',\n              base64Data: base64,\n              fileName: filename,\n            },\n          },\n        })\n        .catch((err) => {\n          clearTimeout(timer);\n          chrome.runtime.onMessage.removeListener(listener);\n          reject(err);\n        });\n    });\n\n    if (resp && resp.success && resp.filePath) {\n      return { filename, fullPath: resp.filePath };\n    }\n  } catch {\n    // ignore, fallback will apply\n  }\n  return undefined;\n}\n\nasync function cleanupNativeTempFile(filePath: string): Promise<void> {\n  if (!filePath) return;\n  try {\n    const requestId = `trace-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n    const timeoutMs = 10000;\n    await new Promise<void>((resolve) => {\n      const timer = setTimeout(() => {\n        chrome.runtime.onMessage.removeListener(listener);\n        resolve(); // best-effort\n      }, timeoutMs);\n      const listener = (message: any) => {\n        if (\n          message &&\n          message.type === 'file_operation_response' &&\n          message.responseToRequestId === requestId\n        ) {\n          clearTimeout(timer);\n          chrome.runtime.onMessage.removeListener(listener);\n          resolve();\n        }\n      };\n      chrome.runtime.onMessage.addListener(listener);\n      chrome.runtime\n        .sendMessage({\n          type: 'forward_to_native',\n          message: {\n            type: 'file_operation',\n            requestId,\n            payload: {\n              action: 'cleanupFile',\n              filePath,\n            },\n          },\n        })\n        .catch(() => {\n          clearTimeout(timer);\n          chrome.runtime.onMessage.removeListener(listener);\n          resolve();\n        });\n    });\n  } catch {\n    // ignore\n  }\n}\n\nfunction getOrCreateStopPromise(session: TraceSessionState): Promise<{ completed: boolean }> {\n  if (session.stopPromise) return session.stopPromise;\n  session.stopPromise = new Promise((resolve) => {\n    session.stopResolver = resolve;\n  });\n  return session.stopPromise;\n}\n\n/**\n * Start performance trace\n */\nclass PerformanceStartTraceTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE;\n\n  async execute(args: StartTraceParams): Promise<ToolResult> {\n    const { reload = false, autoStop = false, durationMs = 5000 } = args || {};\n\n    try {\n      const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!activeTab?.id) {\n        return createErrorResponse('No active tab found');\n      }\n      const tabId = activeTab.id;\n      const existed = sessions.get(tabId);\n      if (existed?.recording) {\n        return {\n          content: [{ type: 'text', text: 'Error: a performance trace is already running.' }],\n          isError: false,\n        };\n      }\n\n      await cdpSessionManager.attach(tabId, 'performance');\n\n      const state: TraceSessionState = {\n        recording: true,\n        events: [],\n        startedAt: Date.now(),\n        pageUrl: activeTab.url || '',\n        listener: (source, method, params) => {\n          if (source.tabId !== tabId) return;\n          if (method === 'Tracing.dataCollected' && params?.value) {\n            try {\n              state.events.push(...(params.value as any[]));\n            } catch {\n              // ignore\n            }\n          } else if (method === 'Tracing.tracingComplete') {\n            state.recording = false;\n            state.stopResolver?.({ completed: true });\n          }\n        },\n      };\n      chrome.debugger.onEvent.addListener(state.listener);\n      sessions.set(tabId, state);\n\n      // Start tracing with categories\n      const cats = tracingCategories().join(',');\n      await cdpSessionManager.sendCommand(tabId, 'Tracing.start', {\n        categories: cats,\n        options: 'record-as-much-as-possible',\n        transferMode: 'ReportEvents',\n      });\n\n      if (reload) {\n        try {\n          await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true });\n        } catch {\n          // best effort; ignore if fails\n        }\n      }\n\n      if (autoStop) {\n        setTimeout(\n          async () => {\n            try {\n              await cdpSessionManager.sendCommand(tabId, 'Tracing.end');\n            } catch {\n              // ignore\n            }\n          },\n          Math.max(1000, Math.min(durationMs, 60000)),\n        );\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: 'Performance trace is recording. Use performance_stop_trace to stop it.',\n              reload,\n              autoStop,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (e: any) {\n      return createErrorResponse(`Failed to start performance trace: ${e?.message || e}`);\n    }\n  }\n}\n\n/**\n * Stop performance trace\n */\nclass PerformanceStopTraceTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE;\n\n  async execute(args: StopTraceParams): Promise<ToolResult> {\n    const { saveToDownloads = true, filenamePrefix } = args || {};\n    try {\n      const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!activeTab?.id) return createErrorResponse('No active tab found');\n      const tabId = activeTab.id;\n      const session = sessions.get(tabId);\n      if (!session) {\n        return {\n          content: [\n            { type: 'text', text: 'No performance trace session found for the current tab.' },\n          ],\n          isError: false,\n        };\n      }\n\n      let stopResult: { completed: boolean } = { completed: false };\n      if (session.recording) {\n        // End tracing and wait for completion signal\n        await cdpSessionManager.sendCommand(tabId, 'Tracing.end');\n        await getOrCreateStopPromise(session);\n        stopResult = await session.stopPromise!;\n      } else {\n        // Already auto-stopped; proceed to finalize without waiting\n        stopResult = { completed: true };\n      }\n      // Fetch metrics before detach\n      const metrics = await enablePerformanceMetrics(tabId);\n\n      // Cleanup event listener and detach\n      try {\n        chrome.debugger.onEvent.removeListener(session.listener);\n      } catch {\n        // ignore\n      }\n      try {\n        await cdpSessionManager.detach(tabId, 'performance');\n      } catch {\n        // ignore\n      }\n\n      const endedAt = Date.now();\n      const trace = { traceEvents: session.events };\n      const json = JSON.stringify(trace);\n\n      let saved: { downloadId?: number; filename?: string; fullPath?: string } | undefined;\n      if (saveToDownloads) {\n        saved = await saveTraceToDownloads(json, filenamePrefix || 'performance_trace');\n      } else {\n        // Persist to native temp directory so that analysis can run without Downloads permission\n        const tempSaved = await saveTraceToNativeTemp(json, filenamePrefix || 'performance_trace');\n        if (tempSaved) {\n          saved = { ...tempSaved } as any;\n        }\n      }\n\n      LAST_RESULTS.set(tabId, {\n        events: session.events,\n        startedAt: session.startedAt,\n        endedAt,\n        tabUrl: session.pageUrl || '',\n        saved,\n        metrics,\n      });\n\n      sessions.delete(tabId);\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              message: 'The performance trace has been stopped.',\n              eventCount: session.events.length,\n              saved,\n              metrics,\n              startedAt: session.startedAt,\n              endedAt,\n              durationMs: endedAt - session.startedAt,\n              url: session.pageUrl || '',\n              tracingCompleted: stopResult?.completed === true,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (e: any) {\n      return createErrorResponse(`Failed to stop performance trace: ${e?.message || e}`);\n    }\n  }\n}\n\n/**\n * Analyze last trace (lightweight)\n * Note: Deep insights require DevTools front-end trace engine on the native side; this is a\n * pragmatic first step returning basic metrics and a quick event histogram.\n */\nclass PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT;\n\n  async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise<ToolResult> {\n    const { insightName } = args || {};\n    try {\n      const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!activeTab?.id) return createErrorResponse('No active tab found');\n      const tabId = activeTab.id;\n      const result = LAST_RESULTS.get(tabId);\n      if (!result) {\n        return {\n          content: [\n            {\n              type: 'text',\n              text: 'No recorded traces found. Start and stop a performance trace first.',\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      // Prefer native-side deep analysis when we have a saved file path\n      const fullPath = (result.saved && (result.saved as any).fullPath) || undefined;\n      if (fullPath) {\n        try {\n          const requestId = `trace-analyze-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n          const timeoutMs = Math.max(10000, Math.min((args as any)?.timeoutMs ?? 60000, 300000));\n          const resp = await new Promise<any>((resolve, reject) => {\n            const timer = setTimeout(() => {\n              chrome.runtime.onMessage.removeListener(listener);\n              reject(new Error('Native trace analysis timed out'));\n            }, timeoutMs);\n            const listener = (message: any) => {\n              if (\n                message &&\n                message.type === 'file_operation_response' &&\n                message.responseToRequestId === requestId\n              ) {\n                clearTimeout(timer);\n                chrome.runtime.onMessage.removeListener(listener);\n                resolve(message.payload);\n              }\n            };\n            chrome.runtime.onMessage.addListener(listener);\n            chrome.runtime\n              .sendMessage({\n                type: 'forward_to_native',\n                message: {\n                  type: 'file_operation',\n                  requestId,\n                  payload: { action: 'analyzeTrace', traceFilePath: fullPath, insightName },\n                },\n              })\n              .catch((err) => {\n                clearTimeout(timer);\n                chrome.runtime.onMessage.removeListener(listener);\n                reject(err);\n              });\n          });\n          if (resp && resp.success) {\n            // Best-effort cleanup for temp files (Downloads paths are ignored by native cleaner)\n            await cleanupNativeTempFile(fullPath);\n            return {\n              content: [\n                {\n                  type: 'text',\n                  text: JSON.stringify({\n                    success: true,\n                    url: result.tabUrl,\n                    startedAt: result.startedAt,\n                    endedAt: result.endedAt,\n                    durationMs: result.endedAt - result.startedAt,\n                    metrics: result.metrics || {},\n                    saved: result.saved,\n                    summary: resp.summary,\n                    insight: resp.insight,\n                  }),\n                },\n              ],\n              isError: false,\n            };\n          }\n          // If native returned error, fall through to lightweight analysis\n        } catch (e) {\n          // Fallback to lightweight analysis below\n        }\n      }\n\n      // Lightweight fallback (when no saved file path)\n      const counts = new Map<string, number>();\n      for (const ev of result.events.slice(0, 100000)) {\n        const n = typeof (ev as any)?.name === 'string' ? (ev as any).name : 'unknown';\n        counts.set(n, (counts.get(n) || 0) + 1);\n      }\n      const top = [...counts.entries()]\n        .sort((a, b) => b[1] - a[1])\n        .slice(0, 20)\n        .map(([name, count]) => ({ name, count }));\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              info: 'Lightweight analysis (no saved file path). Native-side deep analysis unavailable.',\n              requestedInsight: insightName || null,\n              url: result.tabUrl,\n              startedAt: result.startedAt,\n              endedAt: result.endedAt,\n              durationMs: result.endedAt - result.startedAt,\n              metrics: result.metrics || {},\n              topEventNames: top,\n              saved: result.saved,\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (e: any) {\n      return createErrorResponse(`Failed to analyze trace: ${e?.message || e}`);\n    }\n  }\n}\n\nexport const performanceStartTraceTool = new PerformanceStartTraceTool();\nexport const performanceStopTraceTool = new PerformanceStopTraceTool();\nexport const performanceAnalyzeInsightTool = new PerformanceAnalyzeInsightTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/read-page.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport { ERROR_MESSAGES } from '@/common/constants';\nimport { listMarkersForUrl } from '@/entrypoints/background/element-marker/element-marker-storage';\n\ninterface ReadPageStats {\n  processed: number;\n  included: number;\n  durationMs: number;\n}\n\ninterface ReadPageParams {\n  filter?: 'interactive'; // when omitted, return all visible elements\n  depth?: number; // maximum DOM depth to traverse (0 = root only)\n  refId?: string; // focus on subtree rooted at this refId\n  tabId?: number; // target existing tab id\n  windowId?: number; // when no tabId, pick active tab from this window\n}\n\nclass ReadPageTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.READ_PAGE;\n\n  // Execute read page\n  async execute(args: ReadPageParams): Promise<ToolResult> {\n    const { filter, depth, refId } = args || {};\n\n    // Validate refId parameter\n    const focusRefId = typeof refId === 'string' ? refId.trim() : '';\n    if (refId !== undefined && !focusRefId) {\n      return createErrorResponse(\n        `${ERROR_MESSAGES.INVALID_PARAMETERS}: refId must be a non-empty string`,\n      );\n    }\n\n    // Validate depth parameter\n    const requestedDepth = depth === undefined ? undefined : Number(depth);\n    if (requestedDepth !== undefined && (!Number.isInteger(requestedDepth) || requestedDepth < 0)) {\n      return createErrorResponse(\n        `${ERROR_MESSAGES.INVALID_PARAMETERS}: depth must be a non-negative integer`,\n      );\n    }\n\n    // Track if user explicitly controlled the output (skip sparse heuristics)\n    const userControlled = requestedDepth !== undefined || !!focusRefId;\n\n    try {\n      // Tip text returned to callers to guide next action\n      const standardTips =\n        \"If the specific element you need is missing from the returned data, use the 'screenshot' tool to capture the current viewport and confirm the element's on-screen coordinates. Also note: 'markedElements' are user-marked elements and have the highest priority when choosing targets.\";\n\n      const explicit = await this.tryGetTab(args?.tabId);\n      const tab = explicit || (await this.getActiveTabOrThrowInWindow(args?.windowId));\n      if (!tab.id)\n        return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');\n\n      // Load any user-marked elements for this URL (priority hints)\n      const currentUrl = String(tab.url || '');\n      const userMarkers = currentUrl ? await listMarkersForUrl(currentUrl) : [];\n\n      // Inject helper in ISOLATED world to enable chrome.runtime messaging\n      // Inject into all frames to support same-origin iframe operations\n      await this.injectContentScript(\n        tab.id,\n        ['inject-scripts/accessibility-tree-helper.js'],\n        false,\n        'ISOLATED',\n        true,\n      );\n\n      // Ask content script to generate accessibility tree\n      const resp = await this.sendMessageToTab(tab.id, {\n        action: TOOL_MESSAGE_TYPES.GENERATE_ACCESSIBILITY_TREE,\n        filter: filter || null,\n        depth: requestedDepth,\n        refId: focusRefId || undefined,\n      });\n\n      // Evaluate tree result and decide whether to fallback\n      const treeOk = resp && resp.success === true;\n      const pageContent: string =\n        resp && typeof resp.pageContent === 'string' ? resp.pageContent : '';\n\n      // Extract stats from response\n      const stats: ReadPageStats | null =\n        treeOk && resp?.stats\n          ? {\n              processed: resp.stats.processed ?? 0,\n              included: resp.stats.included ?? 0,\n              durationMs: resp.stats.durationMs ?? 0,\n            }\n          : null;\n\n      const lines = pageContent\n        ? pageContent.split('\\n').filter((l: string) => l.trim().length > 0).length\n        : 0;\n      const refCount = Array.isArray(resp?.refMap) ? resp.refMap.length : 0;\n\n      // Skip sparse heuristics when user explicitly controls output\n      const isSparse = !userControlled && lines < 10 && refCount < 3;\n\n      // Build user-marked elements for inclusion\n      const markedElements = userMarkers.map((m) => ({\n        name: m.name,\n        selector: m.selector,\n        selectorType: m.selectorType || 'css',\n        urlMatch: { type: m.matchType, origin: m.origin, path: m.path },\n        source: 'marker',\n        priority: 'highest',\n      }));\n\n      // Helper to convert elements array to pageContent format\n      const formatElementsAsPageContent = (elements: any[]): string => {\n        const out: string[] = [];\n        for (const e of elements || []) {\n          const type = typeof e?.type === 'string' && e.type ? e.type : 'element';\n          const rawText = typeof e?.text === 'string' ? e.text.trim() : '';\n          const text =\n            rawText.length > 0\n              ? ` \"${rawText.replace(/\\s+/g, ' ').slice(0, 100).replace(/\"/g, '\\\\\"')}\"`\n              : '';\n          const selector =\n            typeof e?.selector === 'string' && e.selector ? ` selector=\"${e.selector}\"` : '';\n          const coords =\n            e?.coordinates && Number.isFinite(e.coordinates.x) && Number.isFinite(e.coordinates.y)\n              ? ` (x=${Math.round(e.coordinates.x)},y=${Math.round(e.coordinates.y)})`\n              : '';\n          out.push(`- ${type}${text}${selector}${coords}`);\n          if (out.length >= 150) break;\n        }\n        return out.join('\\n');\n      };\n\n      // Unified base payload structure - consistent keys for stable contract\n      const basePayload: Record<string, any> = {\n        success: true,\n        filter: filter || 'all',\n        pageContent,\n        tips: standardTips,\n        viewport: treeOk ? resp.viewport : { width: null, height: null, dpr: null },\n        stats: stats || { processed: 0, included: 0, durationMs: 0 },\n        refMapCount: refCount,\n        sparse: treeOk ? isSparse : false,\n        depth: requestedDepth ?? null,\n        focus: focusRefId ? { refId: focusRefId, found: treeOk } : null,\n        markedElements,\n        elements: [],\n        count: 0,\n        fallbackUsed: false,\n        fallbackSource: null,\n        reason: null,\n      };\n\n      // Normal path: return tree\n      if (treeOk && !isSparse) {\n        return {\n          content: [{ type: 'text', text: JSON.stringify(basePayload) }],\n          isError: false,\n        };\n      }\n\n      // When refId is explicitly provided, do not fallback (refs are frame-local and may expire)\n      if (focusRefId) {\n        return createErrorResponse(resp?.error || `refId \"${focusRefId}\" not found or expired`);\n      }\n\n      // When user explicitly controls depth, do not override with fallback heuristics\n      if (requestedDepth !== undefined) {\n        return createErrorResponse(resp?.error || 'Failed to generate accessibility tree');\n      }\n\n      // Fallback path: try get_interactive_elements once\n      try {\n        await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']);\n        const fallback = await this.sendMessageToTab(tab.id, {\n          action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS,\n          includeCoordinates: true,\n        });\n\n        if (fallback && fallback.success && Array.isArray(fallback.elements)) {\n          const limited = fallback.elements.slice(0, 150);\n          // Merge user markers at the front, de-duplicated by selector\n          const markerEls = userMarkers.map((m) => ({\n            type: 'marker',\n            selector: m.selector,\n            text: m.name,\n            selectorType: m.selectorType || 'css',\n            isInteractive: true,\n            source: 'marker',\n            priority: 'highest',\n          }));\n          const seen = new Set(markerEls.map((e) => e.selector));\n          const merged = [...markerEls, ...limited.filter((e: any) => !seen.has(e.selector))];\n\n          basePayload.fallbackUsed = true;\n          basePayload.fallbackSource = 'get_interactive_elements';\n          basePayload.reason = treeOk ? 'sparse_tree' : resp?.error || 'tree_failed';\n          basePayload.elements = merged;\n          basePayload.count = fallback.elements.length;\n          if (!basePayload.pageContent) {\n            basePayload.pageContent = formatElementsAsPageContent(merged);\n          }\n\n          return {\n            content: [{ type: 'text', text: JSON.stringify(basePayload) }],\n            isError: false,\n          };\n        }\n      } catch (fallbackErr) {\n        console.warn('read_page fallback failed:', fallbackErr);\n      }\n\n      // If we reach here, both tree (usable) and fallback failed\n      return createErrorResponse(\n        treeOk\n          ? 'Accessibility tree is too sparse and fallback failed'\n          : resp?.error || 'Failed to generate accessibility tree and fallback failed',\n      );\n    } catch (error) {\n      console.error('Error in read page tool:', error);\n      return createErrorResponse(\n        `Error generating accessibility tree: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const readPageTool = new ReadPageTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/screenshot.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport {\n  canvasToDataURL,\n  createImageBitmapFromUrl,\n  cropAndResizeImage,\n  stitchImages,\n  compressImage,\n} from '../../../../utils/image-utils';\nimport { screenshotContextManager } from '@/utils/screenshot-context';\n\n// Screenshot-specific constants\nconst SCREENSHOT_CONSTANTS = {\n  SCROLL_DELAY_MS: 350, // Time to wait after scroll for rendering and lazy loading\n  CAPTURE_STITCH_DELAY_MS: 50, // Small delay between captures in a scroll sequence\n  MAX_CAPTURE_PARTS: 50, // Maximum number of parts to capture (for infinite scroll pages)\n  MAX_CAPTURE_HEIGHT_PX: 50000, // Maximum height in pixels to capture\n  PIXEL_TOLERANCE: 1,\n  SCRIPT_INIT_DELAY: 100, // Delay for script initialization\n} as {\n  readonly SCROLL_DELAY_MS: number;\n  CAPTURE_STITCH_DELAY_MS: number; // This one is mutable\n  readonly MAX_CAPTURE_PARTS: number;\n  readonly MAX_CAPTURE_HEIGHT_PX: number;\n  readonly PIXEL_TOLERANCE: number;\n  readonly SCRIPT_INIT_DELAY: number;\n};\n\n// Adjust CAPTURE_STITCH_DELAY_MS to respect Chrome's capture rate if available in runtime\n// Some TS typings don't expose MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND; use a safe cast with a sane fallback.\nconst __MAX_CAP_RATE: number | undefined = (chrome.tabs as any)\n  ?.MAX_CAPTURE_VISIBLE_TAB_CALLS_PER_SECOND;\nif (typeof __MAX_CAP_RATE === 'number' && __MAX_CAP_RATE > 0) {\n  // Minimum interval between consecutive captureVisibleTab calls (ms)\n  const minIntervalMs = Math.ceil(1000 / __MAX_CAP_RATE);\n  // Our capture loop already waits SCROLL_DELAY_MS between scroll and capture; add any extra delay needed\n  const requiredExtraDelay = Math.max(0, minIntervalMs - SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS);\n  SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS = Math.max(\n    requiredExtraDelay,\n    SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS,\n  );\n}\n\ninterface ScreenshotToolParams {\n  name: string;\n  selector?: string;\n  tabId?: number;\n  background?: boolean;\n  windowId?: number;\n  width?: number;\n  height?: number;\n  storeBase64?: boolean;\n  fullPage?: boolean;\n  savePng?: boolean;\n  maxHeight?: number; // Maximum height to capture in pixels (for infinite scroll pages)\n}\n\n/** Page details returned by screenshot-helper content script */\ninterface ScreenshotPageDetails {\n  totalWidth: number;\n  totalHeight: number;\n  viewportWidth: number;\n  viewportHeight: number;\n  devicePixelRatio: number;\n  currentScrollX: number;\n  currentScrollY: number;\n}\n\nconst PAGE_DETAILS_REQUIRED_FIELDS: Array<keyof ScreenshotPageDetails> = [\n  'totalWidth',\n  'totalHeight',\n  'viewportWidth',\n  'viewportHeight',\n  'devicePixelRatio',\n  'currentScrollX',\n  'currentScrollY',\n];\n\n/**\n * Validates and asserts that the response from content script contains valid page details\n */\nfunction assertValidPageDetails(details: unknown): ScreenshotPageDetails {\n  if (!details || typeof details !== 'object') {\n    throw new Error(\n      'Screenshot helper did not respond. The content script may not be injected or cannot run on this page.',\n    );\n  }\n\n  const candidate = details as Partial<ScreenshotPageDetails>;\n  const invalidFields = PAGE_DETAILS_REQUIRED_FIELDS.filter(\n    (field) => typeof candidate[field] !== 'number' || !Number.isFinite(candidate[field]),\n  );\n\n  if (invalidFields.length > 0) {\n    throw new Error(\n      `Screenshot helper returned invalid page details (missing/invalid: ${invalidFields.join(', ')}).`,\n    );\n  }\n\n  return candidate as ScreenshotPageDetails;\n}\n\n/**\n * Tool for capturing screenshots of web pages\n */\nclass ScreenshotTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.SCREENSHOT;\n\n  /**\n   * Execute screenshot operation\n   */\n  async execute(args: ScreenshotToolParams): Promise<ToolResult> {\n    const {\n      name = 'screenshot',\n      selector,\n      storeBase64 = false,\n      fullPage = false,\n      savePng = true,\n    } = args;\n\n    console.log(`Starting screenshot with options:`, args);\n\n    // Resolve target tab (explicit or active)\n    const explicit = await this.tryGetTab(args.tabId);\n    const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));\n\n    // Check URL restrictions\n    if (\n      tab.url?.startsWith('chrome://') ||\n      tab.url?.startsWith('edge://') ||\n      tab.url?.startsWith('https://chrome.google.com/webstore') ||\n      tab.url?.startsWith('https://microsoftedge.microsoft.com/')\n    ) {\n      return createErrorResponse(\n        'Cannot capture special browser pages or web store pages due to security restrictions.',\n      );\n    }\n\n    let finalImageDataUrl: string | undefined;\n    let finalImageWidthCss: number | undefined;\n    let finalImageHeightCss: number | undefined;\n    const results: any = { base64: null, fileSaved: false };\n    let originalScroll: { x: number; y: number } | null = null;\n    let didPreparePage = false;\n    let pageDetails: ScreenshotPageDetails | undefined;\n\n    try {\n      const background = args.background === true;\n      // CDP path: background=true with simple viewport capture (no fullPage, no selector)\n      const canUseCdpCapture = background && !fullPage && !selector;\n\n      // === Path 1: CDP viewport capture (no content script needed) ===\n      if (canUseCdpCapture) {\n        try {\n          const tabId = tab.id!;\n          const { cdpSessionManager } = await import('@/utils/cdp-session-manager');\n          await cdpSessionManager.withSession(tabId, 'screenshot', async () => {\n            const metrics: any = await cdpSessionManager.sendCommand(\n              tabId,\n              'Page.getLayoutMetrics',\n              {},\n            );\n            const viewport = metrics?.layoutViewport ||\n              metrics?.visualViewport || {\n                clientWidth: 800,\n                clientHeight: 600,\n                pageX: 0,\n                pageY: 0,\n              };\n            const shot: any = await cdpSessionManager.sendCommand(tabId, 'Page.captureScreenshot', {\n              format: 'png',\n            });\n            const base64Data = typeof shot?.data === 'string' ? shot.data : '';\n            if (!base64Data) {\n              throw new Error('CDP Page.captureScreenshot returned empty data');\n            }\n            finalImageDataUrl = `data:image/png;base64,${base64Data}`;\n            finalImageWidthCss = Math.round(viewport.clientWidth || 800);\n            finalImageHeightCss = Math.round(viewport.clientHeight || 600);\n          });\n        } catch (e) {\n          console.warn('CDP viewport capture failed, falling back to helper path:', e);\n        }\n      }\n\n      // === Path 2: Helper-assisted capture (requires content script) ===\n      if (!finalImageDataUrl) {\n        // Always inject helper when we need pageDetails\n        await this.injectContentScript(tab.id!, ['inject-scripts/screenshot-helper.js']);\n        await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));\n\n        // Prepare page (hide scrollbars, handle fixed elements)\n        const prepareResp = await this.sendMessageToTab(tab.id!, {\n          action: TOOL_MESSAGE_TYPES.SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE,\n          options: { fullPage },\n        });\n        if (!prepareResp || prepareResp.success !== true) {\n          throw new Error(\n            'Screenshot helper did not acknowledge page preparation. The content script may not be injected or cannot run on this page.',\n          );\n        }\n        didPreparePage = true;\n\n        // Get page details with validation\n        const rawPageDetails = await this.sendMessageToTab(tab.id!, {\n          action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_PAGE_DETAILS,\n        });\n        pageDetails = assertValidPageDetails(rawPageDetails);\n        originalScroll = { x: pageDetails.currentScrollX, y: pageDetails.currentScrollY };\n\n        if (fullPage) {\n          this.logInfo('Capturing full page...');\n          finalImageDataUrl = await this._captureFullPage(tab.id!, args, pageDetails);\n          // Compute final CSS size\n          if (args.width && args.height) {\n            finalImageWidthCss = args.width;\n            finalImageHeightCss = args.height;\n          } else if (args.width && !args.height) {\n            finalImageWidthCss = args.width;\n            const ratio = pageDetails.totalHeight / pageDetails.totalWidth;\n            finalImageHeightCss = Math.round(args.width * ratio);\n          } else if (!args.width && args.height) {\n            finalImageHeightCss = args.height;\n            const ratio = pageDetails.totalWidth / pageDetails.totalHeight;\n            finalImageWidthCss = Math.round(args.height * ratio);\n          } else {\n            finalImageWidthCss = pageDetails.totalWidth;\n            finalImageHeightCss = pageDetails.totalHeight;\n          }\n        } else if (selector) {\n          this.logInfo(`Capturing element: ${selector}`);\n          finalImageDataUrl = await this._captureElement(\n            tab.id!,\n            args,\n            pageDetails.devicePixelRatio,\n          );\n          if (args.width && args.height) {\n            finalImageWidthCss = args.width;\n            finalImageHeightCss = args.height;\n          } else {\n            finalImageWidthCss = pageDetails.viewportWidth;\n            finalImageHeightCss = pageDetails.viewportHeight;\n          }\n        } else {\n          // Visible area only\n          this.logInfo('Capturing visible area...');\n          finalImageDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });\n          finalImageWidthCss = pageDetails.viewportWidth;\n          finalImageHeightCss = pageDetails.viewportHeight;\n        }\n      }\n\n      if (!finalImageDataUrl) {\n        throw new Error('Failed to capture image data');\n      }\n\n      // 2. Process output\n      // Update screenshot context for coordinate scaling by tools like chrome_computer\n      try {\n        if (typeof finalImageWidthCss === 'number' && typeof finalImageHeightCss === 'number') {\n          let hostname = '';\n          try {\n            hostname = tab.url ? new URL(tab.url).hostname : '';\n          } catch {\n            // ignore\n          }\n          // Use pageDetails if available, otherwise fall back to final image dimensions\n          const viewportWidth = pageDetails?.viewportWidth ?? finalImageWidthCss;\n          const viewportHeight = pageDetails?.viewportHeight ?? finalImageHeightCss;\n          screenshotContextManager.setContext(tab.id!, {\n            screenshotWidth: finalImageWidthCss,\n            screenshotHeight: finalImageHeightCss,\n            viewportWidth,\n            viewportHeight,\n            devicePixelRatio: pageDetails?.devicePixelRatio,\n            hostname,\n          });\n        }\n      } catch (e) {\n        console.warn('Failed to set screenshot context:', e);\n      }\n      if (storeBase64 === true) {\n        // Compress image for base64 output to reduce size\n        const compressed = await compressImage(finalImageDataUrl, {\n          scale: 0.7, // Reduce dimensions by 30%\n          quality: 0.8, // 80% quality for good balance\n          format: 'image/jpeg', // JPEG for better compression\n        });\n\n        // Include base64 data in response (without prefix)\n        const base64Data = compressed.dataUrl.replace(/^data:image\\/[^;]+;base64,/, '');\n        results.base64 = base64Data;\n        return {\n          content: [\n            {\n              type: 'text',\n              text: JSON.stringify({ base64Data, mimeType: compressed.mimeType }),\n            },\n          ],\n          isError: false,\n        };\n      }\n\n      if (savePng === true) {\n        // Save PNG file to downloads\n        this.logInfo('Saving PNG...');\n        try {\n          // Generate filename\n          const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n          const filename = `${name.replace(/[^a-z0-9_-]/gi, '_') || 'screenshot'}_${timestamp}.png`;\n\n          // Use Chrome's download API to save the file\n          const downloadId = await chrome.downloads.download({\n            url: finalImageDataUrl,\n            filename: filename,\n            saveAs: false,\n          });\n\n          results.downloadId = downloadId;\n          results.filename = filename;\n          results.fileSaved = true;\n\n          // Try to get the full file path\n          try {\n            // Wait a moment to ensure download info is updated\n            await new Promise((resolve) => setTimeout(resolve, 100));\n\n            // Search for download item to get full path\n            const [downloadItem] = await chrome.downloads.search({ id: downloadId });\n            if (downloadItem && downloadItem.filename) {\n              // Add full path to response\n              results.fullPath = downloadItem.filename;\n            }\n          } catch (pathError) {\n            console.warn('Could not get full file path:', pathError);\n          }\n        } catch (error) {\n          console.error('Error saving PNG file:', error);\n          results.saveError = String(error instanceof Error ? error.message : error);\n        }\n      }\n    } catch (error) {\n      console.error('Error during screenshot execution:', error);\n      return createErrorResponse(\n        `Screenshot error: ${error instanceof Error ? error.message : JSON.stringify(error)}`,\n      );\n    } finally {\n      // 3. Reset page only if we prepared it\n      if (didPreparePage) {\n        try {\n          // Only include scroll position if we successfully captured it\n          const resetMessage: Record<string, unknown> = {\n            action: TOOL_MESSAGE_TYPES.SCREENSHOT_RESET_PAGE_AFTER_CAPTURE,\n          };\n          if (originalScroll) {\n            resetMessage.scrollX = originalScroll.x;\n            resetMessage.scrollY = originalScroll.y;\n          }\n          await this.sendMessageToTab(tab.id!, resetMessage);\n        } catch (err) {\n          console.warn('Failed to reset page, tab might have closed:', err);\n        }\n      }\n    }\n\n    this.logInfo('Screenshot completed!');\n\n    return {\n      content: [\n        {\n          type: 'text',\n          text: JSON.stringify({\n            success: true,\n            message: `Screenshot [${name}] captured successfully`,\n            tabId: tab.id,\n            url: tab.url,\n            name: name,\n            ...results,\n          }),\n        },\n      ],\n      isError: false,\n    };\n  }\n\n  /**\n   * Log information\n   */\n  private logInfo(message: string) {\n    console.log(`[Screenshot Tool] ${message}`);\n  }\n\n  /**\n   * Capture specific element\n   */\n  async _captureElement(\n    tabId: number,\n    options: ScreenshotToolParams,\n    pageDpr: number,\n  ): Promise<string> {\n    const elementDetails = await this.sendMessageToTab(tabId, {\n      action: TOOL_MESSAGE_TYPES.SCREENSHOT_GET_ELEMENT_DETAILS,\n      selector: options.selector,\n    });\n\n    const dpr = elementDetails.devicePixelRatio || pageDpr || 1;\n\n    // Element rect is viewport-relative, in CSS pixels\n    // captureVisibleTab captures in physical pixels\n    const cropRectPx = {\n      x: elementDetails.rect.x * dpr,\n      y: elementDetails.rect.y * dpr,\n      width: elementDetails.rect.width * dpr,\n      height: elementDetails.rect.height * dpr,\n    };\n\n    // Small delay to ensure element is fully rendered after scrollIntoView\n    await new Promise((resolve) => setTimeout(resolve, SCREENSHOT_CONSTANTS.SCRIPT_INIT_DELAY));\n\n    const visibleCaptureDataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });\n    if (!visibleCaptureDataUrl) {\n      throw new Error('Failed to capture visible tab for element cropping');\n    }\n\n    const croppedCanvas = await cropAndResizeImage(\n      visibleCaptureDataUrl,\n      cropRectPx,\n      dpr,\n      options.width, // Target output width in CSS pixels\n      options.height, // Target output height in CSS pixels\n    );\n    return canvasToDataURL(croppedCanvas);\n  }\n\n  /**\n   * Capture full page\n   */\n  async _captureFullPage(\n    tabId: number,\n    options: ScreenshotToolParams,\n    initialPageDetails: any,\n  ): Promise<string> {\n    const dpr = initialPageDetails.devicePixelRatio;\n    const totalWidthCss = options.width || initialPageDetails.totalWidth; // Use option width if provided\n    const totalHeightCss = initialPageDetails.totalHeight; // Full page always uses actual height\n\n    // Apply maximum height limit for infinite scroll pages\n    const maxHeightPx = options.maxHeight || SCREENSHOT_CONSTANTS.MAX_CAPTURE_HEIGHT_PX;\n    const limitedHeightCss = Math.min(totalHeightCss, maxHeightPx / dpr);\n\n    const totalWidthPx = totalWidthCss * dpr;\n    const totalHeightPx = limitedHeightCss * dpr;\n\n    // Viewport dimensions (CSS pixels) - logged for debugging\n    this.logInfo(\n      `Viewport size: ${initialPageDetails.viewportWidth}x${initialPageDetails.viewportHeight} CSS pixels`,\n    );\n    this.logInfo(\n      `Page dimensions: ${totalWidthCss}x${totalHeightCss} CSS pixels (limited to ${limitedHeightCss} height)`,\n    );\n\n    const viewportHeightCss = initialPageDetails.viewportHeight;\n\n    const capturedParts = [];\n    let currentScrollYCss = 0;\n    let capturedHeightPx = 0;\n    let partIndex = 0;\n\n    while (capturedHeightPx < totalHeightPx && partIndex < SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {\n      this.logInfo(\n        `Capturing part ${partIndex + 1}... (${Math.round((capturedHeightPx / totalHeightPx) * 100)}%)`,\n      );\n\n      if (currentScrollYCss > 0) {\n        // Don't scroll for the first part if already at top\n        const scrollResp = await this.sendMessageToTab(tabId, {\n          action: TOOL_MESSAGE_TYPES.SCREENSHOT_SCROLL_PAGE,\n          x: 0,\n          y: currentScrollYCss,\n          scrollDelay: SCREENSHOT_CONSTANTS.SCROLL_DELAY_MS,\n        });\n        // Update currentScrollYCss based on actual scroll achieved\n        currentScrollYCss = scrollResp.newScrollY;\n      }\n\n      // Ensure rendering after scroll\n      await new Promise((resolve) =>\n        setTimeout(resolve, SCREENSHOT_CONSTANTS.CAPTURE_STITCH_DELAY_MS),\n      );\n\n      const dataUrl = await chrome.tabs.captureVisibleTab({ format: 'png' });\n      if (!dataUrl) throw new Error('captureVisibleTab returned empty during full page capture');\n\n      const yOffsetPx = currentScrollYCss * dpr;\n      capturedParts.push({ dataUrl, y: yOffsetPx });\n\n      const imgForHeight = await createImageBitmapFromUrl(dataUrl); // To get actual captured height\n      const lastPartEffectiveHeightPx = Math.min(imgForHeight.height, totalHeightPx - yOffsetPx);\n\n      capturedHeightPx = yOffsetPx + lastPartEffectiveHeightPx;\n\n      if (capturedHeightPx >= totalHeightPx - SCREENSHOT_CONSTANTS.PIXEL_TOLERANCE) break;\n\n      currentScrollYCss += viewportHeightCss;\n      // Prevent overscrolling past the document height for the next scroll command\n      if (\n        currentScrollYCss > totalHeightCss - viewportHeightCss &&\n        currentScrollYCss < totalHeightCss\n      ) {\n        currentScrollYCss = totalHeightCss - viewportHeightCss;\n      }\n      partIndex++;\n    }\n\n    // Check if we hit any limits\n    if (partIndex >= SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS) {\n      this.logInfo(\n        `Reached maximum number of capture parts (${SCREENSHOT_CONSTANTS.MAX_CAPTURE_PARTS}). This may be an infinite scroll page.`,\n      );\n    }\n    if (totalHeightCss > limitedHeightCss) {\n      this.logInfo(\n        `Page height (${totalHeightCss}px) exceeds maximum capture height (${maxHeightPx / dpr}px). Capturing limited portion.`,\n      );\n    }\n\n    this.logInfo('Stitching image...');\n    const finalCanvas = await stitchImages(capturedParts, totalWidthPx, totalHeightPx);\n\n    // If user specified width but not height (or vice versa for full page), resize maintaining aspect ratio\n    let outputCanvas = finalCanvas;\n    if (options.width && !options.height) {\n      const targetWidthPx = options.width * dpr;\n      const aspectRatio = finalCanvas.height / finalCanvas.width;\n      const targetHeightPx = targetWidthPx * aspectRatio;\n      outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);\n      const ctx = outputCanvas.getContext('2d');\n      if (ctx) {\n        ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);\n      }\n    } else if (options.height && !options.width) {\n      const targetHeightPx = options.height * dpr;\n      const aspectRatio = finalCanvas.width / finalCanvas.height;\n      const targetWidthPx = targetHeightPx * aspectRatio;\n      outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);\n      const ctx = outputCanvas.getContext('2d');\n      if (ctx) {\n        ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);\n      }\n    } else if (options.width && options.height) {\n      // Both specified, direct resize\n      const targetWidthPx = options.width * dpr;\n      const targetHeightPx = options.height * dpr;\n      outputCanvas = new OffscreenCanvas(targetWidthPx, targetHeightPx);\n      const ctx = outputCanvas.getContext('2d');\n      if (ctx) {\n        ctx.drawImage(finalCanvas, 0, 0, targetWidthPx, targetHeightPx);\n      }\n    }\n\n    return canvasToDataURL(outputCanvas);\n  }\n}\n\nexport const screenshotTool = new ScreenshotTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/userscript.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ExecutionWorld, STORAGE_KEYS } from '@/common/constants';\nimport { cdpSessionManager } from '@/utils/cdp-session-manager';\n\ntype UserscriptAction =\n  | 'create'\n  | 'list'\n  | 'get'\n  | 'enable'\n  | 'disable'\n  | 'update'\n  | 'remove'\n  | 'send_command'\n  | 'export';\n\ninterface UserscriptArgsBase {\n  action: UserscriptAction;\n  args?: any;\n}\n\ninterface CreateArgs {\n  script: string;\n  name?: string;\n  description?: string;\n  matches?: string[];\n  excludes?: string[];\n  persist?: boolean; // default true\n  runAt?: 'document_start' | 'document_end' | 'document_idle' | 'auto'; // default auto(document_idle)\n  world?: 'auto' | 'ISOLATED' | 'MAIN'; // default auto(ISOLATED)\n  allFrames?: boolean; // default true\n  mode?: 'auto' | 'css' | 'persistent' | 'once'; // default auto\n  dnrFallback?: boolean; // default true\n  tags?: string[];\n}\n\ntype UpdateArgs = Partial<Omit<CreateArgs, 'script'>> & { id: string; script?: string };\n\ninterface UserscriptRecord {\n  id: string;\n  name?: string;\n  description?: string;\n  script: string;\n  sourceType: 'JS' | 'CSS' | 'TM';\n  matches: string[];\n  excludes: string[];\n  runAt: 'document_start' | 'document_end' | 'document_idle';\n  world: 'ISOLATED' | 'MAIN';\n  allFrames: boolean;\n  persist: boolean;\n  dnrFallback: boolean;\n  tags?: string[];\n  enabled: boolean;\n  createdAt: number;\n  updatedAt: number;\n  installedBy?: string;\n  lastError?: string;\n  applyCount?: number;\n  lastAppliedAt?: number;\n  sha256?: string;\n  cspBlocked?: boolean;\n}\n\n// In-memory tracking of active injections per tab\ntype ActiveInjection = { kind: 'css' | 'js'; world?: 'ISOLATED' | 'MAIN' };\nconst activeInjections: Map<number, Map<string, ActiveInjection>> = new Map();\n\nasync function loadAllRecords(): Promise<Record<string, UserscriptRecord>> {\n  const res = await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS]);\n  return (res[STORAGE_KEYS.USERSCRIPTS] as Record<string, UserscriptRecord>) || {};\n}\n\nasync function saveAllRecords(records: Record<string, UserscriptRecord>): Promise<void> {\n  await chrome.storage.local.set({ [STORAGE_KEYS.USERSCRIPTS]: records });\n}\n\n// Simple FNV-1a hash for deterministic IDs\nfunction fnv1a(str: string): string {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < str.length; i++) {\n    h ^= str.charCodeAt(i);\n    h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);\n  }\n  // Force to unsigned and hex\n  return (h >>> 0).toString(16);\n}\n\nfunction now(): number {\n  return Date.now();\n}\n\nasync function computeSHA256(input: string): Promise<string> {\n  const enc = new TextEncoder().encode(input);\n  const digest = await crypto.subtle.digest('SHA-256', enc);\n  const bytes = Array.from(new Uint8Array(digest));\n  return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nasync function probeUnsafeEvalInMain(tabId: number): Promise<boolean> {\n  try {\n    const res = await chrome.scripting.executeScript({\n      target: { tabId, allFrames: false },\n      world: ExecutionWorld.MAIN,\n      func: () => {\n        try {\n          // If page CSP blocks unsafe-eval, this will throw\n          return !!new Function('return 1')();\n        } catch {\n          return false;\n        }\n      },\n    });\n    return Array.isArray(res) && res[0] && (res[0] as any).result === true;\n  } catch {\n    return false;\n  }\n}\n\n// Basic TM header parser (subset)\nfunction parseUserscriptMeta(source: string): {\n  meta: Record<string, string[]>;\n  isTM: boolean;\n} {\n  const meta: Record<string, string[]> = {};\n  const start = source.indexOf('==UserScript==');\n  const end = source.indexOf('==/UserScript==');\n  if (start !== -1 && end !== -1 && end > start) {\n    const block = source.slice(start, end).split(/\\r?\\n/);\n    for (const line of block) {\n      const m = line.match(/@([\\w-]+)\\s+(.+)/);\n      if (m) {\n        const k = m[1].trim();\n        const v = m[2].trim();\n        if (!meta[k]) meta[k] = [];\n        meta[k].push(v);\n      }\n    }\n    return { meta, isTM: true };\n  }\n  return { meta: {}, isTM: false };\n}\n\nfunction pick<T>(arr: T[] | undefined): T | undefined {\n  return arr && arr.length > 0 ? arr[0] : undefined;\n}\n\nfunction deriveName(meta: Record<string, string[]>, fallback?: string): string | undefined {\n  return pick(meta['name']) || fallback;\n}\n\nfunction toBoolean(val: any, d: boolean): boolean {\n  return typeof val === 'boolean' ? val : d;\n}\n\n// Very light CSS heuristic\nfunction isLikelyCSS(source: string): boolean {\n  const trimmed = source.trim();\n  if (trimmed.startsWith('/*') && trimmed.includes('==UserStyle')) return true;\n  if (/^[.#\\w\\-\\s*,:>+~\\n\\r{}();'\"%!@/]+$/.test(trimmed)) {\n    // no obvious JS keywords\n    if (\n      !/(function|=>|var\\s|let\\s|const\\s|document\\.|window\\.|\\beval\\b|new\\s+Function)/.test(trimmed)\n    ) {\n      // has CSS braces and colons\n      const colon = (trimmed.match(/:/g) || []).length;\n      const brace = (trimmed.match(/[{}]/g) || []).length;\n      return colon > 0 && brace >= 2;\n    }\n  }\n  return false;\n}\n\nfunction normalizeMatches(matches?: string[], currentUrl?: string): string[] {\n  if (matches && matches.length > 0) return matches;\n  if (!currentUrl) return ['<all_urls>'];\n  try {\n    const u = new URL(currentUrl);\n    const host = u.hostname;\n    const base = host.startsWith('www.') ? host.slice(4) : host;\n    return [`${u.protocol}//*.${base}/*`, `${u.protocol}//${host}/*`];\n  } catch {\n    return ['<all_urls>'];\n  }\n}\n\n// Simple URL match using chrome match patterns subset\nfunction matchUrl(patterns: string[], url?: string): boolean {\n  if (!url) return false;\n  try {\n    const u = new URL(url);\n    for (const p of patterns) {\n      if (p === '<all_urls>') return true;\n      const m = p.match(/^(\\*|https?:)\\/\\/([^/]+)\\/(.*)$/);\n      if (!m) continue;\n      const proto = m[1];\n      const host = m[2];\n      const path = m[3];\n      if (proto !== '*' && proto !== u.protocol.replace(':', '')) continue;\n      // host wildcard\n      const hostRegex = new RegExp(\n        '^' +\n          host\n            .split('.')\n            .map((h) => (h === '*' ? '[^.]+' : h.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, '\\\\$&')))\n            .join('\\\\.') +\n          '$',\n      );\n      if (!hostRegex.test(u.hostname)) continue;\n      // path wildcard\n      const pathRegex = new RegExp(\n        '^' + path.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\*/g, '.*') + '$',\n      );\n      const testPath = (u.pathname + (u.search || '') + (u.hash || '')).replace(/^\\//, '');\n      if (pathRegex.test(testPath)) return true;\n    }\n  } catch {\n    return false;\n  }\n  return false;\n}\n\nasync function getActiveTab(): Promise<chrome.tabs.Tab | null> {\n  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n  return tabs[0] || null;\n}\n\nasync function insertCssToTab(tabId: number, css: string, allFrames: boolean) {\n  await chrome.scripting.insertCSS({ target: { tabId, allFrames }, css });\n}\n\nasync function removeCssFromTab(tabId: number, css: string, allFrames: boolean) {\n  try {\n    await chrome.scripting.removeCSS({ target: { tabId, allFrames }, css });\n  } catch (e) {\n    // ignore if not present\n  }\n}\n\nasync function injectJsPersistent(\n  tabId: number,\n  code: string,\n  world: 'ISOLATED' | 'MAIN',\n  allFrames: boolean,\n) {\n  if (world === ExecutionWorld.MAIN) {\n    // Ensure bridge is present in ISOLATED\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames },\n      files: ['inject-scripts/inject-bridge.js'],\n      world: ExecutionWorld.ISOLATED,\n    });\n    // MAIN world code with command handler wrapper\n    const wrapped = `(() => {\n      try {\n        // Optional command API: window.__userscript_onCommand(action, payload)\n        window.addEventListener('chrome-mcp:execute', (ev) => {\n          const { action, payload, requestId } = ev.detail || {};\n          try {\n            let result;\n            const handler = (window as any).__userscript_onCommand;\n            if (typeof handler === 'function') {\n              result = handler(action, payload);\n            }\n            window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, data: result } }));\n          } catch (err) {\n            window.dispatchEvent(new CustomEvent('chrome-mcp:response', { detail: { requestId, error: String(err && (err as any).message || err) } }));\n          }\n        });\n        (new Function(${JSON.stringify(code)}))();\n      } catch (e) {\n        console.warn('Userscript MAIN injection error:', e);\n      }\n    })();`;\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames },\n      func: (src) => {\n        try {\n          // Using Function constructor intentionally to evaluate user-provided script\n          new Function(src)();\n        } catch (e) {\n          console.warn('Userscript MAIN wrapper execution error:', e);\n        }\n      },\n      args: [wrapped],\n      world: ExecutionWorld.MAIN,\n    });\n  } else {\n    // ISOLATED world code with message handler\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames },\n      func: (userCode) => {\n        try {\n          const handlerName = '__userscript_onCommand__';\n          (chrome.runtime.onMessage as any).addListener(\n            (req: any, _sender: any, sendResponse: any) => {\n              if (!req || req.type !== 'userscript:command') return;\n              const { action, payload, scriptId } = req;\n              try {\n                const handler = (globalThis as any)[handlerName];\n                let result;\n                if (typeof handler === 'function') {\n                  result = handler(action, payload, scriptId);\n                }\n                sendResponse({ data: result });\n              } catch (err) {\n                sendResponse({ error: String((err && (err as any).message) || err) });\n              }\n              return true;\n            },\n          );\n          // Using Function constructor intentionally to evaluate user-provided script\n          new Function(userCode)();\n        } catch (e) {\n          console.warn('Userscript ISOLATED injection error:', e);\n        }\n      },\n      args: [code],\n      world: ExecutionWorld.ISOLATED,\n    });\n  }\n}\n\nfunction setActiveInjection(tabId: number, id: string, inj: ActiveInjection) {\n  let m = activeInjections.get(tabId);\n  if (!m) {\n    m = new Map();\n    activeInjections.set(tabId, m);\n  }\n  m.set(id, inj);\n}\n\nfunction clearActiveInjection(tabId: number, id: string) {\n  const m = activeInjections.get(tabId);\n  if (m) m.delete(id);\n}\n\nasync function reinjectForTab(tabId: number, url?: string) {\n  // Emergency global switch\n  const flag = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[\n    STORAGE_KEYS.USERSCRIPTS_DISABLED\n  ];\n  if (flag) return;\n  const all = await loadAllRecords();\n  for (const rec of Object.values(all)) {\n    if (!rec.enabled || !rec.persist) continue;\n    if (!matchUrl(rec.matches, url)) continue;\n    try {\n      if (rec.sourceType === 'CSS') {\n        await insertCssToTab(tabId, rec.script, rec.allFrames);\n        setActiveInjection(tabId, rec.id, { kind: 'css' });\n      } else {\n        // Probe CSP when targeting MAIN\n        if (rec.world === 'MAIN') {\n          const ok = await probeUnsafeEvalInMain(tabId);\n          if (!ok) {\n            rec.cspBlocked = true;\n            await injectJsPersistent(tabId, rec.script, 'ISOLATED', rec.allFrames);\n            setActiveInjection(tabId, rec.id, { kind: 'js', world: 'ISOLATED' });\n            continue;\n          }\n        }\n        await injectJsPersistent(tabId, rec.script, rec.world, rec.allFrames);\n        setActiveInjection(tabId, rec.id, { kind: 'js', world: rec.world });\n      }\n    } catch (e) {\n      console.warn('Reinject failed for tab', tabId, rec.id, e);\n    }\n  }\n}\n\n// Tab update listener: re-apply enabled persistent scripts\nchrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {\n  if (changeInfo.status === 'complete') {\n    reinjectForTab(tabId, tab.url).catch(() => {});\n  }\n});\n\n// webNavigation based runAt mapping\nchrome.webNavigation.onCommitted.addListener(async (details) => {\n  if (details.frameId !== 0) return;\n  const tab = await chrome.tabs.get(details.tabId).catch(() => null);\n  if (!tab) return;\n  const disabled = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[\n    STORAGE_KEYS.USERSCRIPTS_DISABLED\n  ];\n  if (disabled) return;\n  const all = await loadAllRecords();\n  for (const rec of Object.values(all)) {\n    if (!rec.enabled || !rec.persist || rec.runAt !== 'document_start') continue;\n    if (!matchUrl(rec.matches, tab.url)) continue;\n    try {\n      if (rec.sourceType === 'CSS') await insertCssToTab(details.tabId, rec.script, rec.allFrames);\n      else await injectJsPersistent(details.tabId, rec.script, rec.world, rec.allFrames);\n    } catch {\n      // noop\n    }\n  }\n});\n\nchrome.webNavigation.onDOMContentLoaded.addListener(async (details) => {\n  if (details.frameId !== 0) return;\n  const tab = await chrome.tabs.get(details.tabId).catch(() => null);\n  if (!tab) return;\n  const disabled = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[\n    STORAGE_KEYS.USERSCRIPTS_DISABLED\n  ];\n  if (disabled) return;\n  const all = await loadAllRecords();\n  for (const rec of Object.values(all)) {\n    if (!rec.enabled || !rec.persist || rec.runAt !== 'document_end') continue;\n    if (!matchUrl(rec.matches, tab.url)) continue;\n    try {\n      if (rec.sourceType === 'CSS') await insertCssToTab(details.tabId, rec.script, rec.allFrames);\n      else await injectJsPersistent(details.tabId, rec.script, rec.world, rec.allFrames);\n    } catch {\n      // noop\n    }\n  }\n});\n\nclass UserscriptTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.USERSCRIPT;\n\n  async execute(params: UserscriptArgsBase): Promise<ToolResult> {\n    try {\n      const { action } = params;\n      const args = params.args || {};\n\n      switch (action) {\n        case 'create':\n          return await this.create(args as CreateArgs);\n        case 'list':\n          return await this.list(args);\n        case 'get':\n          return await this.get(args);\n        case 'enable':\n          return await this.enable(args, true);\n        case 'disable':\n          return await this.enable(args, false);\n        case 'update':\n          return await this.update(args as UpdateArgs);\n        case 'remove':\n          return await this.remove(args);\n        case 'send_command':\n          return await this.sendCommand(args);\n        case 'export':\n          return await this.exportAll();\n        default:\n          return createErrorResponse(`Unknown action: ${String(action)}`);\n      }\n    } catch (error) {\n      console.error('Userscript tool error:', error);\n      return createErrorResponse(\n        `Userscript error: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  private async create(args: CreateArgs): Promise<ToolResult> {\n    const active = await getActiveTab();\n    if (!active || !active.id) return createErrorResponse('No active tab found');\n    const currentUrl = active.url;\n\n    const emergency = (await chrome.storage.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED]))[\n      STORAGE_KEYS.USERSCRIPTS_DISABLED\n    ];\n\n    const { meta, isTM } = parseUserscriptMeta(args.script);\n    const name = args.name || deriveName(meta, undefined);\n    const description = args.description || pick(meta['description']);\n    const matches = normalizeMatches(args.matches || meta['match'] || meta['include'], currentUrl);\n    const excludes = args.excludes || meta['exclude'] || [];\n\n    const runAt: UserscriptRecord['runAt'] =\n      (args.runAt && args.runAt !== 'auto' ? args.runAt : (pick(meta['run-at']) as any)) ||\n      'document_idle';\n    const requestedWorld =\n      (args.world && args.world !== 'auto' ? args.world : (pick(meta['inject-into']) as any)) ||\n      'ISOLATED';\n    const allFrames = toBoolean(args.allFrames, true);\n    const persist = toBoolean(args.persist, true);\n    const dnrFallback = toBoolean(args.dnrFallback, true);\n    const mode = args.mode || 'auto';\n\n    const sourceType: UserscriptRecord['sourceType'] = isTM\n      ? 'TM'\n      : mode === 'css' || isLikelyCSS(args.script)\n        ? 'CSS'\n        : 'JS';\n\n    const sha256 = await computeSHA256(args.script).catch(() => undefined);\n    const id = `us_${fnv1a((name || '') + '|' + args.script)}`;\n\n    const record: UserscriptRecord = {\n      id,\n      name,\n      description,\n      script: args.script,\n      sourceType,\n      matches,\n      excludes,\n      runAt,\n      world: requestedWorld === 'MAIN' ? 'MAIN' : 'ISOLATED',\n      allFrames,\n      persist,\n      dnrFallback,\n      tags: args.tags,\n      enabled: true,\n      createdAt: now(),\n      updatedAt: now(),\n      applyCount: 0,\n      sha256,\n    };\n\n    const all = await loadAllRecords();\n    if (record.persist) {\n      all[id] = record;\n      await saveAllRecords(all);\n    }\n\n    // Apply to current tab immediately if matches\n    let applied = false;\n    const fallbacks: string[] = [];\n    let cspBlocked = false;\n    const t0 = performance.now();\n    try {\n      if (mode === 'once') {\n        // Once: CDP evaluate in page\n        await cdpSessionManager.withSession(active.id!, 'userscript_once', async () => {\n          const expression = `(function(){try{return (function(){${record.script}\\n})()}catch(e){return {__error:String(e&&e.message||e)}}})()`;\n          const result: any = await cdpSessionManager.sendCommand(active.id!, 'Runtime.evaluate', {\n            expression,\n            returnByValue: true,\n            awaitPromise: true,\n          });\n          if (result?.result?.value?.__error) {\n            throw new Error(result.result.value.__error);\n          }\n        });\n        applied = true;\n      } else if (sourceType === 'CSS') {\n        await insertCssToTab(active.id!, record.script, record.allFrames);\n        setActiveInjection(active.id!, id, { kind: 'css' });\n        applied = true;\n      } else {\n        // Probe CSP preflight when target MAIN\n        if (record.world === 'MAIN') {\n          const ok = await probeUnsafeEvalInMain(active.id!);\n          if (!ok) {\n            cspBlocked = true;\n            fallbacks.push('MAIN->ISOLATED');\n            await injectJsPersistent(active.id!, record.script, 'ISOLATED', record.allFrames);\n            setActiveInjection(active.id!, id, { kind: 'js', world: 'ISOLATED' });\n            applied = true;\n          }\n        }\n        if (!applied) {\n          await injectJsPersistent(active.id!, record.script, record.world, record.allFrames);\n          setActiveInjection(active.id!, id, { kind: 'js', world: record.world });\n          applied = true;\n        }\n      }\n    } catch (e) {\n      if (record.persist) {\n        all[id].lastError = e instanceof Error ? e.message : String(e);\n        all[id].cspBlocked = cspBlocked;\n        await saveAllRecords(all);\n      }\n    }\n\n    const result = {\n      id,\n      status: record.persist && all[id]?.lastError ? 'queued' : applied ? 'applied' : 'queued',\n      strategy: {\n        kind:\n          mode === 'once'\n            ? 'once_cdp'\n            : sourceType === 'CSS'\n              ? 'insertCSS'\n              : `persistent_${(record.persist ? all[id]?.world || record.world : record.world).toLowerCase()}`,\n        runAt: record.persist ? all[id]?.runAt || record.runAt : record.runAt,\n        world: record.persist ? all[id]?.world || record.world : record.world,\n        allFrames: record.persist ? (all[id]?.allFrames ?? record.allFrames) : record.allFrames,\n        fallbacksTried: fallbacks,\n        cspBlocked,\n      },\n      warnings: emergency ? ['USERSCRIPTS_DISABLED is ON, injection skipped'] : [],\n      metrics: { injectMs: Math.round(performance.now() - t0) },\n    };\n\n    return {\n      content: [{ type: 'text', text: JSON.stringify(result) }],\n      isError: false,\n    };\n  }\n\n  private async list(args: any): Promise<ToolResult> {\n    const all = await loadAllRecords();\n    const q = (args && args.query ? String(args.query).toLowerCase() : '').trim();\n    const status = args && args.status ? String(args.status) : '';\n    const domain = args && args.domain ? String(args.domain) : '';\n    const items = Object.values(all)\n      .filter((r) => (status ? (status === 'enabled' ? r.enabled : !r.enabled) : true))\n      .filter((r) => (domain ? matchUrl(r.matches, `https://${domain}/`) : true))\n      .filter((r) =>\n        q\n          ? (r.name || '').toLowerCase().includes(q) ||\n            (r.description || '').toLowerCase().includes(q)\n          : true,\n      )\n      .map((r) => ({\n        id: r.id,\n        name: r.name,\n        status: r.enabled ? 'enabled' : 'disabled',\n        sourceType: r.sourceType,\n        matches: r.matches,\n        world: r.world,\n        runAt: r.runAt,\n        tags: r.tags || [],\n        lastError: r.lastError,\n        updatedAt: r.updatedAt,\n        applyCount: r.applyCount || 0,\n        lastAppliedAt: r.lastAppliedAt || null,\n      }));\n    return {\n      content: [{ type: 'text', text: JSON.stringify({ ok: true, items }) }],\n      isError: false,\n    };\n  }\n\n  private async get(args: any): Promise<ToolResult> {\n    const { id } = args || {};\n    if (!id) return createErrorResponse('id is required');\n    const all = await loadAllRecords();\n    const rec = all[id];\n    if (!rec) return createErrorResponse('userscript not found');\n    return {\n      content: [{ type: 'text', text: JSON.stringify({ ok: true, record: rec }) }],\n      isError: false,\n    };\n  }\n\n  private async enable(args: any, enabled: boolean): Promise<ToolResult> {\n    const { id } = args || {};\n    if (!id) return createErrorResponse('id is required');\n    const all = await loadAllRecords();\n    const rec = all[id];\n    if (!rec) return createErrorResponse('userscript not found');\n    rec.enabled = enabled;\n    rec.updatedAt = now();\n    await saveAllRecords(all);\n    return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };\n  }\n\n  private async update(args: UpdateArgs): Promise<ToolResult> {\n    const { id, ...rest } = args;\n    if (!id) return createErrorResponse('id is required');\n    const all = await loadAllRecords();\n    const rec = all[id];\n    if (!rec) return createErrorResponse('userscript not found');\n\n    if (rest.name !== undefined) rec.name = rest.name;\n    if (rest.description !== undefined) rec.description = rest.description;\n    if (rest.matches) rec.matches = rest.matches;\n    if (rest.excludes) rec.excludes = rest.excludes;\n    if (rest.runAt && rest.runAt !== 'auto') rec.runAt = rest.runAt;\n    if (rest.world && rest.world !== 'auto') rec.world = rest.world as any;\n    if (typeof rest.allFrames === 'boolean') rec.allFrames = rest.allFrames;\n    if (typeof rest.persist === 'boolean') rec.persist = rest.persist;\n    if (typeof rest.dnrFallback === 'boolean') rec.dnrFallback = rest.dnrFallback;\n    if (rest.tags) rec.tags = rest.tags;\n    if (typeof rest.script === 'string') rec.script = rest.script;\n    rec.updatedAt = now();\n    await saveAllRecords(all);\n    return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };\n  }\n\n  private async remove(args: any): Promise<ToolResult> {\n    const { id } = args || {};\n    if (!id) return createErrorResponse('id is required');\n    const all = await loadAllRecords();\n    const rec = all[id];\n    if (!rec) return createErrorResponse('userscript not found');\n    delete all[id];\n    await saveAllRecords(all);\n\n    // Attempt cleanup on active tab\n    const active = await getActiveTab();\n    if (active && active.id) {\n      try {\n        if (rec.sourceType === 'CSS') {\n          await removeCssFromTab(active.id, rec.script, rec.allFrames);\n        } else {\n          // Send cleanup signal via bridge (MAIN) or ignore if isolated\n          chrome.tabs.sendMessage(active.id, { type: 'chrome-mcp:cleanup' }).catch(() => {});\n        }\n        clearActiveInjection(active.id, rec.id);\n      } catch (err) {\n        console.warn('Userscript cleanup failed:', err);\n      }\n    }\n\n    return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }], isError: false };\n  }\n\n  private async sendCommand(args: any): Promise<ToolResult> {\n    const { id, payload, tabId } = args || {};\n    if (!id) return createErrorResponse('id is required');\n    const tab = tabId ? await chrome.tabs.get(tabId).catch(() => null) : await getActiveTab();\n    if (!tab || !tab.id) return createErrorResponse('No active tab found');\n\n    const all = await loadAllRecords();\n    const rec = all[id];\n    if (!rec) return createErrorResponse('userscript not found');\n\n    try {\n      if (rec.world === 'MAIN') {\n        // Use bridge\n        const result = await chrome.tabs.sendMessage(tab.id, {\n          action: 'userscript:command',\n          payload,\n          targetWorld: 'MAIN',\n        });\n        return {\n          content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],\n          isError: false,\n        };\n      } else {\n        // ISOLATED handler\n        const result = await chrome.tabs.sendMessage(tab.id, {\n          type: 'userscript:command',\n          action: 'userscript:command',\n          payload,\n          scriptId: id,\n        });\n        return {\n          content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }],\n          isError: false,\n        };\n      }\n    } catch (e) {\n      return createErrorResponse(\n        `send_command failed: ${e instanceof Error ? e.message : String(e)}`,\n      );\n    }\n  }\n\n  private async exportAll(): Promise<ToolResult> {\n    const all = await loadAllRecords();\n    return {\n      content: [{ type: 'text', text: JSON.stringify({ ok: true, data: all }) }],\n      isError: false,\n    };\n  }\n}\n\nexport const userscriptTool = new UserscriptTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/vector-search.ts",
    "content": "/**\n * Vectorized tab content search tool\n * Uses vector database for efficient semantic search\n */\n\nimport { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { ContentIndexer } from '@/utils/content-indexer';\nimport { LIMITS, ERROR_MESSAGES } from '@/common/constants';\nimport type { SearchResult } from '@/utils/vector-database';\n\ninterface VectorSearchResult {\n  tabId: number;\n  url: string;\n  title: string;\n  semanticScore: number;\n  matchedSnippet: string;\n  chunkSource: string;\n  timestamp: number;\n}\n\n/**\n * Tool for vectorized search of tab content using semantic similarity\n */\nclass VectorSearchTabsContentTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT;\n  private contentIndexer: ContentIndexer;\n  private isInitialized = false;\n\n  constructor() {\n    super();\n    this.contentIndexer = new ContentIndexer({\n      autoIndex: true,\n      maxChunksPerPage: LIMITS.MAX_SEARCH_RESULTS,\n      skipDuplicates: true,\n    });\n  }\n\n  private async initializeIndexer(): Promise<void> {\n    try {\n      await this.contentIndexer.initialize();\n      this.isInitialized = true;\n      console.log('VectorSearchTabsContentTool: Content indexer initialized successfully');\n    } catch (error) {\n      console.error('VectorSearchTabsContentTool: Failed to initialize content indexer:', error);\n      this.isInitialized = false;\n    }\n  }\n\n  async execute(args: { query: string }): Promise<ToolResult> {\n    try {\n      const { query } = args;\n\n      if (!query || query.trim().length === 0) {\n        return createErrorResponse(\n          ERROR_MESSAGES.INVALID_PARAMETERS + ': Query parameter is required and cannot be empty',\n        );\n      }\n\n      console.log(`VectorSearchTabsContentTool: Starting vector search with query: \"${query}\"`);\n\n      // Check semantic engine status\n      if (!this.contentIndexer.isSemanticEngineReady()) {\n        if (this.contentIndexer.isSemanticEngineInitializing()) {\n          return createErrorResponse(\n            'Vector search engine is still initializing (model downloading). Please wait a moment and try again.',\n          );\n        } else {\n          // Try to initialize\n          console.log('VectorSearchTabsContentTool: Initializing content indexer...');\n          await this.initializeIndexer();\n\n          // Check semantic engine status again\n          if (!this.contentIndexer.isSemanticEngineReady()) {\n            return createErrorResponse('Failed to initialize vector search engine');\n          }\n        }\n      }\n\n      // Execute vector search, get more results for deduplication\n      const searchResults = await this.contentIndexer.searchContent(query, 50);\n\n      // Convert search results format\n      const vectorSearchResults = this.convertSearchResults(searchResults);\n\n      // Deduplicate by tab, keep only the highest similarity fragment per tab\n      const deduplicatedResults = this.deduplicateByTab(vectorSearchResults);\n\n      // Sort by similarity and get top 10 results\n      const topResults = deduplicatedResults\n        .sort((a, b) => b.semanticScore - a.semanticScore)\n        .slice(0, 10);\n\n      // Get index statistics\n      const stats = this.contentIndexer.getStats();\n\n      const result = {\n        success: true,\n        totalTabsSearched: stats.totalTabs,\n        matchedTabsCount: topResults.length,\n        vectorSearchEnabled: true,\n        indexStats: {\n          totalDocuments: stats.totalDocuments,\n          totalTabs: stats.totalTabs,\n          indexedPages: stats.indexedPages,\n          semanticEngineReady: stats.semanticEngineReady,\n          semanticEngineInitializing: stats.semanticEngineInitializing,\n        },\n        matchedTabs: topResults.map((result) => ({\n          tabId: result.tabId,\n          url: result.url,\n          title: result.title,\n          semanticScore: result.semanticScore,\n          matchedSnippets: [result.matchedSnippet],\n          chunkSource: result.chunkSource,\n          timestamp: result.timestamp,\n        })),\n      };\n\n      console.log(\n        `VectorSearchTabsContentTool: Found ${topResults.length} results with vector search`,\n      );\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result, null, 2),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('VectorSearchTabsContentTool: Search failed:', error);\n      return createErrorResponse(\n        `Vector search failed: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n\n  /**\n   * Ensure all tabs are indexed\n   */\n  private async ensureTabsIndexed(tabs: chrome.tabs.Tab[]): Promise<void> {\n    const indexPromises = tabs\n      .filter((tab) => tab.id)\n      .map(async (tab) => {\n        try {\n          await this.contentIndexer.indexTabContent(tab.id!);\n        } catch (error) {\n          console.warn(`VectorSearchTabsContentTool: Failed to index tab ${tab.id}:`, error);\n        }\n      });\n\n    await Promise.allSettled(indexPromises);\n  }\n\n  /**\n   * Convert search results format\n   */\n  private convertSearchResults(searchResults: SearchResult[]): VectorSearchResult[] {\n    return searchResults.map((result) => ({\n      tabId: result.document.tabId,\n      url: result.document.url,\n      title: result.document.title,\n      semanticScore: result.similarity,\n      matchedSnippet: this.extractSnippet(result.document.chunk.text),\n      chunkSource: result.document.chunk.source,\n      timestamp: result.document.timestamp,\n    }));\n  }\n\n  /**\n   * Deduplicate by tab, keep only the highest similarity fragment per tab\n   */\n  private deduplicateByTab(results: VectorSearchResult[]): VectorSearchResult[] {\n    const tabMap = new Map<number, VectorSearchResult>();\n\n    for (const result of results) {\n      const existingResult = tabMap.get(result.tabId);\n\n      // If this tab has no result yet, or current result has higher similarity, update it\n      if (!existingResult || result.semanticScore > existingResult.semanticScore) {\n        tabMap.set(result.tabId, result);\n      }\n    }\n\n    return Array.from(tabMap.values());\n  }\n\n  /**\n   * Extract text snippet for display\n   */\n  private extractSnippet(text: string, maxLength: number = 200): string {\n    if (text.length <= maxLength) {\n      return text;\n    }\n\n    // Try to truncate at sentence boundary\n    const truncated = text.substring(0, maxLength);\n    const lastSentenceEnd = Math.max(\n      truncated.lastIndexOf('.'),\n      truncated.lastIndexOf('!'),\n      truncated.lastIndexOf('?'),\n      truncated.lastIndexOf('。'),\n      truncated.lastIndexOf('！'),\n      truncated.lastIndexOf('？'),\n    );\n\n    if (lastSentenceEnd > maxLength * 0.7) {\n      return truncated.substring(0, lastSentenceEnd + 1);\n    }\n\n    // If no suitable sentence boundary found, truncate at word boundary\n    const lastSpaceIndex = truncated.lastIndexOf(' ');\n    if (lastSpaceIndex > maxLength * 0.8) {\n      return truncated.substring(0, lastSpaceIndex) + '...';\n    }\n\n    return truncated + '...';\n  }\n\n  /**\n   * Get index statistics\n   */\n  public async getIndexStats() {\n    if (!this.isInitialized) {\n      // Don't automatically initialize - just return basic stats\n      return {\n        totalDocuments: 0,\n        totalTabs: 0,\n        indexSize: 0,\n        indexedPages: 0,\n        isInitialized: false,\n        semanticEngineReady: false,\n        semanticEngineInitializing: false,\n      };\n    }\n    return this.contentIndexer.getStats();\n  }\n\n  /**\n   * Manually rebuild index\n   */\n  public async rebuildIndex(): Promise<void> {\n    if (!this.isInitialized) {\n      await this.initializeIndexer();\n    }\n\n    try {\n      // Clear existing indexes\n      await this.contentIndexer.clearAllIndexes();\n\n      // Get all tabs and reindex\n      const windows = await chrome.windows.getAll({ populate: true });\n      const allTabs: chrome.tabs.Tab[] = [];\n\n      for (const window of windows) {\n        if (window.tabs) {\n          allTabs.push(...window.tabs);\n        }\n      }\n\n      const validTabs = allTabs.filter(\n        (tab) =>\n          tab.id &&\n          tab.url &&\n          !tab.url.startsWith('chrome://') &&\n          !tab.url.startsWith('chrome-extension://') &&\n          !tab.url.startsWith('edge://') &&\n          !tab.url.startsWith('about:'),\n      );\n\n      await this.ensureTabsIndexed(validTabs);\n\n      console.log(`VectorSearchTabsContentTool: Rebuilt index for ${validTabs.length} tabs`);\n    } catch (error) {\n      console.error('VectorSearchTabsContentTool: Failed to rebuild index:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Manually index specified tab\n   */\n  public async indexTab(tabId: number): Promise<void> {\n    if (!this.isInitialized) {\n      await this.initializeIndexer();\n    }\n\n    await this.contentIndexer.indexTabContent(tabId);\n  }\n\n  /**\n   * Remove index for specified tab\n   */\n  public async removeTabIndex(tabId: number): Promise<void> {\n    if (!this.isInitialized) {\n      return;\n    }\n\n    await this.contentIndexer.removeTabIndex(tabId);\n  }\n}\n\n// Export tool instance\nexport const vectorSearchTabsContentTool = new VectorSearchTabsContentTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/web-fetcher.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\ninterface WebFetcherToolParams {\n  htmlContent?: boolean; // get the visible HTML content of the current page. default: false\n  textContent?: boolean; // get the visible text content of the current page. default: true\n  url?: string; // optional URL to fetch content from (if not provided, uses active tab)\n  selector?: string; // optional CSS selector to get content from a specific element\n  tabId?: number; // target existing tab id\n  background?: boolean; // do not activate/focus\n  windowId?: number; // target window id to pick active tab or create tab\n}\n\nclass WebFetcherTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.WEB_FETCHER;\n\n  /**\n   * Execute web fetcher operation\n   */\n  async execute(args: WebFetcherToolParams): Promise<ToolResult> {\n    // Handle mutually exclusive parameters: if htmlContent is true, textContent is forced to false\n    const htmlContent = args.htmlContent === true;\n    const textContent = htmlContent ? false : args.textContent !== false; // Default is true, unless htmlContent is true or textContent is explicitly set to false\n    const url = args.url;\n    const selector = args.selector;\n    const explicitTabId = args.tabId;\n    const background = args.background === true;\n    const windowId = args.windowId;\n\n    console.log(`Starting web fetcher with options:`, {\n      htmlContent,\n      textContent,\n      url,\n      selector,\n    });\n\n    try {\n      // Get tab to fetch content from\n      let tab;\n\n      if (typeof explicitTabId === 'number') {\n        tab = await chrome.tabs.get(explicitTabId);\n      } else if (url) {\n        // If URL is provided, check if it's already open\n        console.log(`Checking if URL is already open: ${url}`);\n        const allTabs = await chrome.tabs.query({});\n\n        // Find tab with matching URL\n        const matchingTabs = allTabs.filter((t) => {\n          // Normalize URLs for comparison (remove trailing slashes)\n          const tabUrl = t.url?.endsWith('/') ? t.url.slice(0, -1) : t.url;\n          const targetUrl = url.endsWith('/') ? url.slice(0, -1) : url;\n          return tabUrl === targetUrl;\n        });\n\n        if (matchingTabs.length > 0) {\n          // Use existing tab\n          tab = matchingTabs[0];\n          console.log(`Found existing tab with URL: ${url}, tab ID: ${tab.id}`);\n        } else {\n          // Create new tab with the URL\n          console.log(`No existing tab found with URL: ${url}, creating new tab`);\n          tab = await chrome.tabs.create({ url, active: background ? false : true });\n\n          // Wait for page to load\n          console.log('Waiting for page to load...');\n          await new Promise((resolve) => setTimeout(resolve, 3000));\n        }\n      } else {\n        // Use active tab (prefer specified window)\n        const tabs =\n          typeof windowId === 'number'\n            ? await chrome.tabs.query({ active: true, windowId })\n            : await chrome.tabs.query({ active: true, currentWindow: true });\n        if (!tabs[0]) {\n          return createErrorResponse('No active tab found');\n        }\n        tab = tabs[0];\n      }\n\n      if (!tab.id) {\n        return createErrorResponse('Tab has no ID');\n      }\n\n      // Optionally bring tab/window to foreground\n      if (!background) {\n        await chrome.tabs.update(tab.id, { active: true });\n        await chrome.windows.update(tab.windowId, { focused: true });\n      }\n\n      // Prepare result object\n      const result: any = {\n        success: true,\n        url: tab.url,\n        title: tab.title,\n      };\n\n      await this.injectContentScript(tab.id, ['inject-scripts/web-fetcher-helper.js']);\n\n      // Get HTML content if requested\n      if (htmlContent) {\n        const htmlResponse = await this.sendMessageToTab(tab.id, {\n          action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_HTML_CONTENT,\n          selector: selector,\n        });\n\n        if (htmlResponse.success) {\n          result.htmlContent = htmlResponse.htmlContent;\n        } else {\n          console.error('Failed to get HTML content:', htmlResponse.error);\n          result.htmlContentError = htmlResponse.error;\n        }\n      }\n\n      // Get text content if requested (and htmlContent is not true)\n      if (textContent) {\n        const textResponse = await this.sendMessageToTab(tab.id, {\n          action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,\n          selector: selector,\n        });\n\n        if (textResponse.success) {\n          result.textContent = textResponse.textContent;\n\n          // Include article metadata if available\n          if (textResponse.article) {\n            result.article = {\n              title: textResponse.article.title,\n              byline: textResponse.article.byline,\n              siteName: textResponse.article.siteName,\n              excerpt: textResponse.article.excerpt,\n              lang: textResponse.article.lang,\n            };\n          }\n\n          // Include page metadata if available\n          if (textResponse.metadata) {\n            result.metadata = textResponse.metadata;\n          }\n        } else {\n          console.error('Failed to get text content:', textResponse.error);\n          result.textContentError = textResponse.error;\n        }\n      }\n\n      // Interactive elements feature has been removed\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in web fetcher:', error);\n      return createErrorResponse(\n        `Error fetching web content: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const webFetcherTool = new WebFetcherTool();\n\ninterface GetInteractiveElementsToolParams {\n  textQuery?: string; // Text to search for within interactive elements (fuzzy search)\n  selector?: string; // CSS selector to filter interactive elements\n  includeCoordinates?: boolean; // Include element coordinates in the response (default: true)\n  types?: string[]; // Types of interactive elements to include (default: all types)\n}\n\nclass GetInteractiveElementsTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS;\n\n  /**\n   * Execute get interactive elements operation\n   */\n  async execute(args: GetInteractiveElementsToolParams): Promise<ToolResult> {\n    const { textQuery, selector, includeCoordinates = true, types } = args;\n\n    console.log(`Starting get interactive elements with options:`, args);\n\n    try {\n      // Get current tab\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      if (!tabs[0]) {\n        return createErrorResponse('No active tab found');\n      }\n\n      const tab = tabs[0];\n      if (!tab.id) {\n        return createErrorResponse('Active tab has no ID');\n      }\n\n      // Ensure content script is injected\n      await this.injectContentScript(tab.id, ['inject-scripts/interactive-elements-helper.js']);\n\n      // Send message to content script\n      const result = await this.sendMessageToTab(tab.id, {\n        action: TOOL_MESSAGE_TYPES.GET_INTERACTIVE_ELEMENTS,\n        textQuery,\n        selector,\n        includeCoordinates,\n        types,\n      });\n\n      if (!result.success) {\n        return createErrorResponse(result.error || 'Failed to get interactive elements');\n      }\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify({\n              success: true,\n              elements: result.elements,\n              count: result.elements.length,\n              query: {\n                textQuery,\n                selector,\n                types: types || 'all',\n              },\n            }),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in get interactive elements operation:', error);\n      return createErrorResponse(\n        `Error getting interactive elements: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const getInteractiveElementsTool = new GetInteractiveElementsTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/browser/window.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { BaseBrowserToolExecutor } from '../base-browser';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\n\nclass WindowTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS;\n  async execute(): Promise<ToolResult> {\n    try {\n      const windows = await chrome.windows.getAll({ populate: true });\n      let tabCount = 0;\n\n      const structuredWindows = windows.map((window) => {\n        const tabs =\n          window.tabs?.map((tab) => {\n            tabCount++;\n            return {\n              tabId: tab.id || 0,\n              url: tab.url || '',\n              title: tab.title || '',\n              active: tab.active || false,\n            };\n          }) || [];\n\n        return {\n          windowId: window.id || 0,\n          tabs: tabs,\n        };\n      });\n\n      const result = {\n        windowCount: windows.length,\n        tabCount: tabCount,\n        windows: structuredWindows,\n      };\n\n      return {\n        content: [\n          {\n            type: 'text',\n            text: JSON.stringify(result),\n          },\n        ],\n        isError: false,\n      };\n    } catch (error) {\n      console.error('Error in WindowTool.execute:', error);\n      return createErrorResponse(\n        `Error getting windows and tabs information: ${error instanceof Error ? error.message : String(error)}`,\n      );\n    }\n  }\n}\n\nexport const windowTool = new WindowTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/index.ts",
    "content": "import { createErrorResponse } from '@/common/tool-handler';\nimport { ERROR_MESSAGES } from '@/common/constants';\nimport * as browserTools from './browser';\nimport { flowRunTool, listPublishedFlowsTool } from './record-replay';\n\nconst tools = { ...browserTools, flowRunTool, listPublishedFlowsTool } as any;\nconst toolsMap = new Map(Object.values(tools).map((tool: any) => [tool.name, tool]));\n\n/**\n * Tool call parameter interface\n */\nexport interface ToolCallParam {\n  name: string;\n  args: any;\n}\n\n/**\n * Handle tool execution\n */\nexport const handleCallTool = async (param: ToolCallParam) => {\n  const tool = toolsMap.get(param.name);\n  if (!tool) {\n    return createErrorResponse(`Tool ${param.name} not found`);\n  }\n\n  try {\n    return await tool.execute(param.args);\n  } catch (error) {\n    console.error(`Tool execution failed for ${param.name}:`, error);\n    return createErrorResponse(\n      error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED,\n    );\n  }\n};\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/tools/record-replay.ts",
    "content": "import { createErrorResponse, ToolResult } from '@/common/tool-handler';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { listPublished } from '../record-replay/flow-store';\nimport { getFlow } from '../record-replay/flow-store';\nimport { runFlow } from '../record-replay/flow-runner';\n\nclass FlowRunTool {\n  name = TOOL_NAMES.RECORD_REPLAY.FLOW_RUN;\n  async execute(args: any): Promise<ToolResult> {\n    const {\n      flowId,\n      args: vars,\n      tabTarget,\n      refresh,\n      captureNetwork,\n      returnLogs,\n      timeoutMs,\n      startUrl,\n    } = args || {};\n    if (!flowId) return createErrorResponse('flowId is required');\n    const flow = await getFlow(flowId);\n    if (!flow) return createErrorResponse(`Flow not found: ${flowId}`);\n    const result = await runFlow(flow, {\n      tabTarget,\n      refresh,\n      captureNetwork,\n      returnLogs,\n      timeoutMs,\n      startUrl,\n      args: vars,\n    });\n    return {\n      content: [\n        {\n          type: 'text',\n          text: JSON.stringify(result),\n        },\n      ],\n      isError: false,\n    };\n  }\n}\n\nclass ListPublishedTool {\n  name = TOOL_NAMES.RECORD_REPLAY.LIST_PUBLISHED;\n  async execute(): Promise<ToolResult> {\n    const list = await listPublished();\n    return {\n      content: [\n        {\n          type: 'text',\n          text: JSON.stringify({ success: true, published: list }),\n        },\n      ],\n      isError: false,\n    };\n  }\n}\n\nexport const flowRunTool = new FlowRunTool();\nexport const listPublishedFlowsTool = new ListPublishedTool();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/utils/sidepanel.ts",
    "content": "/**\n * Sidepanel Utilities\n *\n * Shared helpers for opening and managing the Chrome sidepanel from background modules.\n * Used by web-editor, quick-panel, and other modules that need to trigger sidepanel navigation.\n */\n\n/**\n * Best-effort open the sidepanel with AgentChat tab selected.\n *\n * @param tabId - Tab ID to associate with sidepanel\n * @param windowId - Optional window ID for fallback when tab-level open fails\n * @param sessionId - Optional session ID to navigate directly to chat view (deep-link)\n *\n * @remarks\n * This function is intentionally resilient - it will not throw on failures.\n * Sidepanel availability varies across Chrome versions and contexts.\n */\nexport async function openAgentChatSidepanel(\n  tabId: number,\n  windowId?: number,\n  sessionId?: string,\n): Promise<void> {\n  try {\n    // Build deep-link path with optional session navigation\n    let path = 'sidepanel.html?tab=agent-chat';\n    if (sessionId) {\n      path += `&view=chat&sessionId=${encodeURIComponent(sessionId)}`;\n    }\n\n    // Configure sidepanel options for this tab\n\n    const sidePanel = chrome.sidePanel as any;\n\n    if (sidePanel?.setOptions) {\n      await sidePanel.setOptions({\n        tabId,\n        path,\n        enabled: true,\n      });\n    }\n\n    // Attempt to open the sidepanel\n    if (sidePanel?.open) {\n      try {\n        await sidePanel.open({ tabId });\n      } catch {\n        // Fallback to window-level open if tab-level fails\n        // This handles cases where the tab is in a special state\n        if (typeof windowId === 'number') {\n          await sidePanel.open({ windowId });\n        }\n      }\n    }\n  } catch {\n    // Best-effort: side panel may be unavailable in some Chrome versions/environments\n    // Intentionally suppress errors to avoid breaking calling code\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/background/web-editor/index.ts",
    "content": "import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport {\n  WEB_EDITOR_V2_ACTIONS,\n  WEB_EDITOR_V1_ACTIONS,\n  type ElementChangeSummary,\n  type WebEditorApplyBatchPayload,\n  type WebEditorTxChangedPayload,\n  type WebEditorHighlightElementPayload,\n  type WebEditorRevertElementPayload,\n  type WebEditorCancelExecutionPayload,\n  type WebEditorCancelExecutionResponse,\n} from '@/common/web-editor-types';\nimport { openAgentChatSidepanel } from '../utils/sidepanel';\n\nconst CONTEXT_MENU_ID = 'web_editor_toggle';\nconst COMMAND_KEY = 'toggle_web_editor';\nconst DEFAULT_NATIVE_SERVER_PORT = 12306;\n\n/** Storage key prefix for TX change session data (per-tab isolation) */\nconst WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-';\nconst WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-';\n\n/** Storage key prefix for excluded element keys (per-tab isolation, managed by sidepanel) */\nconst WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-';\n\n/** Storage key for AgentChat selected session ID */\nconst STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';\n\n// In-memory execution status cache (per requestId)\ninterface ExecutionStatusEntry {\n  status: string;\n  message?: string;\n  updatedAt: number;\n  result?: { success: boolean; summary?: string; error?: string };\n}\nconst executionStatusCache = new Map<string, ExecutionStatusEntry>();\nconst STATUS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n\nfunction cleanupExpiredStatuses(): void {\n  const now = Date.now();\n  for (const [key, entry] of executionStatusCache) {\n    if (now - entry.updatedAt > STATUS_CACHE_TTL) {\n      executionStatusCache.delete(key);\n    }\n  }\n}\n\nfunction setExecutionStatus(\n  requestId: string,\n  status: string,\n  message?: string,\n  result?: ExecutionStatusEntry['result'],\n): void {\n  executionStatusCache.set(requestId, {\n    status,\n    message,\n    updatedAt: Date.now(),\n    result,\n  });\n  // Periodic cleanup\n  if (executionStatusCache.size > 100) {\n    cleanupExpiredStatuses();\n  }\n}\n\nfunction getExecutionStatus(requestId: string): ExecutionStatusEntry | undefined {\n  return executionStatusCache.get(requestId);\n}\n\n// SSE connections for status updates (per sessionId)\nconst sseConnections = new Map<string, { abort: AbortController; lastRequestId: string }>();\n\n/**\n * Start SSE subscription for a session to receive status updates\n */\nasync function subscribeToSessionStatus(\n  sessionId: string,\n  requestId: string,\n  port: number,\n): Promise<void> {\n  // Close existing connection for this session if any\n  const existing = sseConnections.get(sessionId);\n  if (existing) {\n    existing.abort.abort();\n    sseConnections.delete(sessionId);\n  }\n\n  const abortController = new AbortController();\n  sseConnections.set(sessionId, { abort: abortController, lastRequestId: requestId });\n\n  // Set initial status\n  setExecutionStatus(requestId, 'starting', 'Connecting to Agent...');\n\n  const sseUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/stream`;\n\n  try {\n    const response = await fetch(sseUrl, {\n      method: 'GET',\n      headers: { Accept: 'text/event-stream' },\n      signal: abortController.signal,\n    });\n\n    if (!response.ok || !response.body) {\n      setExecutionStatus(requestId, 'running', 'Agent processing...');\n      return;\n    }\n\n    const reader = response.body.getReader();\n    const decoder = new TextDecoder();\n    let buffer = '';\n\n    setExecutionStatus(requestId, 'running', 'Agent processing...');\n\n    // Read SSE stream\n\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n\n      buffer += decoder.decode(value, { stream: true });\n      const lines = buffer.split('\\n');\n      buffer = lines.pop() ?? '';\n\n      for (const line of lines) {\n        if (line.startsWith('data:')) {\n          try {\n            const data = JSON.parse(line.slice(5).trim());\n            handleSseEvent(requestId, data);\n          } catch {\n            // Ignore parse errors\n          }\n        }\n      }\n    }\n  } catch (err) {\n    if (err instanceof Error && err.name === 'AbortError') {\n      // Intentionally aborted, not an error\n      return;\n    }\n    // Connection error - mark as unknown but not failed (Agent may still be running)\n    const cached = getExecutionStatus(requestId);\n    if (cached && !['completed', 'failed', 'cancelled'].includes(cached.status)) {\n      setExecutionStatus(requestId, 'running', 'Agent processing (connection lost)...');\n    }\n  } finally {\n    sseConnections.delete(sessionId);\n  }\n}\n\n/**\n * Handle SSE event from Agent stream\n */\nfunction handleSseEvent(requestId: string, event: unknown): void {\n  if (!event || typeof event !== 'object') return;\n  const e = event as Record<string, unknown>;\n  const type = e.type;\n  const data = e.data as Record<string, unknown> | undefined;\n\n  // Check if this event is for our request\n  const eventRequestId = data?.requestId as string | undefined;\n  if (eventRequestId && eventRequestId !== requestId) return;\n\n  if (type === 'status' && data) {\n    const status = data.status as string;\n    const message = data.message as string | undefined;\n\n    // Map Agent status to our status\n    // - 'ready' -> 'running' (ready is a running sub-state)\n    // - 'error' -> 'failed' (normalize server 'error' to UI 'failed')\n    let mappedStatus = status;\n    if (status === 'ready') mappedStatus = 'running';\n    if (status === 'error') mappedStatus = 'failed';\n\n    setExecutionStatus(requestId, mappedStatus, message);\n  } else if (type === 'message' && data) {\n    // Update status to show we're receiving messages\n    const cached = getExecutionStatus(requestId);\n    if (cached && cached.status === 'starting') {\n      setExecutionStatus(requestId, 'running', 'Agent is working...');\n    }\n\n    // Check for completion indicators in message content\n    const role = data.role as string | undefined;\n    const isFinal = data.isFinal as boolean | undefined;\n    if (role === 'assistant' && isFinal) {\n      const content = data.content as string | undefined;\n      setExecutionStatus(requestId, 'completed', 'Completed', {\n        success: true,\n        summary: content?.slice(0, 200),\n      });\n    }\n  } else if (type === 'error') {\n    const errorMsg = (e.error as string) || 'Unknown error';\n    setExecutionStatus(requestId, 'failed', errorMsg, {\n      success: false,\n      error: errorMsg,\n    });\n  }\n}\n\n/**\n * Web Editor version configuration\n * - v1: Legacy inject-scripts/web-editor.js (IIFE, ~850 lines)\n * - v2: New TypeScript-based web-editor-v2.js (WXT unlisted script)\n *\n * Set USE_WEB_EDITOR_V2 to true to enable v2.\n * This flag allows gradual rollout and easy rollback.\n */\nconst USE_WEB_EDITOR_V2 = true;\n\n/** Script path for v1 (legacy) */\nconst V1_SCRIPT_PATH = 'inject-scripts/web-editor.js';\n\n/** Script path for v2 (WXT unlisted script output) */\nconst V2_SCRIPT_PATH = 'web-editor-v2.js';\n\n/** Script path for Phase 7 props agent (MAIN world) */\nconst PROPS_AGENT_SCRIPT_PATH = 'inject-scripts/props-agent.js';\n\ntype WebEditorInstructionType = 'update_text' | 'update_style';\n\ninterface WebEditorFingerprint {\n  tag: string;\n  id?: string;\n  classes: string[];\n  text?: string;\n}\n\n/** Debug source from React/Vue fiber (file, line, component name) */\ninterface DebugSource {\n  file: string;\n  line?: number;\n  column?: number;\n  componentName?: string;\n}\n\n/** Style operation details (before/after diff) */\ninterface StyleOperation {\n  type: 'update_style';\n  before: Record<string, string>;\n  after: Record<string, string>;\n  removed: string[];\n}\n\ninterface WebEditorApplyPayload {\n  pageUrl: string;\n  targetFile?: string;\n  fingerprint: WebEditorFingerprint;\n  techStackHint?: string[];\n  instruction: {\n    type: WebEditorInstructionType;\n    description: string;\n    text?: string;\n    style?: Record<string, string>;\n  };\n\n  // V2 extended fields (best-effort, optional)\n  selectorCandidates?: string[];\n  debugSource?: DebugSource;\n  operation?: StyleOperation;\n}\n\nfunction normalizeString(value: unknown): string {\n  return typeof value === 'string' ? value : '';\n}\n\nfunction normalizeStringArray(value: unknown): string[] {\n  if (!Array.isArray(value)) return [];\n  return value.map((item) => normalizeString(item)).filter(Boolean);\n}\n\nfunction normalizeStyleMap(value: unknown): Record<string, string> | undefined {\n  if (!value || typeof value !== 'object') return undefined;\n  const out: Record<string, string> = {};\n  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n    const key = normalizeString(k).trim();\n    const val = normalizeString(v).trim();\n    if (!key || !val) continue;\n    out[key] = val;\n  }\n  return Object.keys(out).length ? out : undefined;\n}\n\nfunction normalizeStyleMapAllowEmpty(value: unknown): Record<string, string> | undefined {\n  if (!value || typeof value !== 'object') return undefined;\n  const out: Record<string, string> = {};\n  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n    const key = normalizeString(k).trim();\n    if (!key) continue;\n    // Allow empty values (represents removed styles)\n    out[key] = normalizeString(v).trim();\n  }\n  return Object.keys(out).length ? out : undefined;\n}\n\nfunction normalizeDebugSource(value: unknown): DebugSource | undefined {\n  if (!value || typeof value !== 'object') return undefined;\n  const obj = value as Record<string, unknown>;\n  const file = normalizeString(obj.file).trim();\n  if (!file) return undefined;\n\n  const source: DebugSource = { file };\n  const line = Number(obj.line);\n  if (Number.isFinite(line) && line > 0) source.line = line;\n  const column = Number(obj.column);\n  if (Number.isFinite(column) && column >= 0) source.column = column;\n  const componentName = normalizeString(obj.componentName).trim();\n  if (componentName) source.componentName = componentName;\n\n  return source;\n}\n\nfunction normalizeOperation(value: unknown): StyleOperation | undefined {\n  if (!value || typeof value !== 'object') return undefined;\n  const obj = value as Record<string, unknown>;\n  if (obj.type !== 'update_style') return undefined;\n\n  const before = normalizeStyleMapAllowEmpty(obj.before);\n  const after = normalizeStyleMapAllowEmpty(obj.after);\n  const removed = normalizeStringArray(obj.removed);\n\n  if (!before && !after && removed.length === 0) return undefined;\n\n  return {\n    type: 'update_style',\n    before: before ?? {},\n    after: after ?? {},\n    removed,\n  };\n}\n\nfunction normalizeApplyPayload(raw: unknown): WebEditorApplyPayload {\n  const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n  const pageUrl = normalizeString(obj.pageUrl).trim();\n  const targetFile = normalizeString(obj.targetFile).trim() || undefined;\n  const techStackHint = normalizeStringArray(obj.techStackHint);\n\n  const fingerprintRaw = (\n    obj.fingerprint && typeof obj.fingerprint === 'object' ? obj.fingerprint : {}\n  ) as Record<string, unknown>;\n  const fingerprint: WebEditorFingerprint = {\n    tag: normalizeString(fingerprintRaw.tag).trim() || 'unknown',\n    id: normalizeString(fingerprintRaw.id).trim() || undefined,\n    classes: normalizeStringArray(fingerprintRaw.classes),\n    text: normalizeString(fingerprintRaw.text).trim() || undefined,\n  };\n\n  const instructionRaw = (\n    obj.instruction && typeof obj.instruction === 'object' ? obj.instruction : {}\n  ) as Record<string, unknown>;\n  const type = normalizeString(instructionRaw.type).trim() as WebEditorInstructionType;\n  if (type !== 'update_text' && type !== 'update_style') {\n    throw new Error('Invalid instruction.type');\n  }\n\n  const instruction = {\n    type,\n    description: normalizeString(instructionRaw.description).trim() || '',\n    text: normalizeString(instructionRaw.text).trim() || undefined,\n    style: normalizeStyleMap(instructionRaw.style),\n  };\n\n  if (!pageUrl) {\n    throw new Error('pageUrl is required');\n  }\n  if (!instruction.description) {\n    throw new Error('instruction.description is required');\n  }\n\n  // V2 extended fields (optional)\n  const selectorCandidates = normalizeStringArray(obj.selectorCandidates);\n  const debugSource = normalizeDebugSource(obj.debugSource);\n  const operation = normalizeOperation(obj.operation);\n\n  return {\n    pageUrl,\n    targetFile,\n    fingerprint,\n    techStackHint: techStackHint.length ? techStackHint : undefined,\n    instruction,\n    selectorCandidates: selectorCandidates.length ? selectorCandidates : undefined,\n    debugSource,\n    operation,\n  };\n}\n\n/**\n * Normalize and validate batch apply payload.\n * Runtime validation for WebEditorApplyBatchPayload.\n */\nfunction normalizeApplyBatchPayload(raw: unknown): WebEditorApplyBatchPayload {\n  const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n\n  const tabIdRaw = Number(obj.tabId);\n  const tabId = Number.isFinite(tabIdRaw) && tabIdRaw > 0 ? tabIdRaw : 0;\n\n  const elements = Array.isArray(obj.elements) ? (obj.elements as ElementChangeSummary[]) : [];\n\n  const excludedKeys = Array.isArray(obj.excludedKeys)\n    ? obj.excludedKeys.map((k) => normalizeString(k).trim()).filter((k): k is string => Boolean(k))\n    : [];\n\n  const pageUrl = normalizeString(obj.pageUrl).trim() || undefined;\n\n  return { tabId, elements, excludedKeys, pageUrl };\n}\n\n/**\n * Build a batch prompt for multiple element changes.\n * Designed for AgentChat integration to apply multiple visual edits at once.\n */\nfunction buildAgentPromptBatch(elements: readonly ElementChangeSummary[], pageUrl: string): string {\n  const lines: string[] = [];\n\n  // Header\n  lines.push('You are a senior frontend engineer working in a local codebase.');\n  lines.push(\n    'Goal: persist a batch of visual edits from the browser into the source code with minimal changes.',\n  );\n  lines.push('');\n\n  // Page context\n  lines.push(`Page URL: ${pageUrl}`);\n  lines.push('');\n\n  lines.push('## Batch Changes');\n  lines.push(`Total elements: ${elements.length}`);\n  lines.push('');\n  lines.push(\n    'For each element, prefer \"source\" (file/line/component) when available; otherwise use selectors/fingerprint to locate it.',\n  );\n  lines.push('');\n\n  // Element details\n  elements.forEach((element, index) => {\n    const title = element.fullLabel || element.label || element.elementKey;\n    lines.push(`### ${index + 1}. ${title}`);\n    lines.push(`- elementKey: ${element.elementKey}`);\n    lines.push(`- change type: ${element.type}`);\n\n    // Debug source (high-confidence location)\n    const ds = element.debugSource ?? element.locator?.debugSource;\n    if (ds?.file) {\n      const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file;\n      lines.push(`- source: ${loc}${ds.componentName ? ` (${ds.componentName})` : ''}`);\n    }\n\n    // Locator hints for fallback\n    if (element.locator?.selectors?.length) {\n      lines.push('- selectors:');\n      for (const sel of element.locator.selectors.slice(0, 5)) {\n        lines.push(`  - ${sel}`);\n      }\n    }\n    if (element.locator?.fingerprint) {\n      lines.push(`- fingerprint: ${element.locator.fingerprint}`);\n    }\n    if (Array.isArray(element.locator?.path) && element.locator.path.length > 0) {\n      lines.push(`- path: ${JSON.stringify(element.locator.path)}`);\n    }\n    if (element.locator?.shadowHostChain?.length) {\n      lines.push(`- shadowHostChain: ${JSON.stringify(element.locator.shadowHostChain)}`);\n    }\n    lines.push('');\n\n    // Net effect details\n    const net = element.netEffect;\n    lines.push('#### Net Effect (apply these final values)');\n\n    if (net.textChange) {\n      lines.push('##### Text');\n      lines.push(`- before: ${JSON.stringify(net.textChange.before)}`);\n      lines.push(`- after: ${JSON.stringify(net.textChange.after)}`);\n      lines.push('');\n    }\n\n    if (net.classChanges) {\n      lines.push('##### Classes');\n      lines.push(`- before: ${net.classChanges.before.join(' ')}`);\n      lines.push(`- after: ${net.classChanges.after.join(' ')}`);\n      lines.push('');\n    }\n\n    if (net.styleChanges) {\n      lines.push('##### Styles (before → after)');\n      const before = net.styleChanges.before ?? {};\n      const after = net.styleChanges.after ?? {};\n      const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);\n      for (const key of Array.from(allKeys).sort()) {\n        const beforeVal = before[key] ?? '(unset)';\n        const afterRaw = Object.prototype.hasOwnProperty.call(after, key) ? after[key] : '(unset)';\n        const afterVal = afterRaw === '' ? '(removed)' : afterRaw;\n        if (beforeVal !== afterVal) {\n          lines.push(`- ${key}: \"${beforeVal}\" → \"${afterVal}\"`);\n        }\n      }\n      lines.push('');\n    }\n\n    // Fallback message if no specific changes\n    if (!net.textChange && !net.classChanges && !net.styleChanges) {\n      lines.push(\n        '- No net effect details available; use locator hints to inspect the element in code.',\n      );\n      lines.push('');\n    }\n  });\n\n  // Instructions\n  lines.push('## How to Apply');\n  lines.push('1. Use \"source\" when available to go directly to the component file.');\n  lines.push('2. Otherwise, use selectors/fingerprint/path to locate the element in the codebase.');\n  lines.push('3. Apply the net effect with minimal changes and correct styling conventions.');\n  lines.push('4. Avoid generated/bundled outputs; update source files only.');\n  lines.push('');\n\n  // Output format\n  lines.push('## Constraints');\n  lines.push('- Make the smallest safe edit possible for each element');\n  lines.push(\n    '- If Tailwind/CSS Modules/styled-components are used, update the correct styling source',\n  );\n  lines.push('- Do not change unrelated behavior or formatting');\n  lines.push('');\n\n  lines.push(\n    '## Output\\nApply all the changes in the repo, then reply with a short summary of what file(s) you modified and the exact changes made.',\n  );\n\n  return lines.join('\\n');\n}\n\nfunction buildAgentPrompt(payload: WebEditorApplyPayload): string {\n  const lines: string[] = [];\n\n  // Header\n  lines.push('You are a senior frontend engineer working in a local codebase.');\n  lines.push(\n    'Goal: persist a visual edit from the browser into the source code with minimal changes.',\n  );\n  lines.push('');\n\n  // Page context\n  lines.push(`Page URL: ${payload.pageUrl}`);\n  lines.push('');\n\n  // == Source Location (high-confidence if debugSource available) ==\n  const ds = payload.debugSource;\n  if (ds?.file) {\n    lines.push('## Source Location (from React/Vue debug info)');\n    const loc = ds.line ? `${ds.file}:${ds.line}${ds.column ? `:${ds.column}` : ''}` : ds.file;\n    lines.push(`- file: ${loc}`);\n    if (ds.componentName) lines.push(`- component: ${ds.componentName}`);\n    lines.push('');\n    lines.push('This is high-confidence source location extracted from framework debug info.');\n    lines.push('Start your search here. Only fall back to fingerprint if this file is invalid.');\n    lines.push('');\n  } else if (payload.targetFile) {\n    lines.push(`## Target File (best-effort): ${payload.targetFile}`);\n    lines.push(\n      'If this path is invalid or points to node_modules, fall back to fingerprint search.',\n    );\n    lines.push('');\n  }\n\n  // == Element Fingerprint ==\n  lines.push('## Element Fingerprint');\n  lines.push(`- tag: ${payload.fingerprint.tag}`);\n  if (payload.fingerprint.id) lines.push(`- id: ${payload.fingerprint.id}`);\n  if (payload.fingerprint.classes?.length) {\n    lines.push(`- classes: ${payload.fingerprint.classes.join(' ')}`);\n  }\n  if (payload.fingerprint.text) lines.push(`- text: ${payload.fingerprint.text}`);\n  lines.push('');\n\n  // == CSS Selectors (for precise matching) ==\n  if (payload.selectorCandidates?.length) {\n    lines.push('## CSS Selectors (ordered by specificity)');\n    for (const sel of payload.selectorCandidates.slice(0, 5)) {\n      lines.push(`- ${sel}`);\n    }\n    lines.push('');\n    lines.push('Use these selectors to grep the codebase if file location is unavailable.');\n    lines.push('');\n  }\n\n  // == Tech Stack ==\n  if (payload.techStackHint?.length) {\n    lines.push(`## Tech Stack: ${payload.techStackHint.join(', ')}`);\n    lines.push('');\n  }\n\n  // == Requested Change ==\n  lines.push('## Requested Change');\n  lines.push(`- type: ${payload.instruction.type}`);\n  lines.push(`- description: ${payload.instruction.description}`);\n\n  if (payload.instruction.type === 'update_text' && payload.instruction.text !== undefined) {\n    lines.push(`- new text: ${JSON.stringify(payload.instruction.text)}`);\n  }\n\n  // For style updates, show detailed before/after diff if available\n  if (payload.instruction.type === 'update_style') {\n    const op = payload.operation;\n    if (op && (Object.keys(op.before).length > 0 || Object.keys(op.after).length > 0)) {\n      lines.push('');\n      lines.push('### Style Changes (before → after)');\n      const allKeys = new Set([...Object.keys(op.before), ...Object.keys(op.after)]);\n      for (const key of allKeys) {\n        const before = op.before[key] ?? '(unset)';\n        const after = op.after[key] ?? '(removed)';\n        if (before !== after) {\n          lines.push(`  ${key}: \"${before}\" → \"${after}\"`);\n        }\n      }\n      if (op.removed.length > 0) {\n        lines.push(`  [Removed]: ${op.removed.join(', ')}`);\n      }\n    } else if (payload.instruction.style) {\n      lines.push(`- style map: ${JSON.stringify(payload.instruction.style, null, 2)}`);\n    }\n  }\n  lines.push('');\n\n  // == Instructions ==\n  lines.push('## How to Apply');\n  if (ds?.file) {\n    lines.push(`1. Open ${ds.file}${ds.line ? ` around line ${ds.line}` : ''}`);\n    if (ds.componentName) {\n      lines.push(`2. Locate the \"${ds.componentName}\" component definition`);\n    }\n    lines.push(\n      `3. Find the element matching tag=\"${payload.fingerprint.tag}\"${payload.fingerprint.classes?.length ? ` with classes including \"${payload.fingerprint.classes[0]}\"` : ''}`,\n    );\n    lines.push('4. Apply the requested style/text change');\n  } else if (payload.targetFile) {\n    lines.push(`1. Open ${payload.targetFile}`);\n    lines.push('2. Search for the element by matching fingerprint (tag, classes, text)');\n    lines.push('3. If not found, use repo-wide search with selectors or class names');\n    lines.push('4. Apply the requested change');\n  } else {\n    lines.push('1. Use repo-wide search (rg) with class names or text from fingerprint');\n    if (payload.selectorCandidates?.length) {\n      lines.push(`2. Try searching for: \"${payload.selectorCandidates[0]}\"`);\n    }\n    lines.push('3. Locate the component/template containing this element');\n    lines.push('4. Apply the requested change');\n  }\n  lines.push('');\n\n  // == Constraints ==\n  lines.push('## Constraints');\n  lines.push('- Make the smallest safe edit possible');\n  if (payload.techStackHint?.includes('Tailwind')) {\n    lines.push('- Tailwind detected: prefer updating className over inline styles');\n  }\n  if (payload.techStackHint?.includes('React') || payload.techStackHint?.includes('Vue')) {\n    lines.push('- Update the component source, not generated/bundled code');\n  }\n  lines.push('- If CSS Modules or styled-components are used, update the correct styling source');\n  lines.push('- Do not change unrelated behavior or formatting');\n  lines.push('');\n\n  // == Output ==\n  lines.push(\n    '## Output\\nApply the change in the repo, then reply with a short summary of what file(s) you modified and the exact change made.',\n  );\n\n  return lines.join('\\n');\n}\n\nasync function ensureContextMenu(): Promise<void> {\n  try {\n    if (!(chrome as any).contextMenus?.create) return;\n    try {\n      await chrome.contextMenus.remove(CONTEXT_MENU_ID);\n    } catch {}\n    await chrome.contextMenus.create({\n      id: CONTEXT_MENU_ID,\n      title: '切换网页编辑模式',\n      contexts: ['all'],\n    });\n  } catch (error) {\n    console.warn('[WebEditor] Failed to ensure context menu:', error);\n  }\n}\n\n/**\n * Get the appropriate action constants based on version\n */\nfunction getActions() {\n  return USE_WEB_EDITOR_V2 ? WEB_EDITOR_V2_ACTIONS : WEB_EDITOR_V1_ACTIONS;\n}\n\n/**\n * Ensure the web editor script is injected into the tab\n * Supports both v1 (legacy) and v2 (new) versions\n *\n * V1 and V2 use different action names to avoid conflicts:\n * - V1: web_editor_ping, web_editor_toggle, etc.\n * - V2: web_editor_ping_v2, web_editor_toggle_v2, etc.\n */\nasync function ensureEditorInjected(tabId: number): Promise<void> {\n  const scriptPath = USE_WEB_EDITOR_V2 ? V2_SCRIPT_PATH : V1_SCRIPT_PATH;\n  const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]';\n  const actions = getActions();\n\n  // Try to ping existing instance using version-specific action\n  try {\n    const pong: { status?: string; version?: number } = await chrome.tabs.sendMessage(\n      tabId,\n      { action: actions.PING },\n      { frameId: 0 },\n    );\n\n    if (pong?.status === 'pong') {\n      // Already injected with correct version\n      return;\n    }\n  } catch {\n    // No existing instance, fallthrough to inject\n  }\n\n  // Inject the script\n  try {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: [scriptPath],\n      world: 'ISOLATED',\n    });\n    console.log(`${logPrefix} Script injected successfully`);\n  } catch (error) {\n    console.warn(`${logPrefix} Failed to inject editor script:`, error);\n  }\n}\n\n/**\n * Inject props agent into MAIN world for Phase 7 Props editing\n * Only inject for v2 editor\n */\nasync function ensurePropsAgentInjected(tabId: number): Promise<void> {\n  if (!USE_WEB_EDITOR_V2) return;\n\n  try {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: [PROPS_AGENT_SCRIPT_PATH],\n      world: 'MAIN',\n    });\n  } catch (error) {\n    // Best-effort: some pages (chrome://, extensions, PDF) block injection\n    console.warn('[WebEditorV2] Failed to inject props agent:', error);\n  }\n}\n\n/**\n * Send cleanup event to props agent\n */\nasync function sendPropsAgentCleanup(tabId: number): Promise<void> {\n  if (!USE_WEB_EDITOR_V2) return;\n\n  try {\n    // Dispatch cleanup event in ISOLATED world\n    // CustomEvent crosses worlds and is observed by MAIN agent\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      func: () => {\n        try {\n          window.dispatchEvent(new CustomEvent('web-editor-props:cleanup'));\n        } catch {\n          // ignore\n        }\n      },\n      world: 'ISOLATED',\n    });\n  } catch (error) {\n    // Best-effort cleanup; ignore failures if tab is gone or injection blocked\n    console.warn('[WebEditorV2] Failed to send props agent cleanup:', error);\n  }\n}\n\n// =============================================================================\n// Phase 7.1.6: Early Injection for Props Agent\n// =============================================================================\n\n/**\n * Content script ID prefix for early injection (document_start).\n * Registered scripts persist across sessions and survive browser restarts.\n */\nconst PROPS_AGENT_EARLY_INJECTION_ID_PREFIX = 'mcp_we_props_early';\n\n/**\n * Result of early injection registration\n */\ninterface EarlyInjectionResult {\n  id: string;\n  host: string;\n  matches: string[];\n  alreadyRegistered: boolean;\n}\n\n/**\n * Sanitize a string for use in content script ID\n * Only allows alphanumeric, underscore, and hyphen\n */\nfunction sanitizeContentScriptId(input: string): string {\n  const cleaned = String(input ?? '')\n    .toLowerCase()\n    .replace(/[^a-z0-9_-]+/g, '_')\n    .replace(/^_+|_+$/g, '');\n  return cleaned.slice(0, 80) || 'site';\n}\n\n/**\n * Build match patterns from tab URL for early injection.\n * Returns patterns for the specific host only (not all URLs).\n */\nfunction buildEarlyInjectionPatterns(tabUrl: string): { host: string; matches: string[] } {\n  let url: URL;\n  try {\n    url = new URL(tabUrl);\n  } catch {\n    throw new Error('Invalid tab URL');\n  }\n\n  if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n    throw new Error(`Early injection only supports http/https pages (got ${url.protocol})`);\n  }\n\n  const host = url.hostname.trim();\n  if (!host) {\n    throw new Error('Unable to derive host from tab URL');\n  }\n\n  // Match all paths on this host for both http and https\n  return { host, matches: [`*://${host}/*`] };\n}\n\n/**\n * Register props agent for early injection (document_start, MAIN world).\n * This allows capturing React DevTools hook before React initializes.\n *\n * The registration is per-host and persists across sessions.\n */\nasync function registerPropsAgentEarlyInjection(tabUrl: string): Promise<EarlyInjectionResult> {\n  const { host, matches } = buildEarlyInjectionPatterns(tabUrl);\n  const id = `${PROPS_AGENT_EARLY_INJECTION_ID_PREFIX}_${sanitizeContentScriptId(host)}`;\n\n  // Check if already registered (idempotent)\n  let alreadyRegistered = false;\n  try {\n    const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [id] });\n    alreadyRegistered = existing.some((s) => s.id === id);\n  } catch {\n    // API might not support getRegisteredContentScripts in all contexts\n    alreadyRegistered = false;\n  }\n\n  if (!alreadyRegistered) {\n    await chrome.scripting.registerContentScripts([\n      {\n        id,\n        js: [PROPS_AGENT_SCRIPT_PATH],\n        matches,\n        runAt: 'document_start',\n        world: 'MAIN',\n        allFrames: false,\n        persistAcrossSessions: true,\n      },\n    ]);\n    console.log(`[WebEditorV2] Registered early injection for ${host}`);\n  }\n\n  return { id, host, matches, alreadyRegistered };\n}\n\nasync function toggleEditorInTab(tabId: number): Promise<{ active?: boolean }> {\n  await ensureEditorInjected(tabId);\n  const logPrefix = USE_WEB_EDITOR_V2 ? '[WebEditorV2]' : '[WebEditor]';\n  const actions = getActions();\n\n  try {\n    const resp: { active?: boolean } = await chrome.tabs.sendMessage(\n      tabId,\n      { action: actions.TOGGLE },\n      { frameId: 0 },\n    );\n    const active = typeof resp?.active === 'boolean' ? resp.active : undefined;\n\n    // Phase 7: Inject props agent on start; cleanup on stop\n    if (active === true) {\n      await ensurePropsAgentInjected(tabId);\n    } else if (active === false) {\n      await sendPropsAgentCleanup(tabId);\n    }\n\n    return { active };\n  } catch (error) {\n    console.warn(`${logPrefix} Failed to toggle editor in tab:`, error);\n    return {};\n  }\n}\n\nasync function getActiveTabId(): Promise<number | null> {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    return typeof tabId === 'number' ? tabId : null;\n  } catch {\n    return null;\n  }\n}\n\nexport function initWebEditorListeners(): void {\n  ensureContextMenu().catch(() => {});\n\n  // Clean up session storage when tab is closed to avoid stale data\n  chrome.tabs.onRemoved.addListener((tabId) => {\n    try {\n      const keys = [\n        `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`,\n        `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`,\n        `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`,\n      ];\n      chrome.storage.session.remove(keys).catch(() => {});\n    } catch {}\n  });\n\n  if ((chrome as any).contextMenus?.onClicked?.addListener) {\n    chrome.contextMenus.onClicked.addListener(async (info, tab) => {\n      try {\n        if (info.menuItemId !== CONTEXT_MENU_ID) return;\n        const tabId = tab?.id;\n        if (typeof tabId !== 'number') return;\n        await toggleEditorInTab(tabId);\n      } catch {}\n    });\n  }\n\n  chrome.commands.onCommand.addListener(async (command) => {\n    try {\n      if (command !== COMMAND_KEY) return;\n      const tabId = await getActiveTabId();\n      if (typeof tabId !== 'number') return;\n      await toggleEditorInTab(tabId);\n    } catch {}\n  });\n\n  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n    try {\n      // Phase 7.1.6: Handle early injection registration request\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION) {\n        (async () => {\n          const senderTab = (_sender as chrome.runtime.MessageSender)?.tab;\n          const senderTabId = senderTab?.id;\n          const senderTabUrl = senderTab?.url;\n\n          if (typeof senderTabId !== 'number' || typeof senderTabUrl !== 'string') {\n            return sendResponse({\n              success: false,\n              error: 'Sender tab information is required',\n            });\n          }\n\n          try {\n            const result = await registerPropsAgentEarlyInjection(senderTabUrl);\n\n            // Respond first, then reload (to avoid message port closing during navigation)\n            sendResponse({ success: true, ...result });\n\n            // Small delay to ensure response is sent before navigation\n            await new Promise((resolve) => setTimeout(resolve, 50));\n\n            // Reload the tab so early injection takes effect\n            try {\n              await chrome.tabs.reload(senderTabId);\n            } catch {\n              // Best-effort: some tabs may block reload\n            }\n          } catch (err) {\n            sendResponse({\n              success: false,\n              error: err instanceof Error ? err.message : String(err),\n            });\n          }\n        })();\n        return true; // Async response\n      }\n\n      // =====================================================================\n      // WEB_EDITOR_OPEN_SOURCE: Open component source file in VSCode\n      // =====================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE) {\n        (async () => {\n          try {\n            const payload = message.payload as { debugSource?: unknown } | undefined;\n            const debugSource = payload?.debugSource;\n\n            if (!debugSource || typeof debugSource !== 'object') {\n              return sendResponse({ success: false, error: 'debugSource is required' });\n            }\n\n            const rec = debugSource as Record<string, unknown>;\n            const file = typeof rec.file === 'string' ? rec.file.trim() : '';\n            if (!file) {\n              return sendResponse({ success: false, error: 'debugSource.file is required' });\n            }\n\n            // Read server port and selected project\n            const stored = await chrome.storage.local.get([\n              'nativeServerPort',\n              'agent-selected-project-id',\n            ]);\n            const portRaw = stored.nativeServerPort;\n            const port = Number.isFinite(Number(portRaw))\n              ? Number(portRaw)\n              : DEFAULT_NATIVE_SERVER_PORT;\n            const projectId = stored['agent-selected-project-id'];\n\n            if (!projectId || typeof projectId !== 'string') {\n              return sendResponse({\n                success: false,\n                error: 'No project selected. Please select a project in AgentChat first.',\n              });\n            }\n\n            // Prepare line/column\n            const lineRaw = Number(rec.line);\n            const columnRaw = Number(rec.column);\n            const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;\n            const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;\n\n            // Call native-server to open file (server will validate project and path)\n            const openResp = await fetch(\n              `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open-file`,\n              {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({\n                  filePath: file,\n                  line,\n                  column,\n                }),\n              },\n            );\n\n            // Try to parse JSON response for detailed error\n            let result: { success: boolean; error?: string };\n            try {\n              result = await openResp.json();\n            } catch {\n              const text = await openResp.text().catch(() => '');\n              result = {\n                success: false,\n                error: text || `HTTP ${openResp.status}`,\n              };\n            }\n\n            sendResponse(result);\n          } catch (err) {\n            sendResponse({\n              success: false,\n              error: err instanceof Error ? err.message : String(err),\n            });\n          }\n        })();\n        return true; // Async response\n      }\n\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE) {\n        getActiveTabId()\n          .then(async (tabId) => {\n            if (typeof tabId !== 'number') return sendResponse({ success: false });\n            const result = await toggleEditorInTab(tabId);\n            sendResponse({ success: true, ...result });\n          })\n          .catch(() => sendResponse({ success: false }));\n        return true;\n      }\n\n      // =======================================================================\n      // Phase 1.5: Handle TX_CHANGED broadcast from web-editor\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) {\n        (async () => {\n          const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id;\n          if (typeof senderTabId !== 'number') {\n            sendResponse({ success: false, error: 'Sender tabId is required' });\n            return;\n          }\n\n          const rawPayload = message.payload as WebEditorTxChangedPayload | undefined;\n          if (!rawPayload || typeof rawPayload !== 'object') {\n            sendResponse({ success: false, error: 'Invalid payload' });\n            return;\n          }\n\n          // Hydrate payload with tabId from sender\n          const payload: WebEditorTxChangedPayload = { ...rawPayload, tabId: senderTabId };\n          const storageKey = `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${senderTabId}`;\n\n          // Persist to session storage for cold-start recovery\n          // Remove keys on clear to avoid stale data (rollback still has edits, so keep it)\n          if (payload.action === 'clear') {\n            // Clear TX state and excluded keys together\n            const excludedKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`;\n            await chrome.storage.session.remove([storageKey, excludedKey]);\n          } else {\n            await chrome.storage.session.set({ [storageKey]: payload });\n          }\n\n          // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed)\n          chrome.runtime\n            .sendMessage({\n              type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED,\n              payload,\n            })\n            .catch(() => {\n              // Ignore errors - sidepanel may be closed\n            });\n\n          sendResponse({ success: true });\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      // =======================================================================\n      // Selection sync: Handle SELECTION_CHANGED broadcast from web-editor\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) {\n        (async () => {\n          const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id;\n          if (typeof senderTabId !== 'number') {\n            sendResponse({ success: false, error: 'Sender tabId is required' });\n            return;\n          }\n\n          const rawPayload = message.payload as\n            | import('@/common/web-editor-types').WebEditorSelectionChangedPayload\n            | undefined;\n          if (!rawPayload || typeof rawPayload !== 'object') {\n            sendResponse({ success: false, error: 'Invalid payload' });\n            return;\n          }\n\n          // Hydrate payload with tabId from sender\n          const payload = { ...rawPayload, tabId: senderTabId };\n          const storageKey = `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${senderTabId}`;\n\n          // Persist to session storage for cold-start recovery\n          // Remove key on deselection to avoid stale data\n          if (payload.selected === null) {\n            await chrome.storage.session.remove(storageKey);\n          } else {\n            await chrome.storage.session.set({ [storageKey]: payload });\n          }\n\n          // Broadcast to sidepanel (best-effort, ignore errors if sidepanel is closed)\n          chrome.runtime\n            .sendMessage({\n              type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED,\n              payload,\n            })\n            .catch(() => {\n              // Ignore errors - sidepanel may be closed\n            });\n\n          sendResponse({ success: true });\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      // =======================================================================\n      // Clear selection: Handle CLEAR_SELECTION from sidepanel (after send)\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CLEAR_SELECTION) {\n        (async () => {\n          const payload = message.payload as { tabId?: number } | undefined;\n          const targetTabId = payload?.tabId;\n\n          if (typeof targetTabId !== 'number' || targetTabId <= 0) {\n            sendResponse({ success: false, error: 'Invalid tabId' });\n            return;\n          }\n\n          // Forward to content script (web-editor-v2)\n          try {\n            await chrome.tabs.sendMessage(targetTabId, {\n              action: WEB_EDITOR_V2_ACTIONS.CLEAR_SELECTION,\n            });\n            sendResponse({ success: true });\n          } catch (error) {\n            // Tab may be closed or web-editor not active - this is expected\n            sendResponse({\n              success: false,\n              error: error instanceof Error ? error.message : 'Failed to send to tab',\n            });\n          }\n        })().catch((error) => {\n          // Catch any unhandled errors in the async IIFE\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      // =======================================================================\n      // Phase 1.5: Handle APPLY_BATCH from web-editor toolbar\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY_BATCH) {\n        const payload = normalizeApplyBatchPayload(message.payload);\n        (async () => {\n          const senderTabId = (_sender as chrome.runtime.MessageSender)?.tab?.id;\n          const senderWindowId = (_sender as chrome.runtime.MessageSender)?.tab?.windowId;\n\n          // Read storage for server port and selected session\n          const stored = await chrome.storage.local.get([\n            'nativeServerPort',\n            STORAGE_KEY_SELECTED_SESSION,\n          ]);\n\n          const portRaw = stored?.nativeServerPort;\n          const port = Number.isFinite(Number(portRaw))\n            ? Number(portRaw)\n            : DEFAULT_NATIVE_SERVER_PORT;\n\n          const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim();\n\n          // Best-effort: open AgentChat sidepanel so user can see the session\n          // Pass sessionId for deep linking directly to chat view\n          if (typeof senderTabId === 'number') {\n            openAgentChatSidepanel(senderTabId, senderWindowId, sessionId || undefined).catch(\n              () => {},\n            );\n          }\n\n          if (!sessionId) {\n            // No session selected - sidepanel is already being opened (best-effort)\n            // User needs to select or create a session manually\n            sendResponse({\n              success: false,\n              error:\n                'No Agent session selected. Please select or create a session in AgentChat, then try Apply again.',\n            });\n            return;\n          }\n\n          // Hydrate payload with tabId\n          const hydratedPayload: WebEditorApplyBatchPayload =\n            typeof senderTabId === 'number' ? { ...payload, tabId: senderTabId } : payload;\n\n          // Read excluded keys from session storage (per-tab, managed by sidepanel)\n          let sessionExcludedKeys: string[] = [];\n          if (typeof senderTabId === 'number') {\n            const excludedSessionKey = `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${senderTabId}`;\n            try {\n              if (chrome.storage?.session?.get) {\n                const stored = (await chrome.storage.session.get(excludedSessionKey)) as Record<\n                  string,\n                  unknown\n                >;\n                const raw = stored?.[excludedSessionKey];\n                sessionExcludedKeys = Array.isArray(raw)\n                  ? raw.map((k) => normalizeString(k).trim()).filter(Boolean)\n                  : [];\n              }\n            } catch {\n              // Best-effort: ignore session storage failures\n            }\n          }\n\n          // Filter out excluded elements (union: payload excludedKeys + session excludedKeys)\n          const excluded = new Set([...hydratedPayload.excludedKeys, ...sessionExcludedKeys]);\n          const elements = hydratedPayload.elements.filter((e) => !excluded.has(e.elementKey));\n          if (elements.length === 0) {\n            sendResponse({ success: false, error: 'No elements selected to apply.' });\n            return;\n          }\n\n          // Build page URL from payload or sender tab\n          const pageUrl =\n            normalizeString(hydratedPayload.pageUrl).trim() ||\n            normalizeString((_sender as chrome.runtime.MessageSender)?.tab?.url).trim() ||\n            'unknown';\n\n          // Build batch prompt and send to agent\n          const instruction = buildAgentPromptBatch(elements, pageUrl);\n          const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`;\n\n          // Extract element labels for compact display\n          const elementLabels = elements.slice(0, 5).map((e) => e.label);\n\n          const resp = await fetch(url, {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n              instruction,\n              // Pass dbSessionId so backend loads session-level configuration (engine, model, options)\n              dbSessionId: sessionId,\n              // Display text for UI (compact representation)\n              displayText: `Apply ${elements.length} change${elements.length === 1 ? '' : 's'}`,\n              // Client metadata for special message rendering\n              clientMeta: {\n                kind: 'web_editor_apply_batch',\n                pageUrl,\n                elementCount: elements.length,\n                elementLabels,\n              },\n            }),\n          });\n\n          if (!resp.ok) {\n            const text = await resp.text().catch(() => '');\n            sendResponse({\n              success: false,\n              error: text || `HTTP ${resp.status}`,\n            });\n            return;\n          }\n\n          const json: any = await resp.json().catch(() => ({}));\n          const requestId = json?.requestId as string | undefined;\n\n          if (requestId) {\n            // Start SSE subscription for status updates (fire and forget)\n            subscribeToSessionStatus(sessionId, requestId, port).catch(() => {});\n          }\n\n          sendResponse({ success: true, requestId, sessionId });\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      // =======================================================================\n      // Phase 1.8: Handle HIGHLIGHT_ELEMENT from sidepanel chips hover\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT) {\n        const payload = message.payload as WebEditorHighlightElementPayload | undefined;\n        (async () => {\n          // Validate payload\n          const tabId = payload?.tabId;\n          if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) {\n            sendResponse({ success: false, error: 'Invalid tabId' });\n            return;\n          }\n\n          const mode = payload?.mode;\n          if (mode !== 'hover' && mode !== 'clear') {\n            sendResponse({ success: false, error: 'Invalid mode' });\n            return;\n          }\n\n          // Clear mode: forward directly without locator/selector validation\n          // This prevents overlay residue when sidepanel unmounts\n          if (mode === 'clear') {\n            try {\n              const response = await chrome.tabs.sendMessage(tabId, {\n                action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT,\n                mode: 'clear',\n              });\n              sendResponse({ success: true, response });\n            } catch (error) {\n              sendResponse({\n                success: false,\n                error: String(error instanceof Error ? error.message : error),\n              });\n            }\n            return;\n          }\n\n          // Hover mode: validate and forward locator\n          const locator = payload?.locator;\n          if (!locator || typeof locator !== 'object') {\n            sendResponse({ success: false, error: 'Invalid locator' });\n            return;\n          }\n\n          // Extract best selector for fallback highlighting\n          const selectors = Array.isArray(locator.selectors) ? locator.selectors : [];\n          const primarySelector = selectors.find(\n            (s): s is string => typeof s === 'string' && s.trim().length > 0,\n          );\n\n          if (!primarySelector) {\n            sendResponse({ success: false, error: 'No valid selector in locator' });\n            return;\n          }\n\n          // Forward to web-editor content script\n          try {\n            const response = await chrome.tabs.sendMessage(tabId, {\n              action: WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT,\n              locator, // Full locator for Shadow DOM/iframe support\n              selector: primarySelector, // Backward compatibility fallback\n              mode,\n              elementKey: payload.elementKey,\n            });\n\n            sendResponse({ success: true, response });\n          } catch (error) {\n            // Content script might not be available\n            sendResponse({\n              success: false,\n              error: String(error instanceof Error ? error.message : error),\n            });\n          }\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      // =======================================================================\n      // Phase 2: Handle REVERT_ELEMENT from sidepanel chips\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_REVERT_ELEMENT) {\n        const payload = message.payload as WebEditorRevertElementPayload | undefined;\n        (async () => {\n          // Validate payload\n          const tabId = payload?.tabId;\n          if (typeof tabId !== 'number' || !Number.isFinite(tabId) || tabId <= 0) {\n            sendResponse({ success: false, error: 'Invalid tabId' });\n            return;\n          }\n\n          const elementKey = payload?.elementKey;\n          if (typeof elementKey !== 'string' || !elementKey.trim()) {\n            sendResponse({ success: false, error: 'Invalid elementKey' });\n            return;\n          }\n\n          // Forward to web-editor content script (frameId: 0 for main frame only)\n          try {\n            const response = await chrome.tabs.sendMessage(\n              tabId,\n              {\n                action: WEB_EDITOR_V2_ACTIONS.REVERT_ELEMENT,\n                elementKey,\n              },\n              { frameId: 0 },\n            );\n\n            sendResponse({ success: true, ...response });\n          } catch (error) {\n            // Content script might not be available\n            sendResponse({\n              success: false,\n              error: String(error instanceof Error ? error.message : error),\n            });\n          }\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY) {\n        const payload = normalizeApplyPayload(message.payload);\n        (async () => {\n          const senderTabId = (_sender as any)?.tab?.id;\n          const sessionId =\n            typeof senderTabId === 'number' ? `web-editor-${senderTabId}` : 'web-editor';\n\n          const stored = await chrome.storage.local.get([\n            'nativeServerPort',\n            'agent-selected-project-id',\n          ]);\n          const portRaw = stored?.nativeServerPort;\n          const port = Number.isFinite(Number(portRaw))\n            ? Number(portRaw)\n            : DEFAULT_NATIVE_SERVER_PORT;\n\n          const projectId = normalizeString(stored?.['agent-selected-project-id']).trim() || '';\n\n          if (!projectId) {\n            return sendResponse({\n              success: false,\n              error:\n                'No Agent project selected. Open Side Panel → 智能助手 and select/create a project first.',\n            });\n          }\n\n          const instruction = buildAgentPrompt(payload);\n          const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/act`;\n\n          const resp = await fetch(url, {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n              instruction,\n              projectId,\n            }),\n          });\n\n          if (!resp.ok) {\n            const text = await resp.text().catch(() => '');\n            return sendResponse({\n              success: false,\n              error: text || `HTTP ${resp.status}`,\n            });\n          }\n\n          const json: any = await resp.json().catch(() => ({}));\n          const requestId = json?.requestId as string | undefined;\n\n          if (requestId) {\n            // Start SSE subscription for status updates (fire and forget)\n            subscribeToSessionStatus(sessionId, requestId, port).catch(() => {});\n          }\n\n          return sendResponse({ success: true, requestId, sessionId });\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          });\n        });\n        return true;\n      }\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_STATUS_QUERY) {\n        const { requestId } = message;\n        if (!requestId || typeof requestId !== 'string') {\n          sendResponse({ success: false, error: 'requestId is required' });\n          return false;\n        }\n\n        const entry = getExecutionStatus(requestId);\n        if (!entry) {\n          // No status yet - likely still pending or not tracked\n          sendResponse({ success: true, status: 'pending', message: 'Waiting for status...' });\n        } else {\n          sendResponse({\n            success: true,\n            status: entry.status,\n            message: entry.message,\n            result: entry.result,\n          });\n        }\n        return false; // Synchronous response\n      }\n\n      // =======================================================================\n      // Cancel Execution: Handle WEB_EDITOR_CANCEL_EXECUTION from toolbar/sidepanel\n      // =======================================================================\n      if (message?.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CANCEL_EXECUTION) {\n        const payload = message.payload as WebEditorCancelExecutionPayload | undefined;\n        (async () => {\n          // Validate payload\n          const sessionId = payload?.sessionId?.trim();\n          const requestId = payload?.requestId?.trim();\n\n          if (!sessionId) {\n            sendResponse({\n              success: false,\n              error: 'sessionId is required',\n            } as WebEditorCancelExecutionResponse);\n            return;\n          }\n          if (!requestId) {\n            sendResponse({\n              success: false,\n              error: 'requestId is required',\n            } as WebEditorCancelExecutionResponse);\n            return;\n          }\n\n          // Get server port\n          const stored = await chrome.storage.local.get(['nativeServerPort']);\n          const port = stored.nativeServerPort || DEFAULT_NATIVE_SERVER_PORT;\n\n          try {\n            // Call cancel API\n            const cancelUrl = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`;\n            const response = await fetch(cancelUrl, { method: 'DELETE' });\n\n            if (!response.ok) {\n              const errorText = await response.text().catch(() => `HTTP ${response.status}`);\n              sendResponse({\n                success: false,\n                error: errorText,\n              } as WebEditorCancelExecutionResponse);\n              return;\n            }\n\n            // Update local execution status cache\n            setExecutionStatus(requestId, 'cancelled', 'Execution cancelled by user');\n\n            // Abort SSE connection for this session\n            const sseConnection = sseConnections.get(sessionId);\n            if (sseConnection && sseConnection.lastRequestId === requestId) {\n              sseConnection.abort.abort();\n              sseConnections.delete(sessionId);\n            }\n\n            sendResponse({ success: true } as WebEditorCancelExecutionResponse);\n          } catch (error) {\n            sendResponse({\n              success: false,\n              error: String(error instanceof Error ? error.message : error),\n            } as WebEditorCancelExecutionResponse);\n          }\n        })().catch((error) => {\n          sendResponse({\n            success: false,\n            error: String(error instanceof Error ? error.message : error),\n          } as WebEditorCancelExecutionResponse);\n        });\n        return true; // Will respond asynchronously\n      }\n    } catch (error) {\n      sendResponse({\n        success: false,\n        error: String(error instanceof Error ? error.message : error),\n      });\n    }\n    return false;\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/builder/App.vue",
    "content": "<template>\n  <!-- rr-theme container provides CSS variables; data-theme for light/dark -->\n  <div class=\"builder-page rr-theme\" :data-theme=\"theme\">\n    <div v-if=\"fallbackNotice\" class=\"notice-top\">\n      <span>已应用回退建议：提升 {{ fallbackNotice.type }} 优先级</span>\n      <button class=\"mini\" @click=\"undoFallbackPromotion\">撤销</button>\n    </div>\n\n    <div class=\"main\">\n      <Canvas\n        :nodes=\"store.nodes\"\n        :edges=\"store.edges\"\n        :node-errors=\"validation.nodeErrors\"\n        :focus-node-id=\"focusNodeId\"\n        :fit-seq=\"fitSeq\"\n        @select-node=\"store.selectNode\"\n        @select-edge=\"store.selectEdge\"\n        @duplicate-node=\"store.duplicateNode\"\n        @remove-node=\"store.removeNode\"\n        @connect-from=\"store.connectFrom\"\n        @connect=\"store.onConnect\"\n        @node-dragged=\"store.setNodePosition\"\n        @add-node-at=\"onAddNodeAt\"\n      />\n\n      <div class=\"topbar rr-topbar backdrop-blur\">\n        <div class=\"left\">\n          <strong class=\"text-[var(--rr-text)]\">{{ title }}</strong>\n          <span class=\"tip\">工作流可视化编排</span>\n        </div>\n        <div class=\"right\">\n          <button class=\"top-btn\" @click=\"exportFlow\" title=\"导出 JSON\">\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\" />\n            </svg>\n            导出\n          </button>\n          <label class=\"top-btn import\" title=\"导入 JSON\">\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12\" />\n            </svg>\n            导入\n            <input type=\"file\" accept=\"application/json\" @change=\"onImport\" />\n          </label>\n          <button class=\"top-btn\" @click=\"openRename\" title=\"重命名工作流\">\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path d=\"M12 20h9\" />\n              <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z\" />\n            </svg>\n            Rename\n          </button>\n          <button\n            class=\"top-btn\"\n            :class=\"{ active: triggerPanelVisible }\"\n            @click=\"triggerPanelVisible = !triggerPanelVisible\"\n            title=\"管理触发器\"\n          >\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path d=\"M13 2L3 14h9l-1 8 10-12h-9l1-8z\" />\n            </svg>\n            Triggers\n          </button>\n          <span class=\"divider-vert\" />\n          <button\n            class=\"top-btn\"\n            :disabled=\"!selectedId\"\n            @click=\"runFromSelected\"\n            title=\"从选中节点回放\"\n          >\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <polygon points=\"5 3 19 12 5 21 5 3\" />\n            </svg>\n            从选中运行\n          </button>\n          <button class=\"top-btn primary\" @click=\"runAll\" title=\"从头回放整流\">\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <polygon points=\"5 3 19 12 5 21 5 3\" />\n            </svg>\n            运行\n          </button>\n          <span class=\"divider-vert\" />\n          <span class=\"status\" :data-state=\"saveState\">{{ saveLabel }}</span>\n\n          <button class=\"top-btn success\" @click=\"save\">\n            <svg\n              width=\"14\"\n              height=\"14\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\" />\n              <polyline points=\"17 21 17 13 7 13 7 21\" />\n              <polyline points=\"7 3 7 8 15 8\" />\n            </svg>\n            保存\n          </button>\n        </div>\n      </div>\n\n      <Sidebar\n        class=\"floating-sidebar\"\n        :flow=\"store.flowLocal\"\n        :palette-types=\"store.paletteTypes\"\n        :subflow-ids=\"store.listSubflowIds()\"\n        :current-subflow-id=\"currentSubflowIdVal\"\n        @add-node=\"store.addNode\"\n        @switch-main=\"store.switchToMain\"\n        @switch-subflow=\"store.switchToSubflow\"\n        @add-subflow=\"store.addSubflow\"\n        @remove-subflow=\"store.removeSubflow\"\n      />\n\n      <PropertyPanel\n        v-if=\"activeNode\"\n        class=\"floating-property\"\n        :node=\"activeNode\"\n        :variables=\"availableVars\"\n        :highlight-field=\"highlightField\"\n        :subflow-ids=\"store.listSubflowIds()\"\n        @remove-node=\"store.removeNode\"\n        @create-subflow=\"store.addSubflow\"\n        @switch-to-subflow=\"store.switchToSubflow\"\n      />\n      <EdgePropertyPanel\n        v-else-if=\"activeEdge\"\n        class=\"floating-property\"\n        :edge=\"activeEdge\"\n        :nodes=\"store.nodes\"\n        @remove-edge=\"store.removeEdge\"\n      />\n\n      <TriggerPanel\n        v-if=\"triggerPanelVisible && store.flowLocal?.id\"\n        class=\"floating-trigger\"\n        :flow-id=\"store.flowLocal.id\"\n        @close=\"triggerPanelVisible = false\"\n      />\n\n      <div class=\"bottom-toolbar\">\n        <button class=\"toolbar-btn\" @click=\"store.undo\" title=\"撤销 (⌘/Ctrl+Z)\">\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path d=\"M3 7v6h6M21 17a9 9 0 00-9-9 9 9 0 00-9 9\" />\n          </svg>\n        </button>\n        <button class=\"toolbar-btn\" @click=\"store.redo\" title=\"重做 (⌘/Ctrl+Shift+Z)\">\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path d=\"M21 7v6h-6M3 17a9 9 0 019-9 9 9 0 019 9\" />\n          </svg>\n        </button>\n        <span class=\"toolbar-divider\" />\n        <button class=\"toolbar-btn\" @click=\"store.layoutAuto\" title=\"自动排版\">\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n            <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\" />\n            <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n            <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\" />\n          </svg>\n        </button>\n        <button class=\"toolbar-btn\" @click=\"fitAll\" title=\"自适应视图\">\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path\n              d=\"M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3\"\n            />\n          </svg>\n        </button>\n      </div>\n    </div>\n    <!-- simple toast container -->\n    <div class=\"rr-toast-container\">\n      <div v-for=\"t in toasts\" :key=\"t.id\" class=\"rr-toast\" :data-level=\"t.level\">\n        {{ t.message }}\n      </div>\n    </div>\n  </div>\n  <!-- Rename dialog -->\n  <div v-if=\"renameVisible\" class=\"rr-modal\">\n    <div class=\"rr-dialog small\">\n      <div class=\"rr-header\">\n        <div class=\"title\">重命名工作流</div>\n        <button class=\"close\" @click=\"renameVisible = false\">✕</button>\n      </div>\n      <div class=\"rr-body\">\n        <div class=\"row\">\n          <label>名称</label>\n          <input v-model=\"renameName\" placeholder=\"工作流名称\" />\n        </div>\n        <div class=\"row\">\n          <label>描述</label>\n          <textarea v-model=\"renameDesc\" placeholder=\"可选描述\"></textarea>\n        </div>\n      </div>\n      <div class=\"rr-footer\">\n        <button class=\"primary\" @click=\"applyRename\">保存</button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n// Dedicated full-page builder using the same inner components as popup modal\nimport { computed, onMounted, onUnmounted, ref, watch } from 'vue';\nimport type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport type {\n  FlowId,\n  NodeId,\n  TriggerId,\n} from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';\nimport type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport { useRRV3Rpc } from '@/entrypoints/shared/composables';\nimport {\n  flowV2ToV3ForRpc,\n  flowV3ToV2ForBuilder,\n  isFlowV3,\n  extractFlowCandidates,\n} from '@/entrypoints/shared/utils';\n\nimport { useBuilderStore } from '@/entrypoints/popup/components/builder/store/useBuilderStore';\nimport { validateFlow } from '@/entrypoints/popup/components/builder/model/validation';\nimport Canvas from '@/entrypoints/popup/components/builder/components/Canvas.vue';\nimport Sidebar from '@/entrypoints/popup/components/builder/components/Sidebar.vue';\nimport PropertyPanel from '@/entrypoints/popup/components/builder/components/PropertyPanel.vue';\nimport EdgePropertyPanel from '@/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue';\nimport TriggerPanel from '@/entrypoints/popup/components/builder/components/TriggerPanel.vue';\n\nconst title = ref('工作流编辑器');\n// theme state: persisted in localStorage and default to system preference\nconst theme = ref<'light' | 'dark'>(\n  (localStorage.getItem('rr-theme') as 'light' | 'dark' | null) ||\n    (matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),\n);\nfunction toggleTheme() {\n  theme.value = theme.value === 'dark' ? 'light' : 'dark';\n  try {\n    localStorage.setItem('rr-theme', theme.value);\n  } catch {}\n}\nconst store = useBuilderStore();\n\n// V3 RPC client\nconst rpc = useRRV3Rpc({\n  autoConnect: true,\n  onError: (message) => pushToast(message, 'error'),\n});\n\n// toast event bus (listen to rr_toast)\ntype ToastItem = { id: string; message: string; level: 'info' | 'warn' | 'error' };\nconst toasts = ref<ToastItem[]>([]);\nfunction pushToast(message: string, level: 'info' | 'warn' | 'error' = 'warn') {\n  const id = `toast_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;\n  const item: ToastItem = { id, message, level };\n  toasts.value.push(item);\n  setTimeout(() => {\n    const idx = toasts.value.findIndex((x) => x.id === id);\n    if (idx >= 0) toasts.value.splice(idx, 1);\n  }, 2500);\n}\nfunction onToast(ev: any) {\n  try {\n    const msg = String(ev?.detail?.message || '');\n    const level = (ev?.detail?.level || 'warn') as any;\n    if (msg) pushToast(msg, level);\n  } catch {}\n}\nonMounted(() => window.addEventListener('rr_toast', onToast as any));\nonUnmounted(() => window.removeEventListener('rr_toast', onToast as any));\n\n// Parse query string\nfunction getQuery(): Record<string, string> {\n  const q: Record<string, string> = {};\n  const url = new URL(location.href);\n  url.searchParams.forEach((v, k) => (q[k] = v));\n  return q;\n}\n\nasync function bootstrap() {\n  const q = getQuery();\n  if (q.flowId) {\n    try {\n      await rpc.ensureConnected();\n      const flowV3 = (await rpc.request('rr_v3.getFlow', {\n        flowId: q.flowId as FlowId,\n      })) as FlowV3 | null;\n\n      if (flowV3) {\n        const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(flowV3);\n        warnings.forEach((w) => pushToast(w, 'warn'));\n        store.initFromFlow(flowV2);\n        title.value = `编辑：${flowV2.name || flowV2.id}`;\n\n        if (q.focus) {\n          setTimeout(() => {\n            try {\n              store.selectNode(q.focus!);\n              (focusNodeId as any).value = q.focus!;\n              setTimeout(() => ((focusNodeId as any).value = null), 300);\n            } catch {}\n          }, 0);\n        }\n      } else {\n        // Flow not found - notify user and initialize empty flow\n        pushToast(`工作流 \"${q.flowId}\" 未找到，已创建新工作流`, 'warn');\n        initEmptyFlow();\n      }\n    } catch (e) {\n      pushToast(`加载工作流失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n      initEmptyFlow();\n    }\n  } else if (q.new === '1') {\n    initEmptyFlow();\n  }\n}\n\n/**\n * 初始化一个空的工作流\n */\nfunction initEmptyFlow() {\n  const now = Date.now();\n  const empty: FlowV2 = {\n    id: `flow_${now}`,\n    name: '新建工作流',\n    version: 1,\n    steps: [],\n    variables: [],\n    meta: {\n      createdAt: new Date(now).toISOString(),\n      updatedAt: new Date(now).toISOString(),\n    } as any,\n  } as any;\n  store.initFromFlow(empty);\n  title.value = '新建工作流';\n}\n\n// Builder helpers mostly ported from modal component\nconst selectedId = computed<string | null>(() => (store.activeNodeId as any)?.value ?? null);\nconst selectedEdgeId = computed<string | null>(() => (store.activeEdgeId as any)?.value ?? null);\nconst activeNode = computed(() => store.nodes.find((n) => n.id === selectedId.value) || null);\nconst activeEdge = computed(() => store.edges.find((e) => e.id === selectedEdgeId.value) || null);\nconst validation = computed(() => validateFlow(store.nodes));\n\n// Available variables for the currently selected node (global + previous node outputs)\nconst availableVars = computed(() => store.listAvailableVariables(selectedId.value || undefined));\n\nconst search = ref('');\nconst focusNodeId = ref<string | null>(null);\nconst currentSubflowIdVal = computed<string | null>(\n  () => (store.currentSubflowId as any)?.value ?? null,\n);\nconst highlightField = ref<string | null>(null);\nconst fitSeq = ref(0);\nfunction focusSearch() {\n  const q = search.value.trim().toLowerCase();\n  if (!q) return;\n  const matches = (n: any): boolean => {\n    if ((n.name || '').toLowerCase().includes(q)) return true;\n    if ((n.type || '').toLowerCase().includes(q)) return true;\n    try {\n      const walk = (v: any): boolean => {\n        if (v == null) return false;\n        if (typeof v === 'string')\n          return v.toLowerCase().includes(q) || v.toLowerCase().includes(`{${q}}`);\n        if (Array.isArray(v)) return v.some(walk);\n        if (typeof v === 'object') return Object.values(v).some(walk);\n        return false;\n      };\n      return walk(n.config);\n    } catch {\n      return false;\n    }\n  };\n  const hit = store.nodes.find((n) => matches(n));\n  if (hit) {\n    store.selectNode(hit.id);\n    focusNodeId.value = hit.id;\n    setTimeout(() => (focusNodeId.value = null), 300);\n  }\n}\nfunction onAddNodeAt(type: string, x: number, y: number) {\n  try {\n    store.addNodeAt(type as any, x, y);\n  } catch {}\n}\nfunction fitAll() {\n  fitSeq.value++;\n}\n\n// trigger panel state\nconst triggerPanelVisible = ref(false);\n\n// rename dialog\nconst renameVisible = ref(false);\nconst renameName = ref('');\nconst renameDesc = ref('');\nfunction openRename() {\n  renameName.value = store.flowLocal.name || '';\n  renameDesc.value = (store.flowLocal as any).description || '';\n  renameVisible.value = true;\n}\nfunction applyRename() {\n  store.flowLocal.name = renameName.value.trim();\n  (store.flowLocal as any).description = renameDesc.value;\n  renameVisible.value = false;\n}\n\n/**\n * 保存 Flow 到 V3 RPC\n * @returns 保存成功返回 FlowV3，失败返回 null\n */\nasync function save(): Promise<FlowV3 | null> {\n  try {\n    // Use exportFlowForSave to properly handle subflow editing:\n    // - Flushes current canvas state back to flowLocal (including subflow edits)\n    // - Returns deep copy with correct nodes/edges from flowLocal\n    // Note: steps are NOT generated - nodes/edges are the source of truth\n    const flowV2 = store.exportFlowForSave();\n    await rpc.ensureConnected();\n\n    // Convert V2 -> V3 for RPC\n    const { flow: flowV3, warnings: convWarnings } = flowV2ToV3ForRpc(flowV2);\n    convWarnings.forEach((w) => pushToast(w, 'warn'));\n\n    // Save via RPC (cast FlowV3 to JsonObject for RPC compatibility)\n    const saved = (await rpc.request('rr_v3.saveFlow', {\n      flow: flowV3 as unknown as JsonObject,\n    })) as unknown as FlowV3;\n\n    // Sync timestamps back to local state\n    if (!store.flowLocal.meta) {\n      (store.flowLocal as any).meta = {};\n    }\n    (store.flowLocal as any).meta.createdAt = saved.createdAt;\n    (store.flowLocal as any).meta.updatedAt = saved.updatedAt;\n\n    // Sync triggers (best-effort, don't block save result)\n    try {\n      await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);\n    } catch {}\n\n    return saved;\n  } catch (e) {\n    pushToast(`保存失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n    return null;\n  }\n}\n\n// ==================== Trigger Sync Helpers ====================\n\nfunction trigId(flowId: string, nodeId: string, kind: string): TriggerId {\n  return `trg_${flowId}_${nodeId}_${kind}` as TriggerId;\n}\n\nfunction schId(flowId: string, nodeId: string, idx: number): TriggerId {\n  return `sch_${flowId}_${nodeId}_${idx}` as TriggerId;\n}\n\n/**\n * 将 V2 schedule 配置转换为 cron 表达式\n * @returns cron 表达式或 null（如果无法转换）\n */\nfunction scheduleToCron(schedule: { type?: string; when?: string }): string | null {\n  if (!schedule) return null;\n\n  const type = String(schedule.type || '').trim();\n  const when = String(schedule.when || '').trim();\n\n  if (type === 'interval') {\n    const minutesRaw = Number(when);\n    if (!Number.isFinite(minutesRaw)) return null;\n    const minutes = Math.max(1, Math.round(minutesRaw));\n    if (minutes < 60) return `*/${minutes} * * * *`;\n    const hours = Math.max(1, Math.round(minutes / 60));\n    return `0 */${hours} * * *`;\n  }\n\n  if (type === 'daily') {\n    const [hRaw, mRaw] = when.split(':');\n    const hourRaw = Number(hRaw);\n    const minuteRaw = Number(mRaw);\n    if (!Number.isFinite(hourRaw) || !Number.isFinite(minuteRaw)) return null;\n    const hour = Math.min(23, Math.max(0, Math.floor(hourRaw)));\n    const minute = Math.min(59, Math.max(0, Math.floor(minuteRaw)));\n    return `${minute} ${hour} * * *`;\n  }\n\n  // V3 cron 不支持 'once' 一次性定时\n  return null;\n}\n\n/**\n * 从 trigger 节点配置同步触发器到 V3 存储\n * @description V2 schedules 会转换为 V3 cron triggers\n */\nasync function syncTriggersAndSchedules(flowId: string, nodes: unknown[]) {\n  const triggersNeeded: TriggerSpec[] = [];\n  const tnodes = (nodes || []).filter((n: any) => n && n.type === 'trigger');\n\n  for (const n of tnodes as any[]) {\n    const cfg = n.config || {};\n    const enabled = cfg.enabled !== false;\n\n    // URL trigger\n    if (cfg.modes?.url && Array.isArray(cfg.url?.rules) && cfg.url.rules.length) {\n      triggersNeeded.push({\n        id: trigId(flowId, n.id, 'url'),\n        kind: 'url',\n        enabled,\n        flowId: flowId as FlowId,\n        match: cfg.url.rules,\n      });\n    }\n\n    // Context menu trigger\n    if (cfg.modes?.contextMenu && cfg.contextMenu?.title) {\n      triggersNeeded.push({\n        id: trigId(flowId, n.id, 'menu'),\n        kind: 'contextMenu',\n        enabled,\n        flowId: flowId as FlowId,\n        title: cfg.contextMenu.title,\n        contexts: (Array.isArray(cfg.contextMenu.contexts)\n          ? cfg.contextMenu.contexts\n          : ['all']\n        ).map(String),\n      });\n    }\n\n    // Command trigger\n    if (cfg.modes?.command && cfg.command?.commandKey) {\n      triggersNeeded.push({\n        id: trigId(flowId, n.id, 'cmd'),\n        kind: 'command',\n        enabled,\n        flowId: flowId as FlowId,\n        commandKey: String(cfg.command.commandKey),\n      });\n    }\n\n    // DOM trigger\n    if (cfg.modes?.dom && cfg.dom?.selector) {\n      const debounceMsRaw = Number(cfg.dom.debounceMs);\n      triggersNeeded.push({\n        id: trigId(flowId, n.id, 'dom'),\n        kind: 'dom',\n        enabled,\n        flowId: flowId as FlowId,\n        selector: String(cfg.dom.selector),\n        appear: cfg.dom.appear !== false,\n        once: cfg.dom.once !== false,\n        debounceMs: Number.isFinite(debounceMsRaw) ? debounceMsRaw : 800,\n      });\n    }\n\n    // Schedule -> Cron trigger (V3 converts schedules to cron)\n    if (cfg.modes?.schedule && Array.isArray(cfg.schedules)) {\n      cfg.schedules.forEach((s: any, i: number) => {\n        const cron = scheduleToCron(s);\n        if (!cron) {\n          const scheduleType = String(s?.type || 'unknown');\n          if (scheduleType === 'once') {\n            pushToast(\n              `节点 ${n.id} 的定时 #${i + 1}: V3 暂不支持一次性定时（once），已跳过`,\n              'warn',\n            );\n          } else {\n            pushToast(\n              `节点 ${n.id} 的定时 #${i + 1}: 无法转换为 cron（type=${scheduleType}），已跳过`,\n              'warn',\n            );\n          }\n          return;\n        }\n\n        triggersNeeded.push({\n          id: schId(flowId, n.id, i),\n          kind: 'cron',\n          enabled: enabled && s?.enabled !== false,\n          flowId: flowId as FlowId,\n          cron,\n        });\n      });\n    }\n  }\n\n  // Sync triggers via V3 RPC\n  try {\n    await rpc.ensureConnected();\n\n    // Get existing triggers for this flow\n    const existing = (await rpc.request('rr_v3.listTriggers', {\n      flowId: flowId as FlowId,\n    })) as TriggerSpec[] | null;\n\n    const existingById = new Map((existing || []).map((t) => [t.id, t]));\n    const neededIds = new Set(triggersNeeded.map((t) => t.id));\n\n    // Create or update triggers\n    for (const trigger of triggersNeeded) {\n      // Cast TriggerSpec to JsonObject for RPC compatibility\n      const triggerPayload = trigger as unknown as JsonObject;\n      if (existingById.has(trigger.id)) {\n        await rpc.request('rr_v3.updateTrigger', { trigger: triggerPayload });\n      } else {\n        await rpc.request('rr_v3.createTrigger', { trigger: triggerPayload });\n      }\n    }\n\n    // Delete stale triggers (only node-managed triggers, not panel-created ones like interval/once)\n    // Node-managed trigger IDs have prefixes: trg_{flowId}_ or sch_{flowId}_\n    const nodeManagedPrefixes = [`trg_${flowId}_`, `sch_${flowId}_`];\n    const isNodeManaged = (triggerId: string) =>\n      nodeManagedPrefixes.some((prefix) => triggerId.startsWith(prefix));\n\n    for (const existing of existingById.values()) {\n      if (!neededIds.has(existing.id) && isNodeManaged(existing.id)) {\n        await rpc.request('rr_v3.deleteTrigger', { triggerId: existing.id });\n      }\n    }\n  } catch (e) {\n    // Best-effort sync - log for debugging but don't block user\n    console.warn('[Builder] Trigger sync failed:', e);\n  }\n}\n\nasync function exportFlow() {\n  try {\n    // Save first to ensure latest changes are persisted\n    const saved = await save();\n    if (!saved) return;\n\n    // Export the V3 flow directly (no need for separate RPC call)\n    const blob = new Blob([JSON.stringify(saved, null, 2)], { type: 'application/json' });\n    const url = URL.createObjectURL(blob);\n    await chrome.downloads.download({\n      url,\n      filename: `${store.flowLocal.name || 'flow'}.json`,\n      saveAs: true,\n    } as chrome.downloads.DownloadOptions);\n    URL.revokeObjectURL(url);\n  } catch (e) {\n    pushToast(`导出失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n  }\n}\n\nasync function onImport(e: Event) {\n  const input = e.target as HTMLInputElement;\n  const file = input.files?.[0];\n  if (!file) return;\n\n  try {\n    const txt = await file.text();\n    const parsed = JSON.parse(txt);\n    const candidates = extractFlowCandidates(parsed);\n\n    if (!candidates.length) {\n      pushToast('导入失败：未找到工作流数据', 'error');\n      return;\n    }\n\n    const first = candidates[0];\n\n    if (isFlowV3(first)) {\n      // V3 format: save via RPC, then load into builder\n      await rpc.ensureConnected();\n      const saved = (await rpc.request('rr_v3.saveFlow', {\n        flow: first as unknown as JsonObject,\n      })) as unknown as FlowV3;\n\n      const { flow: flowV2, warnings } = flowV3ToV2ForBuilder(saved);\n      warnings.forEach((w) => pushToast(w, 'warn'));\n      store.initFromFlow(flowV2);\n      title.value = `编辑：${flowV2.name || flowV2.id}`;\n\n      // Sync triggers\n      try {\n        await syncTriggersAndSchedules(flowV2.id, flowV2.nodes || []);\n      } catch {}\n    } else {\n      // V2 format: load directly, then save to convert to V3\n      store.initFromFlow(first as FlowV2);\n\n      // If V2 flow has steps but no nodes, trigger conversion\n      if (\n        Array.isArray((first as any)?.steps) &&\n        (!Array.isArray((first as any)?.nodes) || (first as any).nodes.length === 0)\n      ) {\n        store.importFromSteps();\n      }\n\n      title.value = `编辑：${store.flowLocal.name || store.flowLocal.id}`;\n      await save(); // Convert and save as V3\n    }\n  } catch (e) {\n    pushToast(`导入失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n  } finally {\n    input.value = '';\n  }\n}\n\nasync function runFromSelected() {\n  if (!selectedId.value || !store.flowLocal?.id) return;\n\n  try {\n    const saved = await save();\n    if (!saved) return;\n\n    await rpc.ensureConnected();\n\n    // Skip trigger nodes (they can't be start nodes)\n    const node = store.nodes.find((n) => n.id === selectedId.value) || null;\n    const startNodeId = node?.type === 'trigger' ? undefined : selectedId.value;\n\n    await rpc.request('rr_v3.enqueueRun', {\n      flowId: saved.id as FlowId,\n      ...(startNodeId ? { startNodeId: startNodeId as NodeId } : {}),\n    });\n  } catch (e) {\n    pushToast(`运行失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n  }\n}\n\nasync function runAll() {\n  if (!store.flowLocal?.id) return;\n\n  try {\n    const saved = await save();\n    if (!saved) return;\n\n    await rpc.ensureConnected();\n    await rpc.request('rr_v3.enqueueRun', { flowId: saved.id as FlowId });\n  } catch (e) {\n    pushToast(`运行失败：${e instanceof Error ? e.message : String(e)}`, 'error');\n  }\n}\n\n// Hotkeys\nfunction onKey(e: KeyboardEvent) {\n  const id = selectedId.value;\n  const isMeta = e.metaKey || e.ctrlKey;\n  // Do not trigger global hotkeys when user is typing in an input control\n  // or editing inside contenteditable, especially within the property panel.\n  const t = e.target as HTMLElement | null;\n  if (t) {\n    const tag = (t.tagName || '').toLowerCase();\n    const inEditable =\n      tag === 'input' ||\n      tag === 'textarea' ||\n      tag === 'select' ||\n      (t as HTMLElement).isContentEditable ||\n      !!t.closest('.floating-property');\n    if (inEditable) return;\n  }\n\n  if ((e.key === 'Delete' || e.key === 'Backspace') && id) {\n    e.preventDefault();\n    store.removeNode(id);\n  } else if (isMeta && e.key.toLowerCase?.() === 'd') {\n    if (id) {\n      e.preventDefault();\n      store.duplicateNode(id);\n    }\n  } else if (isMeta && e.key.toLowerCase?.() === 'z') {\n    e.preventDefault();\n    if (e.shiftKey) store.redo();\n    else store.undo();\n  } else if (isMeta && e.key.toLowerCase?.() === 's') {\n    e.preventDefault();\n    save();\n  }\n}\nonMounted(() => {\n  document.addEventListener('keydown', onKey);\n  bootstrap();\n});\nonUnmounted(() => document.removeEventListener('keydown', onKey));\n\n// Auto save debounced\nconst saveState = ref<'idle' | 'saving' | 'saved'>('idle');\nconst saveLabel = computed(() =>\n  saveState.value === 'saving' ? '保存中…' : saveState.value === 'saved' ? '已保存' : '',\n);\nlet saveTimer: ReturnType<typeof setTimeout> | null = null;\nlet statusTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction scheduleAutoSave() {\n  if (saveTimer) clearTimeout(saveTimer);\n  saveTimer = setTimeout(async () => {\n    try {\n      saveState.value = 'saving';\n      await new Promise((r) => setTimeout(r, 0));\n      const saved = await save();\n      if (!saved) {\n        saveState.value = 'idle';\n        return;\n      }\n      saveState.value = 'saved';\n      if (statusTimer) clearTimeout(statusTimer);\n      statusTimer = setTimeout(() => (saveState.value = 'idle'), 1200);\n    } catch {\n      saveState.value = 'idle';\n    }\n  }, 800);\n}\nwatch(\n  () => [store.nodes, store.edges, store.flowLocal.name, (store.flowLocal as any).description],\n  scheduleAutoSave,\n  { deep: true },\n);\n\n// Fallback suggestion from run logs\nconst fallbackNotice = ref<{ nodeId: string; type: string; prevIndex: number } | null>(null);\nfunction applyFallbackPromotion(nodeId: string, toType: string) {\n  const node = store.nodes.find((n) => n.id === nodeId);\n  if (!node || (node.type !== 'click' && node.type !== 'fill')) return;\n  const cands = (node as any).config?.target?.candidates as Array<{ type: string; value: string }>;\n  if (!Array.isArray(cands) || !cands.length) return;\n  const idx = cands.findIndex((c) => c.type === String(toType));\n  if (idx > 0) {\n    const cand = cands.splice(idx, 1)[0];\n    cands.unshift(cand);\n    fallbackNotice.value = { nodeId, type: String(toType), prevIndex: idx };\n    focusNode(nodeId);\n    highlightField.value = 'target.candidates';\n    setTimeout(() => (highlightField.value = null), 1500);\n  }\n}\nfunction undoFallbackPromotion() {\n  const n = fallbackNotice.value;\n  if (!n) return;\n  const node = store.nodes.find((x) => x.id === n.nodeId);\n  if (!node || (node.type !== 'click' && node.type !== 'fill')) {\n    fallbackNotice.value = null;\n    return;\n  }\n  const cands = (node as any).config?.target?.candidates as Array<{ type: string; value: string }>;\n  if (!Array.isArray(cands) || cands.length === 0) {\n    fallbackNotice.value = null;\n    return;\n  }\n  const currentIdx = cands.findIndex((c) => c.type === n.type);\n  if (currentIdx >= 0 && n.prevIndex >= 0 && n.prevIndex < cands.length) {\n    const cand = cands.splice(currentIdx, 1)[0];\n    cands.splice(n.prevIndex, 0, cand);\n  }\n  fallbackNotice.value = null;\n}\n\nfunction focusNode(id: string) {\n  store.selectNode(id);\n  focusNodeId.value = id;\n  setTimeout(() => (focusNodeId.value = null), 300);\n}\n// per-node error indicators replace global error panel\n</script>\n\n<style scoped>\n.builder-page {\n  position: relative;\n  width: 100vw;\n  height: 100vh;\n  background: var(--rr-bg);\n  display: flex;\n  flex-direction: column;\n  color: var(--rr-text);\n}\n.topbar {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  height: 52px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 12px;\n  border: none;\n  background: #ededed;\n  z-index: 20;\n  pointer-events: none;\n}\n.topbar > * {\n  pointer-events: auto;\n}\n\n.rr-toast-container {\n  position: fixed;\n  top: 60px;\n  right: 16px;\n  z-index: 1000;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n.rr-toast {\n  min-width: 180px;\n  max-width: 360px;\n  padding: 10px 12px;\n  border-radius: 10px;\n  font-size: 12px;\n  color: #111;\n  background: #fff8e1;\n  border: 1px solid #facc15;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);\n}\n.rr-toast[data-level='info'] {\n  background: #e0f2fe;\n  border-color: #38bdf8;\n}\n.rr-toast[data-level='error'] {\n  background: #fee2e2;\n  border-color: #ef4444;\n}\n.topbar .left {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n.topbar .tip {\n  color: var(--rr-muted);\n  font-size: 12px;\n}\n.topbar .right {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n.main {\n  flex: 1;\n  position: relative;\n  background: var(--rr-bg);\n  overflow: hidden;\n  width: 100%;\n  height: 100%;\n}\n.floating-sidebar {\n  position: absolute;\n  left: 0;\n  top: 36px;\n  z-index: 10;\n  pointer-events: auto;\n}\n.floating-property {\n  position: absolute;\n  right: 0;\n  /* keep below topbar and pinned above bottom */\n  top: 52px;\n  z-index: 10;\n  pointer-events: auto;\n}\n.floating-trigger {\n  position: absolute;\n  right: 400px; /* offset from property panel */\n  top: 52px;\n  z-index: 10;\n  pointer-events: auto;\n}\n.bottom-toolbar {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n  bottom: 20px;\n  display: flex;\n  gap: 4px;\n  align-items: center;\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  border-radius: 12px;\n  padding: 8px 12px;\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);\n  backdrop-filter: blur(8px);\n}\n.toolbar-btn {\n  width: 36px;\n  height: 36px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  background: transparent;\n  color: var(--rr-text-secondary);\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.toolbar-btn:hover {\n  background: var(--rr-hover);\n  color: var(--rr-text);\n}\n.toolbar-btn:active {\n  transform: scale(0.95);\n}\n.toolbar-divider {\n  width: 1px;\n  height: 24px;\n  background: var(--rr-border);\n  margin: 0 4px;\n}\n.top-btn {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 8px 14px;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text);\n  border-radius: 8px;\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.top-btn:hover:not(:disabled) {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n  transform: translateY(-1px);\n  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);\n}\n.top-btn:active:not(:disabled) {\n  transform: translateY(0);\n}\n.top-btn:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n.top-btn.active {\n  background: var(--rr-accent);\n  color: #fff;\n  border-color: var(--rr-accent);\n}\n.top-btn.primary {\n  background: var(--rr-accent);\n  color: #fff;\n  border-color: var(--rr-accent);\n}\n.top-btn.primary:hover {\n  background: #2563eb;\n  border-color: #2563eb;\n}\n.top-btn.success {\n  background: #10b981;\n  color: #fff;\n  border-color: #10b981;\n}\n.top-btn.success:hover {\n  background: #059669;\n  border-color: #059669;\n}\n.top-btn.danger {\n  background: rgba(239, 68, 68, 0.1);\n  color: var(--rr-danger);\n  border-color: rgba(239, 68, 68, 0.3);\n}\n.top-btn.danger:hover {\n  background: rgba(239, 68, 68, 0.2);\n  border-color: var(--rr-danger);\n}\n.top-btn.ghost {\n  border: none;\n  background: transparent;\n}\n.top-btn.ghost:hover {\n  background: var(--rr-hover);\n}\n.top-btn.import {\n  position: relative;\n  overflow: hidden;\n}\n.top-btn.import input {\n  position: absolute;\n  inset: 0;\n  opacity: 0;\n  cursor: pointer;\n}\n.divider-vert {\n  width: 1px;\n  height: 24px;\n  background: var(--rr-border);\n  margin: 0 8px;\n}\n.topbar .status {\n  color: var(--rr-muted);\n  font-size: 12px;\n  margin-right: 8px;\n  min-width: 48px;\n  display: inline-block;\n}\n.btn.import {\n  position: relative;\n  overflow: hidden;\n}\n.btn.import input {\n  position: absolute;\n  inset: 0;\n  opacity: 0;\n  cursor: pointer;\n}\n.notice-top {\n  background: var(--rr-brand-strong);\n  color: #fff;\n  padding: 8px 12px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.notice-top .mini {\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  color: var(--rr-text);\n}\n/* removed legacy error-panel styles */\n\n/* dialog styles (aligned with popup ScheduleDialog) */\n.rr-modal {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.35);\n  z-index: 2147483646;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n.rr-dialog {\n  background: #fff;\n  border-radius: 8px;\n  width: 520px;\n  max-width: 96vw;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n}\n.rr-dialog.small {\n  width: 520px;\n}\n.rr-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 12px 16px;\n  border-bottom: 1px solid #e5e7eb;\n}\n.rr-header .title {\n  font-weight: 600;\n}\n.rr-header .close {\n  border: none;\n  background: #f3f4f6;\n  border-radius: 6px;\n  padding: 4px 8px;\n  cursor: pointer;\n}\n.rr-body {\n  padding: 12px 16px;\n  overflow: auto;\n}\n.rr-footer {\n  padding: 12px 16px;\n  border-top: 1px solid #e5e7eb;\n  display: flex;\n  justify-content: flex-end;\n  gap: 8px;\n}\n.rr-footer .primary {\n  background: #2563eb;\n  color: #fff;\n  border: none;\n  border-radius: 6px;\n  padding: 8px 14px;\n  cursor: pointer;\n}\n.row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n  margin: 6px 0;\n}\n.row > label {\n  width: 88px;\n  color: #374151;\n}\n.row > input,\n.row > textarea {\n  flex: 1;\n  border: 1px solid #d1d5db;\n  border-radius: 6px;\n  padding: 6px 8px;\n}\n.row > textarea {\n  min-height: 64px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/builder/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>工作流编辑器</title>\n    <meta name=\"manifest.type\" content=\"unlisted_page\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/builder/main.ts",
    "content": "import { createApp } from 'vue';\nimport App from './App.vue';\n\n// Tailwind first, then custom tokens\nimport '../styles/tailwind.css';\n\ncreateApp(App).mount('#app');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/content.ts",
    "content": "export default defineContentScript({\n  matches: ['*://*.google.com/*'],\n  main() {},\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/element-picker.content.ts",
    "content": "/**\n * Element Picker Content Script\n *\n * Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events\n * to background while a chrome_request_element_selection session is active.\n *\n * This script only runs in the top frame and handles:\n * - Displaying the element picker panel UI\n * - Forwarding user actions (cancel, confirm, etc.) to background\n * - Receiving state updates from background\n */\n\nimport {\n  createElementPickerController,\n  type ElementPickerController,\n  type ElementPickerUiState,\n} from '@/shared/element-picker';\nimport { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport type { PickedElement } from 'chrome-mcp-shared';\n\n// ============================================================\n// Message Types\n// ============================================================\n\ninterface UiShowMessage {\n  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW;\n  sessionId: string;\n  requests: Array<{ id: string; name: string; description?: string }>;\n  activeRequestId: string | null;\n  deadlineTs: number;\n}\n\ninterface UiUpdateMessage {\n  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE;\n  sessionId: string;\n  activeRequestId: string | null;\n  selections: Record<string, PickedElement | null>;\n  deadlineTs: number;\n  errorMessage: string | null;\n}\n\ninterface UiHideMessage {\n  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE;\n  sessionId: string;\n}\n\ninterface UiPingMessage {\n  action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING;\n}\n\ntype PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage;\n\n// ============================================================\n// Content Script Definition\n// ============================================================\n\nexport default defineContentScript({\n  matches: ['<all_urls>'],\n  runAt: 'document_idle',\n\n  main() {\n    // Only mount UI in the top frame\n    if (window.top !== window) return;\n\n    let controller: ElementPickerController | null = null;\n    let currentSessionId: string | null = null;\n\n    /**\n     * Ensure the controller is created and configured.\n     */\n    function ensureController(): ElementPickerController {\n      if (controller) return controller;\n\n      controller = createElementPickerController({\n        onCancel: () => {\n          if (!currentSessionId) return;\n          void chrome.runtime.sendMessage({\n            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,\n            sessionId: currentSessionId,\n            event: 'cancel',\n          });\n        },\n        onConfirm: () => {\n          if (!currentSessionId) return;\n          void chrome.runtime.sendMessage({\n            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,\n            sessionId: currentSessionId,\n            event: 'confirm',\n          });\n        },\n        onSetActiveRequest: (requestId: string) => {\n          if (!currentSessionId) return;\n          void chrome.runtime.sendMessage({\n            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,\n            sessionId: currentSessionId,\n            event: 'set_active_request',\n            requestId,\n          });\n        },\n        onClearSelection: (requestId: string) => {\n          if (!currentSessionId) return;\n          void chrome.runtime.sendMessage({\n            type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT,\n            sessionId: currentSessionId,\n            event: 'clear_selection',\n            requestId,\n          });\n        },\n      });\n\n      return controller;\n    }\n\n    /**\n     * Handle incoming messages from background.\n     */\n    function handleMessage(\n      message: unknown,\n      _sender: chrome.runtime.MessageSender,\n      sendResponse: (response?: unknown) => void,\n    ): boolean | void {\n      const msg = message as PickerMessage | undefined;\n      if (!msg?.action) return false;\n\n      // Respond to ping (used by background to check if UI script is ready)\n      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) {\n        sendResponse({ success: true });\n        return true;\n      }\n\n      // Show the picker panel\n      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) {\n        const showMsg = msg as UiShowMessage;\n        currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null;\n\n        if (!currentSessionId) {\n          sendResponse({ success: false, error: 'Missing sessionId' });\n          return true;\n        }\n\n        const ctrl = ensureController();\n        const initialState: ElementPickerUiState = {\n          sessionId: currentSessionId,\n          requests: Array.isArray(showMsg.requests) ? showMsg.requests : [],\n          activeRequestId: showMsg.activeRequestId ?? null,\n          selections: {},\n          deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(),\n          errorMessage: null,\n        };\n        ctrl.show(initialState);\n        sendResponse({ success: true });\n        return true;\n      }\n\n      // Update the picker panel state\n      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) {\n        const updateMsg = msg as UiUpdateMessage;\n\n        if (!currentSessionId || updateMsg.sessionId !== currentSessionId) {\n          sendResponse({ success: false, error: 'Session mismatch' });\n          return true;\n        }\n\n        controller?.update({\n          sessionId: currentSessionId,\n          activeRequestId: updateMsg.activeRequestId ?? null,\n          selections: updateMsg.selections || {},\n          deadlineTs: updateMsg.deadlineTs,\n          errorMessage: updateMsg.errorMessage ?? null,\n        });\n        sendResponse({ success: true });\n        return true;\n      }\n\n      // Hide the picker panel\n      if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) {\n        const hideMsg = msg as UiHideMessage;\n\n        // Best-effort hide even if session mismatches\n        if (currentSessionId && hideMsg.sessionId !== currentSessionId) {\n          // Log but don't fail\n          console.warn('[ElementPicker] Session mismatch on hide, hiding anyway');\n        }\n\n        controller?.hide();\n        currentSessionId = null;\n        sendResponse({ success: true });\n        return true;\n      }\n\n      return false;\n    }\n\n    // Register message listener\n    chrome.runtime.onMessage.addListener(handleMessage);\n\n    // Cleanup on page unload\n    window.addEventListener('unload', () => {\n      chrome.runtime.onMessage.removeListener(handleMessage);\n      controller?.dispose();\n      controller = null;\n      currentSessionId = null;\n    });\n  },\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/offscreen/gif-encoder.ts",
    "content": "/**\n * GIF Encoder Module for Offscreen Document\n *\n * Handles GIF encoding using the gifenc library in the offscreen document context.\n * This module provides frame-by-frame GIF encoding with palette quantization.\n */\n\nimport { GIFEncoder, quantize, applyPalette } from 'gifenc';\nimport { MessageTarget, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface GifEncoderState {\n  encoder: ReturnType<typeof GIFEncoder> | null;\n  width: number;\n  height: number;\n  frameCount: number;\n  isInitialized: boolean;\n}\n\ninterface GifAddFrameMessage {\n  target: MessageTarget;\n  type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME;\n  imageData: number[];\n  width: number;\n  height: number;\n  delay: number;\n  maxColors?: number;\n}\n\ninterface GifFinishMessage {\n  target: MessageTarget;\n  type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_FINISH;\n}\n\ninterface GifResetMessage {\n  target: MessageTarget;\n  type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_RESET;\n}\n\ntype GifMessage = GifAddFrameMessage | GifFinishMessage | GifResetMessage;\n\ninterface GifMessageResponse {\n  success: boolean;\n  error?: string;\n  frameCount?: number;\n  gifData?: number[];\n  byteLength?: number;\n}\n\n// ============================================================================\n// State\n// ============================================================================\n\nconst state: GifEncoderState = {\n  encoder: null,\n  width: 0,\n  height: 0,\n  frameCount: 0,\n  isInitialized: false,\n};\n\n// ============================================================================\n// Handlers\n// ============================================================================\n\nfunction initializeEncoder(width: number, height: number): void {\n  state.encoder = GIFEncoder();\n  state.width = width;\n  state.height = height;\n  state.frameCount = 0;\n  state.isInitialized = true;\n}\n\nfunction addFrame(\n  imageData: Uint8ClampedArray,\n  width: number,\n  height: number,\n  delay: number,\n  maxColors: number = 256,\n): void {\n  // Initialize encoder on first frame\n  if (!state.isInitialized || state.width !== width || state.height !== height) {\n    initializeEncoder(width, height);\n  }\n\n  if (!state.encoder) {\n    throw new Error('GIF encoder not initialized');\n  }\n\n  // Quantize colors to create palette\n  const palette = quantize(imageData, maxColors, { format: 'rgb444' });\n\n  // Map pixels to palette indices\n  const indexedPixels = applyPalette(imageData, palette, 'rgb444');\n\n  // Write frame to encoder\n  state.encoder.writeFrame(indexedPixels, width, height, {\n    palette,\n    delay,\n    dispose: 2, // Restore to background color\n  });\n\n  state.frameCount++;\n}\n\nfunction finishEncoding(): Uint8Array {\n  if (!state.encoder) {\n    throw new Error('GIF encoder not initialized');\n  }\n\n  state.encoder.finish();\n  const bytes = state.encoder.bytes();\n\n  // Reset state after finishing\n  resetEncoder();\n\n  return bytes;\n}\n\nfunction resetEncoder(): void {\n  if (state.encoder) {\n    state.encoder.reset();\n  }\n  state.encoder = null;\n  state.width = 0;\n  state.height = 0;\n  state.frameCount = 0;\n  state.isInitialized = false;\n}\n\n// ============================================================================\n// Message Handler\n// ============================================================================\n\nfunction isGifMessage(message: unknown): message is GifMessage {\n  if (!message || typeof message !== 'object') return false;\n  const msg = message as Record<string, unknown>;\n  if (msg.target !== MessageTarget.Offscreen) return false;\n\n  const gifTypes = [\n    OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME,\n    OFFSCREEN_MESSAGE_TYPES.GIF_FINISH,\n    OFFSCREEN_MESSAGE_TYPES.GIF_RESET,\n  ];\n\n  return gifTypes.includes(msg.type as string);\n}\n\nexport function handleGifMessage(\n  message: unknown,\n  sendResponse: (response: GifMessageResponse) => void,\n): boolean {\n  if (!isGifMessage(message)) {\n    return false;\n  }\n\n  try {\n    switch (message.type) {\n      case OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME: {\n        const { imageData, width, height, delay, maxColors } = message;\n        const clampedData = new Uint8ClampedArray(imageData);\n        addFrame(clampedData, width, height, delay, maxColors);\n        sendResponse({\n          success: true,\n          frameCount: state.frameCount,\n        });\n        break;\n      }\n\n      case OFFSCREEN_MESSAGE_TYPES.GIF_FINISH: {\n        const gifBytes = finishEncoding();\n        sendResponse({\n          success: true,\n          gifData: Array.from(gifBytes),\n          byteLength: gifBytes.byteLength,\n        });\n        break;\n      }\n\n      case OFFSCREEN_MESSAGE_TYPES.GIF_RESET: {\n        resetEncoder();\n        sendResponse({ success: true });\n        break;\n      }\n\n      default:\n        sendResponse({ success: false, error: `Unknown GIF message type` });\n    }\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n    console.error('GIF encoder error:', errorMessage);\n    sendResponse({ success: false, error: errorMessage });\n  }\n\n  return true;\n}\n\nconsole.log('GIF encoder module loaded');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/offscreen/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n  </head>\n  <body>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>"
  },
  {
    "path": "app/chrome-extension/entrypoints/offscreen/main.ts",
    "content": "import { SemanticSimilarityEngine } from '@/utils/semantic-similarity-engine';\nimport {\n  MessageTarget,\n  SendMessageType,\n  OFFSCREEN_MESSAGE_TYPES,\n  BACKGROUND_MESSAGE_TYPES,\n} from '@/common/message-types';\nimport { handleGifMessage } from './gif-encoder';\nimport { initKeepalive } from './rr-keepalive';\n\n// 初始化 RR V3 Keepalive\ninitKeepalive();\n\n// Global semantic similarity engine instance\nlet similarityEngine: SemanticSimilarityEngine | null = null;\ninterface OffscreenMessage {\n  target: MessageTarget | string;\n  type: SendMessageType | string;\n}\n\ninterface SimilarityEngineInitMessage extends OffscreenMessage {\n  type: SendMessageType.SimilarityEngineInit;\n  config: any;\n}\n\ninterface SimilarityEngineComputeBatchMessage extends OffscreenMessage {\n  type: SendMessageType.SimilarityEngineComputeBatch;\n  pairs: { text1: string; text2: string }[];\n  options?: Record<string, any>;\n}\n\ninterface SimilarityEngineGetEmbeddingMessage extends OffscreenMessage {\n  type: 'similarityEngineCompute';\n  text: string;\n  options?: Record<string, any>;\n}\n\ninterface SimilarityEngineGetEmbeddingsBatchMessage extends OffscreenMessage {\n  type: 'similarityEngineBatchCompute';\n  texts: string[];\n  options?: Record<string, any>;\n}\n\ninterface SimilarityEngineStatusMessage extends OffscreenMessage {\n  type: 'similarityEngineStatus';\n}\n\ntype MessageResponse = {\n  result?: string;\n  error?: string;\n  success?: boolean;\n  similarities?: number[];\n  embedding?: number[];\n  embeddings?: number[][];\n  isInitialized?: boolean;\n  currentConfig?: any;\n};\n\n// Listen for messages from the extension\nchrome.runtime.onMessage.addListener(\n  (\n    message: OffscreenMessage,\n    _sender: chrome.runtime.MessageSender,\n    sendResponse: (response: MessageResponse) => void,\n  ) => {\n    if (message.target !== MessageTarget.Offscreen) {\n      return;\n    }\n\n    // Handle GIF encoding messages first\n    if (handleGifMessage(message, sendResponse)) {\n      return true;\n    }\n\n    try {\n      switch (message.type) {\n        case SendMessageType.SimilarityEngineInit:\n        case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT: {\n          const initMsg = message as SimilarityEngineInitMessage;\n          console.log('Offscreen: Received similarity engine init message:', message.type);\n          handleSimilarityEngineInit(initMsg.config)\n            .then(() => sendResponse({ success: true }))\n            .catch((error) => sendResponse({ success: false, error: error.message }));\n          break;\n        }\n\n        case SendMessageType.SimilarityEngineComputeBatch: {\n          const computeMsg = message as SimilarityEngineComputeBatchMessage;\n          handleComputeSimilarityBatch(computeMsg.pairs, computeMsg.options)\n            .then((similarities) => sendResponse({ success: true, similarities }))\n            .catch((error) => sendResponse({ success: false, error: error.message }));\n          break;\n        }\n\n        case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_COMPUTE: {\n          const embeddingMsg = message as SimilarityEngineGetEmbeddingMessage;\n          handleGetEmbedding(embeddingMsg.text, embeddingMsg.options)\n            .then((embedding) => {\n              console.log('Offscreen: Sending embedding response:', {\n                length: embedding.length,\n                type: typeof embedding,\n                constructor: embedding.constructor.name,\n                isFloat32Array: embedding instanceof Float32Array,\n                firstFewValues: Array.from(embedding.slice(0, 5)),\n              });\n              const embeddingArray = Array.from(embedding);\n              console.log('Offscreen: Converted to array:', {\n                length: embeddingArray.length,\n                type: typeof embeddingArray,\n                isArray: Array.isArray(embeddingArray),\n                firstFewValues: embeddingArray.slice(0, 5),\n              });\n              sendResponse({ success: true, embedding: embeddingArray });\n            })\n            .catch((error) => sendResponse({ success: false, error: error.message }));\n          break;\n        }\n\n        case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE: {\n          const batchMsg = message as SimilarityEngineGetEmbeddingsBatchMessage;\n          handleGetEmbeddingsBatch(batchMsg.texts, batchMsg.options)\n            .then((embeddings) =>\n              sendResponse({\n                success: true,\n                embeddings: embeddings.map((emb) => Array.from(emb)),\n              }),\n            )\n            .catch((error) => sendResponse({ success: false, error: error.message }));\n          break;\n        }\n\n        case OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_STATUS: {\n          handleGetEngineStatus()\n            .then((status: any) => sendResponse({ success: true, ...status }))\n            .catch((error: any) => sendResponse({ success: false, error: error.message }));\n          break;\n        }\n\n        default:\n          sendResponse({ error: `Unknown message type: ${message.type}` });\n      }\n    } catch (error) {\n      if (error instanceof Error) {\n        sendResponse({ error: error.message });\n      } else {\n        sendResponse({ error: 'Unknown error occurred' });\n      }\n    }\n\n    // Return true to indicate we'll respond asynchronously\n    return true;\n  },\n);\n\n// Global variable to track current model state\nlet currentModelConfig: any = null;\n\n/**\n * Check if engine reinitialization is needed\n */\nfunction needsReinitialization(newConfig: any): boolean {\n  if (!similarityEngine || !currentModelConfig) {\n    return true;\n  }\n\n  // Check if key configuration has changed\n  const keyFields = ['modelPreset', 'modelVersion', 'modelIdentifier', 'dimension'];\n  for (const field of keyFields) {\n    if (newConfig[field] !== currentModelConfig[field]) {\n      console.log(\n        `Offscreen: ${field} changed from ${currentModelConfig[field]} to ${newConfig[field]}`,\n      );\n      return true;\n    }\n  }\n\n  return false;\n}\n\n/**\n * Progress callback function type\n */\ntype ProgressCallback = (progress: { status: string; progress: number; message?: string }) => void;\n\n/**\n * Initialize semantic similarity engine\n */\nasync function handleSimilarityEngineInit(config: any): Promise<void> {\n  console.log('Offscreen: Initializing semantic similarity engine with config:', config);\n  console.log('Offscreen: Config useLocalFiles:', config.useLocalFiles);\n  console.log('Offscreen: Config modelPreset:', config.modelPreset);\n  console.log('Offscreen: Config modelVersion:', config.modelVersion);\n  console.log('Offscreen: Config modelDimension:', config.modelDimension);\n  console.log('Offscreen: Config modelIdentifier:', config.modelIdentifier);\n\n  // Check if reinitialization is needed\n  const needsReinit = needsReinitialization(config);\n  console.log('Offscreen: Needs reinitialization:', needsReinit);\n\n  if (!needsReinit) {\n    console.log('Offscreen: Using existing engine (no changes detected)');\n    await updateModelStatus('ready', 100);\n    return;\n  }\n\n  // If engine already exists, clean up old instance first (support model switching)\n  if (similarityEngine) {\n    console.log('Offscreen: Cleaning up existing engine for model switch...');\n    try {\n      // Properly call dispose method to clean up all resources\n      await similarityEngine.dispose();\n      console.log('Offscreen: Previous engine disposed successfully');\n    } catch (error) {\n      console.warn('Offscreen: Failed to dispose previous engine:', error);\n    }\n    similarityEngine = null;\n    currentModelConfig = null;\n\n    // Clear vector data in IndexedDB to ensure data consistency\n    try {\n      console.log('Offscreen: Clearing IndexedDB vector data for model switch...');\n      await clearVectorIndexedDB();\n      console.log('Offscreen: IndexedDB vector data cleared successfully');\n    } catch (error) {\n      console.warn('Offscreen: Failed to clear IndexedDB vector data:', error);\n    }\n  }\n\n  try {\n    // Update status to initializing\n    await updateModelStatus('initializing', 10);\n\n    // Create progress callback function\n    const progressCallback: ProgressCallback = async (progress) => {\n      console.log('Offscreen: Progress update:', progress);\n      await updateModelStatus(progress.status, progress.progress);\n    };\n\n    // Create engine instance and pass progress callback\n    similarityEngine = new SemanticSimilarityEngine(config);\n    console.log('Offscreen: Starting engine initialization with progress tracking...');\n\n    // Use enhanced initialization method (if progress callback is supported)\n    if (typeof (similarityEngine as any).initializeWithProgress === 'function') {\n      await (similarityEngine as any).initializeWithProgress(progressCallback);\n    } else {\n      // Fallback to standard initialization method\n      console.log('Offscreen: Using standard initialization (no progress callback support)');\n      await updateModelStatus('downloading', 30);\n      await similarityEngine.initialize();\n      await updateModelStatus('ready', 100);\n    }\n\n    // Save current configuration\n    currentModelConfig = { ...config };\n\n    console.log('Offscreen: Semantic similarity engine initialized successfully');\n  } catch (error) {\n    console.error('Offscreen: Failed to initialize semantic similarity engine:', error);\n    // Update status to error\n    const errorMessage = error instanceof Error ? error.message : 'Unknown initialization error';\n    const errorType = analyzeErrorType(errorMessage);\n    await updateModelStatus('error', 0, errorMessage, errorType);\n    // Clean up failed instance\n    similarityEngine = null;\n    currentModelConfig = null;\n    throw error;\n  }\n}\n\n/**\n * Clear vector data in IndexedDB\n */\nasync function clearVectorIndexedDB(): Promise<void> {\n  try {\n    // Clear vector search related IndexedDB databases\n    const dbNames = ['VectorSearchDB', 'ContentIndexerDB', 'SemanticSimilarityDB'];\n\n    for (const dbName of dbNames) {\n      try {\n        // Try to delete database\n        const deleteRequest = indexedDB.deleteDatabase(dbName);\n        await new Promise<void>((resolve, _reject) => {\n          deleteRequest.onsuccess = () => {\n            console.log(`Offscreen: Successfully deleted database: ${dbName}`);\n            resolve();\n          };\n          deleteRequest.onerror = () => {\n            console.warn(`Offscreen: Failed to delete database: ${dbName}`, deleteRequest.error);\n            resolve(); // 不阻塞其他数据库的清理\n          };\n          deleteRequest.onblocked = () => {\n            console.warn(`Offscreen: Database deletion blocked: ${dbName}`);\n            resolve(); // 不阻塞其他数据库的清理\n          };\n        });\n      } catch (error) {\n        console.warn(`Offscreen: Error deleting database ${dbName}:`, error);\n      }\n    }\n  } catch (error) {\n    console.error('Offscreen: Failed to clear vector IndexedDB:', error);\n    throw error;\n  }\n}\n\n// Analyze error type\nfunction analyzeErrorType(errorMessage: string): 'network' | 'file' | 'unknown' {\n  const message = errorMessage.toLowerCase();\n\n  if (\n    message.includes('network') ||\n    message.includes('fetch') ||\n    message.includes('timeout') ||\n    message.includes('connection') ||\n    message.includes('cors') ||\n    message.includes('failed to fetch')\n  ) {\n    return 'network';\n  }\n\n  if (\n    message.includes('corrupt') ||\n    message.includes('invalid') ||\n    message.includes('format') ||\n    message.includes('parse') ||\n    message.includes('decode') ||\n    message.includes('onnx')\n  ) {\n    return 'file';\n  }\n\n  return 'unknown';\n}\n\n// Helper function to update model status\nasync function updateModelStatus(\n  status: string,\n  progress: number,\n  errorMessage?: string,\n  errorType?: string,\n) {\n  try {\n    const modelState = {\n      status,\n      downloadProgress: progress,\n      isDownloading: status === 'downloading' || status === 'initializing',\n      lastUpdated: Date.now(),\n      errorMessage: errorMessage || '',\n      errorType: errorType || '',\n    };\n\n    // In offscreen document, update storage through message passing to background script\n    // because offscreen document may not have direct chrome.storage access\n    if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {\n      await chrome.storage.local.set({ modelState });\n    } else {\n      // If chrome.storage is not available, pass message to background script\n      console.log('Offscreen: chrome.storage not available, sending message to background');\n      try {\n        await chrome.runtime.sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.UPDATE_MODEL_STATUS,\n          modelState: modelState,\n        });\n      } catch (messageError) {\n        console.error('Offscreen: Failed to send status update message:', messageError);\n      }\n    }\n  } catch (error) {\n    console.error('Offscreen: Failed to update model status:', error);\n  }\n}\n\n/**\n * Batch compute semantic similarity\n */\nasync function handleComputeSimilarityBatch(\n  pairs: { text1: string; text2: string }[],\n  options: Record<string, any> = {},\n): Promise<number[]> {\n  if (!similarityEngine) {\n    throw new Error('Similarity engine not initialized. Please reinitialize the engine.');\n  }\n\n  console.log(`Offscreen: Computing similarities for ${pairs.length} pairs`);\n  const similarities = await similarityEngine.computeSimilarityBatch(pairs, options);\n  console.log('Offscreen: Similarity computation completed');\n\n  return similarities;\n}\n\n/**\n * Get embedding vector for single text\n */\nasync function handleGetEmbedding(\n  text: string,\n  options: Record<string, any> = {},\n): Promise<Float32Array> {\n  if (!similarityEngine) {\n    throw new Error('Similarity engine not initialized. Please reinitialize the engine.');\n  }\n\n  console.log(`Offscreen: Getting embedding for text: \"${text.substring(0, 50)}...\"`);\n  const embedding = await similarityEngine.getEmbedding(text, options);\n  console.log('Offscreen: Embedding computation completed');\n\n  return embedding;\n}\n\n/**\n * Batch get embedding vectors for texts\n */\nasync function handleGetEmbeddingsBatch(\n  texts: string[],\n  options: Record<string, any> = {},\n): Promise<Float32Array[]> {\n  if (!similarityEngine) {\n    throw new Error('Similarity engine not initialized. Please reinitialize the engine.');\n  }\n\n  console.log(`Offscreen: Getting embeddings for ${texts.length} texts`);\n  const embeddings = await similarityEngine.getEmbeddingsBatch(texts, options);\n  console.log('Offscreen: Batch embedding computation completed');\n\n  return embeddings;\n}\n\n/**\n * Get engine status\n */\nasync function handleGetEngineStatus(): Promise<{\n  isInitialized: boolean;\n  currentConfig: any;\n}> {\n  return {\n    isInitialized: !!similarityEngine,\n    currentConfig: currentModelConfig,\n  };\n}\n\nconsole.log('Offscreen: Semantic similarity engine handler loaded');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/offscreen/rr-keepalive.ts",
    "content": "/**\n * @fileoverview Offscreen Keepalive\n * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat.\n *\n * Architecture:\n * - Offscreen connects to Background (Service Worker) via a named Port.\n * - Offscreen sends periodic `keepalive.ping` messages while keepalive is enabled.\n * - Background replies with `keepalive.pong` to confirm the channel is alive.\n *\n * Contract:\n * - After `stop`, keepalive must fully stop: no ping loop, no Port, and no reconnection attempts.\n * - After `start`, keepalive must (re)connect if needed and resume the ping loop.\n */\n\nimport {\n  RR_V3_KEEPALIVE_PORT_NAME,\n  DEFAULT_KEEPALIVE_PING_INTERVAL_MS,\n  type KeepaliveMessage,\n} from '@/common/rr-v3-keepalive-protocol';\n\n// ==================== Runtime Control Protocol ====================\n\nconst KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const;\n\ntype KeepaliveControlCommand = 'start' | 'stop';\n\ninterface KeepaliveControlMessage {\n  type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE;\n  command: KeepaliveControlCommand;\n}\n\nfunction isKeepaliveControlMessage(value: unknown): value is KeepaliveControlMessage {\n  if (!value || typeof value !== 'object') return false;\n  const v = value as Record<string, unknown>;\n  if (v.type !== KEEPALIVE_CONTROL_MESSAGE_TYPE) return false;\n  return v.command === 'start' || v.command === 'stop';\n}\n\n// ==================== State ====================\n\nlet initialized = false;\nlet keepalivePort: chrome.runtime.Port | null = null;\nlet pingTimer: ReturnType<typeof setInterval> | null = null;\n/** Whether keepalive is desired (set by start/stop commands from Background) */\nlet keepaliveDesired = false;\nlet reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n// ==================== Type Guards ====================\n\n/**\n * Type guard for KeepaliveMessage.\n */\nfunction isKeepaliveMessage(value: unknown): value is KeepaliveMessage {\n  if (!value || typeof value !== 'object') return false;\n  const v = value as Record<string, unknown>;\n\n  const type = v.type;\n  if (\n    type !== 'keepalive.ping' &&\n    type !== 'keepalive.pong' &&\n    type !== 'keepalive.start' &&\n    type !== 'keepalive.stop'\n  ) {\n    return false;\n  }\n\n  return typeof v.timestamp === 'number' && Number.isFinite(v.timestamp);\n}\n\n// ==================== Port Management ====================\n\n/**\n * Schedule a reconnect attempt to maintain the Port connection.\n * Only reconnect while keepalive is desired.\n */\nfunction scheduleReconnect(delayMs = 1000): void {\n  if (!initialized) return;\n  if (!keepaliveDesired) return;\n  if (reconnectTimer) return;\n\n  reconnectTimer = setTimeout(() => {\n    reconnectTimer = null;\n    if (!initialized) return;\n    if (!keepaliveDesired) return;\n    if (!keepalivePort) {\n      console.log('[rr-keepalive] Attempting scheduled reconnect...');\n      keepalivePort = connectToBackground();\n    }\n  }, delayMs);\n}\n\n/**\n * Create a Port connection to Background.\n */\nfunction connectToBackground(): chrome.runtime.Port | null {\n  if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {\n    console.warn('[rr-keepalive] chrome.runtime.connect not available');\n    return null;\n  }\n\n  try {\n    const port = chrome.runtime.connect({ name: RR_V3_KEEPALIVE_PORT_NAME });\n\n    port.onMessage.addListener((msg: unknown) => {\n      if (!isKeepaliveMessage(msg)) return;\n\n      if (msg.type === 'keepalive.start') {\n        console.log('[rr-keepalive] Received start command via Port');\n        startPingLoop();\n      } else if (msg.type === 'keepalive.stop') {\n        console.log('[rr-keepalive] Received stop command via Port');\n        stopPingLoop();\n      } else if (msg.type === 'keepalive.pong') {\n        // Background replied to our ping.\n        console.debug('[rr-keepalive] Received pong');\n      }\n    });\n\n    port.onDisconnect.addListener(() => {\n      console.log('[rr-keepalive] Port disconnected');\n      keepalivePort = null;\n      // Only reconnect if keepalive is still desired.\n      scheduleReconnect(1000);\n    });\n\n    console.log('[rr-keepalive] Connected to background');\n    return port;\n  } catch (e) {\n    console.warn('[rr-keepalive] Failed to connect:', e);\n    return null;\n  }\n}\n\n// ==================== Ping Loop ====================\n\n/**\n * Send a ping message to Background.\n */\nfunction sendPing(): void {\n  if (!keepalivePort) {\n    keepalivePort = connectToBackground();\n  }\n\n  if (!keepalivePort) return;\n\n  const msg: KeepaliveMessage = {\n    type: 'keepalive.ping',\n    timestamp: Date.now(),\n  };\n\n  try {\n    keepalivePort.postMessage(msg);\n    console.debug('[rr-keepalive] Sent ping');\n  } catch (e) {\n    console.warn('[rr-keepalive] Failed to send ping:', e);\n    keepalivePort = null;\n    scheduleReconnect(1000);\n  }\n}\n\n/**\n * Start the ping loop.\n */\nfunction startPingLoop(): void {\n  if (pingTimer) return;\n\n  keepaliveDesired = true;\n\n  // Ensure we have a Port connection.\n  if (!keepalivePort) {\n    keepalivePort = connectToBackground();\n  }\n\n  // Send one ping immediately.\n  sendPing();\n\n  // Start the interval timer.\n  pingTimer = setInterval(() => {\n    sendPing();\n  }, DEFAULT_KEEPALIVE_PING_INTERVAL_MS);\n\n  console.log(\n    `[rr-keepalive] Ping loop started (interval=${DEFAULT_KEEPALIVE_PING_INTERVAL_MS}ms)`,\n  );\n}\n\n/**\n * Stop the ping loop.\n * This must fully stop keepalive: no timer, no Port, and no reconnection attempts.\n */\nfunction stopPingLoop(): void {\n  keepaliveDesired = false;\n\n  if (pingTimer) {\n    clearInterval(pingTimer);\n    pingTimer = null;\n  }\n\n  if (reconnectTimer) {\n    clearTimeout(reconnectTimer);\n    reconnectTimer = null;\n  }\n\n  // Disconnect the Port to fully stop keepalive.\n  if (keepalivePort) {\n    try {\n      keepalivePort.disconnect();\n    } catch {\n      // Ignore\n    }\n    keepalivePort = null;\n  }\n\n  console.log('[rr-keepalive] Ping loop stopped');\n}\n\n// ==================== Public API ====================\n\n/**\n * Initialize keepalive control handlers.\n * @description Registers the runtime control listener and waits for start/stop commands.\n */\nexport function initKeepalive(): void {\n  if (initialized) return;\n  initialized = true;\n\n  // Check Chrome API availability.\n  if (typeof chrome === 'undefined' || !chrome.runtime?.onMessage) {\n    console.warn('[rr-keepalive] chrome.runtime.onMessage not available');\n    return;\n  }\n\n  // Listen for runtime control messages from Background.\n  // This allows Background to send start/stop even when Port is not connected.\n  chrome.runtime.onMessage.addListener((msg: unknown, _sender, sendResponse) => {\n    if (!isKeepaliveControlMessage(msg)) return;\n\n    if (msg.command === 'start') {\n      console.log('[rr-keepalive] Received runtime start command');\n      startPingLoop();\n    } else {\n      console.log('[rr-keepalive] Received runtime stop command');\n      stopPingLoop();\n    }\n\n    try {\n      sendResponse({ ok: true });\n    } catch {\n      // Ignore\n    }\n  });\n\n  // Also establish initial Port connection for backwards compatibility.\n  if (chrome.runtime?.connect) {\n    keepalivePort = connectToBackground();\n  }\n\n  console.log('[rr-keepalive] Keepalive initialized');\n}\n\n/**\n * Check whether keepalive is active.\n */\nexport function isKeepaliveActive(): boolean {\n  return keepaliveDesired && pingTimer !== null && keepalivePort !== null;\n}\n\n/**\n * Get the active port count (for debugging).\n * @deprecated Use isKeepaliveActive() instead\n */\nexport function getActivePortCount(): number {\n  return keepalivePort ? 1 : 0;\n}\n\n// Re-export for backwards compatibility\nexport {\n  RR_V3_KEEPALIVE_PORT_NAME,\n  type KeepaliveMessage,\n} from '@/common/rr-v3-keepalive-protocol';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/options/App.vue",
    "content": "<template>\n  <div class=\"page\">\n    <header class=\"topbar\">\n      <h1>{{ m('userscriptsManagerTitle') }}</h1>\n      <div class=\"switch\">\n        <label>\n          <input type=\"checkbox\" v-model=\"emergencyDisabled\" @change=\"saveEmergency\" />\n          <span>{{ m('emergencySwitchLabel') }}</span>\n        </label>\n      </div>\n    </header>\n\n    <section class=\"create\">\n      <h2>{{ m('createRunSectionTitle') }}</h2>\n      <div class=\"grid\">\n        <label>\n          {{ m('nameLabel') }}\n          <input v-model=\"form.name\" :placeholder=\"m('placeholderOptional')\" />\n        </label>\n        <label>\n          {{ m('runAtLabel') }}\n          <select v-model=\"form.runAt\">\n            <option value=\"auto\">{{ m('runAtAuto') }}</option>\n            <option value=\"document_start\">{{ m('runAtDocumentStart') }}</option>\n            <option value=\"document_end\">{{ m('runAtDocumentEnd') }}</option>\n            <option value=\"document_idle\">{{ m('runAtDocumentIdle') }}</option>\n          </select>\n        </label>\n        <label>\n          {{ m('worldLabel') }}\n          <select v-model=\"form.world\">\n            <option value=\"auto\">{{ m('worldAuto') }}</option>\n            <option value=\"ISOLATED\">{{ m('worldIsolated') }}</option>\n            <option value=\"MAIN\">{{ m('worldMain') }}</option>\n          </select>\n        </label>\n        <label>\n          {{ m('modeLabel') }}\n          <select v-model=\"form.mode\">\n            <option value=\"auto\">{{ m('modeAuto') }}</option>\n            <option value=\"persistent\">{{ m('modePersistent') }}</option>\n            <option value=\"css\">{{ m('modeCss') }}</option>\n            <option value=\"once\">{{ m('modeOnce') }}</option>\n          </select>\n        </label>\n        <label>\n          {{ m('allFramesLabel') }}\n          <input type=\"checkbox\" v-model=\"form.allFrames\" />\n        </label>\n        <label>\n          {{ m('persistLabel') }}\n          <input type=\"checkbox\" v-model=\"form.persist\" />\n        </label>\n        <label>\n          {{ m('dnrFallbackLabel') }}\n          <input type=\"checkbox\" v-model=\"form.dnrFallback\" />\n        </label>\n      </div>\n      <label>\n        {{ m('matchesInputLabel') }}\n        <input v-model=\"form.matches\" :placeholder=\"m('placeholderMatchesExample')\" />\n      </label>\n      <label>\n        {{ m('excludesInputLabel') }}\n        <input v-model=\"form.excludes\" :placeholder=\"m('placeholderOptional')\" />\n      </label>\n      <label>\n        {{ m('tagsInputLabel') }}\n        <input v-model=\"form.tags\" :placeholder=\"m('placeholderOptional')\" />\n      </label>\n      <label>\n        {{ m('scriptLabel') }}\n        <textarea v-model=\"form.script\" :placeholder=\"m('placeholderScriptHint')\" rows=\"8\" />\n      </label>\n      <div class=\"row\">\n        <button :disabled=\"submitting\" @click=\"apply('auto')\">{{ m('applyButton') }}</button>\n        <button :disabled=\"submitting\" @click=\"apply('once')\">{{ m('runOnceButton') }}</button>\n        <span class=\"hint\" v-if=\"lastResult\">{{ lastResult }}</span>\n      </div>\n    </section>\n\n    <section class=\"filters\">\n      <h2>{{ m('listSectionTitle') }}</h2>\n      <div class=\"grid\">\n        <label>\n          {{ m('queryLabel') }}\n          <input v-model=\"filters.query\" @input=\"reload()\" />\n        </label>\n        <label>\n          {{ m('statusLabel') }}\n          <select v-model=\"filters.status\" @change=\"reload()\">\n            <option value=\"\">{{ m('statusAll') }}</option>\n            <option value=\"enabled\">{{ m('statusEnabled') }}</option>\n            <option value=\"disabled\">{{ m('statusDisabled') }}</option>\n          </select>\n        </label>\n        <label>\n          {{ m('domainLabel') }}\n          <input\n            v-model=\"filters.domain\"\n            @input=\"reload()\"\n            :placeholder=\"m('placeholderDomainHint')\"\n          />\n        </label>\n      </div>\n      <div class=\"row\">\n        <button @click=\"exportAll\">{{ m('exportAllButton') }}</button>\n      </div>\n      <table class=\"table\">\n        <thead>\n          <tr>\n            <th>{{ m('tableHeaderName') }}</th>\n            <th>{{ m('statusLabel') }}</th>\n            <th>{{ m('tableHeaderWorld') }}</th>\n            <th>{{ m('tableHeaderRunAt') }}</th>\n            <th>{{ m('tableHeaderUpdated') }}</th>\n            <th></th>\n          </tr>\n        </thead>\n        <tbody>\n          <tr v-for=\"it in items\" :key=\"it.id\">\n            <td>{{ it.name || it.id }}</td>\n            <td>\n              <label>\n                <input type=\"checkbox\" :checked=\"it.status === 'enabled'\" @change=\"toggle(it)\" />\n                {{ it.status }}\n              </label>\n            </td>\n            <td>{{ it.world }}</td>\n            <td>{{ it.runAt }}</td>\n            <td>{{ formatTime(it.updatedAt) }}</td>\n            <td class=\"actions\">\n              <button @click=\"remove(it)\">{{ m('deleteButton') }}</button>\n            </td>\n          </tr>\n        </tbody>\n      </table>\n    </section>\n\n    <!-- Flow Editor removed: unified to Builder in Popup -->\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { STORAGE_KEYS } from '@/common/constants';\n\ntype ListItem = {\n  id: string;\n  name?: string;\n  status: 'enabled' | 'disabled';\n  world: 'ISOLATED' | 'MAIN';\n  runAt: 'document_start' | 'document_end' | 'document_idle';\n  updatedAt: number;\n};\n\nconst emergencyDisabled = ref(false);\nconst items = ref<ListItem[]>([]);\nconst filters = ref({ query: '', status: '', domain: '' });\n\nconst form = ref({\n  name: '',\n  runAt: 'auto',\n  world: 'auto',\n  mode: 'auto',\n  allFrames: true,\n  persist: true,\n  dnrFallback: true,\n  script: '',\n  matches: '',\n  excludes: '',\n  tags: '',\n});\n\nconst submitting = ref(false);\nconst lastResult = ref('');\n\nfunction formatTime(ts?: number) {\n  if (!ts) return '';\n  try {\n    return new Date(ts).toLocaleString();\n  } catch {\n    return String(ts);\n  }\n}\n\nasync function saveEmergency() {\n  await globalThis.chrome?.storage?.local.set({\n    [STORAGE_KEYS.USERSCRIPTS_DISABLED]: emergencyDisabled.value,\n  });\n}\n\nasync function loadEmergency() {\n  const v = await globalThis.chrome?.storage?.local.get([STORAGE_KEYS.USERSCRIPTS_DISABLED] as any);\n  emergencyDisabled.value = !!v[STORAGE_KEYS.USERSCRIPTS_DISABLED];\n}\n\nasync function callTool(name: string, args: any) {\n  const res = await globalThis.chrome?.runtime?.sendMessage({\n    type: 'call_tool',\n    name,\n    args,\n  } as any);\n  if (!res || !res.success) throw new Error(res?.error || 'call failed');\n  return res.result;\n}\n\nasync function reload() {\n  const result = await callTool(TOOL_NAMES.BROWSER.USERSCRIPT, {\n    action: 'list',\n    args: { ...filters.value },\n  });\n  try {\n    const txt = (result?.content?.[0]?.text as string) || '{}';\n    const data = JSON.parse(txt);\n    items.value = data.items || [];\n  } catch (e) {\n    console.warn('parse list failed', e);\n  }\n}\n\nasync function apply(mode: 'auto' | 'once') {\n  if (!form.value.script.trim()) return;\n  submitting.value = true;\n  lastResult.value = '';\n  try {\n    const args: any = {\n      script: form.value.script,\n      name: form.value.name || undefined,\n      runAt: form.value.runAt as any,\n      world: form.value.world as any,\n      allFrames: !!form.value.allFrames,\n      persist: !!form.value.persist,\n      dnrFallback: !!form.value.dnrFallback,\n      mode,\n    };\n    if (form.value.matches.trim())\n      args.matches = form.value.matches.split(',').map((s) => s.trim());\n    if (form.value.excludes.trim())\n      args.excludes = form.value.excludes.split(',').map((s) => s.trim());\n    if (form.value.tags.trim()) args.tags = form.value.tags.split(',').map((s) => s.trim());\n\n    const result = await callTool(TOOL_NAMES.BROWSER.USERSCRIPT, { action: 'create', args });\n    lastResult.value = (result?.content?.[0]?.text as string) || '';\n    await reload();\n  } catch (e: any) {\n    lastResult.value = 'Error: ' + (e?.message || String(e));\n  } finally {\n    submitting.value = false;\n  }\n}\n\nasync function toggle(it: ListItem) {\n  try {\n    await callTool(TOOL_NAMES.BROWSER.USERSCRIPT, {\n      action: it.status === 'enabled' ? 'disable' : 'enable',\n      args: { id: it.id },\n    });\n    await reload();\n  } catch (e) {\n    console.warn('toggle failed', e);\n  }\n}\n\nasync function remove(it: ListItem) {\n  try {\n    await callTool(TOOL_NAMES.BROWSER.USERSCRIPT, { action: 'remove', args: { id: it.id } });\n    await reload();\n  } catch (e) {\n    console.warn('remove failed', e);\n  }\n}\n\nasync function exportAll() {\n  try {\n    const res = await callTool(TOOL_NAMES.BROWSER.USERSCRIPT, { action: 'export', args: {} });\n    const txt = (res?.content?.[0]?.text as string) || '{}';\n    const blob = new Blob([txt], { type: 'application/json' });\n    const url = URL.createObjectURL(blob);\n    await globalThis.chrome?.downloads?.download({\n      url,\n      filename: 'userscripts-export.json',\n      saveAs: true,\n    } as any);\n    URL.revokeObjectURL(url);\n  } catch (e) {\n    console.warn('export failed', e);\n  }\n}\n\nonMounted(async () => {\n  await loadEmergency();\n  await reload();\n});\n\nfunction m(key: string, substitutions?: string | string[]) {\n  const msg = (globalThis.chrome?.i18n?.getMessage(key, substitutions as any) || '').trim();\n  return msg || key;\n}\n</script>\n\n<style scoped>\n.page {\n  font-family:\n    -apple-system,\n    BlinkMacSystemFont,\n    Segoe UI,\n    Roboto,\n    sans-serif;\n  padding: 16px;\n}\n.topbar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 12px;\n}\n.create,\n.filters {\n  background: #fff;\n  border: 1px solid #e5e7eb;\n  border-radius: 12px;\n  padding: 12px;\n  margin-bottom: 16px;\n}\n.grid {\n  display: grid;\n  grid-template-columns: repeat(3, minmax(0, 1fr));\n  gap: 8px;\n}\nlabel {\n  display: flex;\n  flex-direction: column;\n  font-size: 12px;\n  gap: 4px;\n}\ninput,\nselect,\ntextarea {\n  border: 1px solid #d1d5db;\n  border-radius: 6px;\n  padding: 8px;\n  font-size: 12px;\n}\ntextarea {\n  resize: vertical;\n}\n.row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-top: 8px;\n}\nbutton {\n  background: #3b82f6;\n  color: #fff;\n  border: none;\n  padding: 8px 12px;\n  border-radius: 8px;\n  cursor: pointer;\n}\nbutton:hover {\n  background: #2563eb;\n}\n.hint {\n  color: #374151;\n  font-size: 12px;\n}\n.table {\n  width: 100%;\n  border-collapse: collapse;\n  margin-top: 8px;\n}\n.table th,\n.table td {\n  border-bottom: 1px solid #e5e7eb;\n  text-align: left;\n  padding: 8px;\n  font-size: 12px;\n}\n.actions {\n  text-align: right;\n}\n.switch input {\n  margin-right: 6px;\n}\n@media (max-width: 960px) {\n  .grid {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n}\n@media (max-width: 640px) {\n  .grid {\n    grid-template-columns: 1fr;\n  }\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/options/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Userscripts Manager</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/options/main.ts",
    "content": "import { createApp } from 'vue';\nimport App from './App.vue';\n\ncreateApp(App).mount('#app');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/App.vue",
    "content": "<template>\n  <div class=\"popup-container agent-theme\" :data-agent-theme=\"agentTheme\">\n    <!-- 首页 -->\n    <div v-show=\"currentView === 'home'\" class=\"home-view\">\n      <div class=\"header\">\n        <div class=\"header-content\">\n          <h1 class=\"header-title\">Chrome MCP Server</h1>\n        </div>\n      </div>\n      <div class=\"content\">\n        <!-- 服务配置卡片 -->\n        <div class=\"section\">\n          <h2 class=\"section-title\">{{ getMessage('nativeServerConfigLabel') }}</h2>\n          <div class=\"config-card\">\n            <div class=\"status-section\">\n              <div class=\"status-header\">\n                <p class=\"status-label\">{{ getMessage('runningStatusLabel') }}</p>\n                <button\n                  class=\"refresh-status-button\"\n                  @click=\"refreshServerStatus\"\n                  :title=\"getMessage('refreshStatusButton')\"\n                >\n                  <RefreshIcon className=\"icon-small\" />\n                </button>\n              </div>\n              <div class=\"status-info\">\n                <span :class=\"['status-dot', getStatusClass()]\"></span>\n                <span class=\"status-text\">{{ getStatusText() }}</span>\n              </div>\n              <div v-if=\"serverStatus.lastUpdated\" class=\"status-timestamp\">\n                {{ getMessage('lastUpdatedLabel') }}\n                {{ new Date(serverStatus.lastUpdated).toLocaleTimeString() }}\n              </div>\n            </div>\n\n            <div v-if=\"showMcpConfig\" class=\"mcp-config-section\">\n              <div class=\"mcp-config-header\">\n                <p class=\"mcp-config-label\">{{ getMessage('mcpServerConfigLabel') }}</p>\n                <button class=\"copy-config-button\" @click=\"copyMcpConfig\">\n                  {{ copyButtonText }}\n                </button>\n              </div>\n              <div class=\"mcp-config-content\">\n                <pre class=\"mcp-config-json\">{{ mcpConfigJson }}</pre>\n              </div>\n            </div>\n            <div class=\"port-section\">\n              <label for=\"port\" class=\"port-label\">{{ getMessage('connectionPortLabel') }}</label>\n              <input\n                type=\"text\"\n                id=\"port\"\n                :value=\"nativeServerPort\"\n                @input=\"updatePort\"\n                class=\"port-input\"\n              />\n            </div>\n\n            <button class=\"connect-button\" :disabled=\"isConnecting\" @click=\"testNativeConnection\">\n              <BoltIcon />\n              <span>{{\n                isConnecting\n                  ? getMessage('connectingStatus')\n                  : nativeConnectionStatus === 'connected'\n                    ? getMessage('disconnectButton')\n                    : getMessage('connectButton')\n              }}</span>\n            </button>\n          </div>\n        </div>\n\n        <!-- 快捷工具卡片 -->\n        <div class=\"section\">\n          <h2 class=\"section-title\">快捷工具</h2>\n          <div class=\"rr-icon-buttons\">\n            <button\n              class=\"rr-icon-btn rr-icon-btn-record rr-icon-btn-coming-soon has-tooltip\"\n              @click=\"startRecording\"\n              data-tooltip=\"录制功能开发中\"\n            >\n              <RecordIcon :recording=\"false\" />\n            </button>\n            <button\n              class=\"rr-icon-btn rr-icon-btn-stop rr-icon-btn-coming-soon has-tooltip\"\n              @click=\"stopRecording\"\n              data-tooltip=\"录制功能开发中\"\n            >\n              <StopIcon />\n            </button>\n            <button\n              class=\"rr-icon-btn rr-icon-btn-edit has-tooltip\"\n              @click=\"toggleWebEditor\"\n              data-tooltip=\"开启页面编辑模式\"\n            >\n              <EditIcon />\n            </button>\n            <button\n              class=\"rr-icon-btn rr-icon-btn-marker has-tooltip\"\n              @click=\"toggleElementMarker\"\n              data-tooltip=\"开启元素标注\"\n            >\n              <MarkerIcon />\n            </button>\n          </div>\n        </div>\n\n        <!-- 管理入口卡片 -->\n        <div class=\"section\">\n          <h2 class=\"section-title\">管理入口</h2>\n          <div class=\"entry-card\">\n            <button class=\"entry-item\" @click=\"openAgentSidepanel\">\n              <div class=\"entry-icon agent\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"entry-content\">\n                <span class=\"entry-title\">智能助手</span>\n                <span class=\"entry-desc\">AI Agent 对话与任务</span>\n              </div>\n              <svg\n                class=\"entry-arrow\"\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n            </button>\n            <button class=\"entry-item entry-item-coming-soon\" @click=\"openWorkflowSidepanel\">\n              <div class=\"entry-icon workflow\">\n                <WorkflowIcon />\n              </div>\n              <div class=\"entry-content\">\n                <span class=\"entry-title\">\n                  工作流管理\n                  <span class=\"coming-soon-badge\">Coming Soon</span>\n                </span>\n                <span class=\"entry-desc\">录制与回放自动化流程</span>\n              </div>\n              <svg\n                class=\"entry-arrow\"\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n            </button>\n            <button class=\"entry-item\" @click=\"openElementMarkerSidepanel\">\n              <div class=\"entry-icon marker\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"entry-content\">\n                <span class=\"entry-title\">元素标注管理</span>\n                <span class=\"entry-desc\">管理页面元素标注</span>\n              </div>\n              <svg\n                class=\"entry-arrow\"\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n            </button>\n            <button class=\"entry-item\" @click=\"currentView = 'local-model'\">\n              <div class=\"entry-icon model\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"entry-content\">\n                <span class=\"entry-title\">本地模型</span>\n                <span class=\"entry-desc\">语义引擎与模型管理</span>\n              </div>\n              <svg\n                class=\"entry-arrow\"\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"footer\">\n        <div class=\"footer-links\">\n          <button class=\"footer-link\" @click=\"openWelcomePage\" title=\"View installation guide\">\n            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n              />\n            </svg>\n            Guide\n          </button>\n          <button class=\"footer-link\" @click=\"openTroubleshooting\" title=\"Troubleshooting\">\n            <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253\"\n              />\n            </svg>\n            Docs\n          </button>\n        </div>\n        <p class=\"footer-text\">chrome mcp server for ai</p>\n      </div>\n    </div>\n\n    <!-- 本地模型二级页面 -->\n    <LocalModelPage\n      v-show=\"currentView === 'local-model'\"\n      :semantic-engine-status=\"semanticEngineStatus\"\n      :is-semantic-engine-initializing=\"isSemanticEngineInitializing\"\n      :semantic-engine-init-progress=\"semanticEngineInitProgress\"\n      :semantic-engine-last-updated=\"semanticEngineLastUpdated\"\n      :available-models=\"availableModels\"\n      :current-model=\"currentModel\"\n      :is-model-switching=\"isModelSwitching\"\n      :is-model-downloading=\"isModelDownloading\"\n      :model-download-progress=\"modelDownloadProgress\"\n      :model-initialization-status=\"modelInitializationStatus\"\n      :model-error-message=\"modelErrorMessage\"\n      :model-error-type=\"modelErrorType\"\n      :storage-stats=\"storageStats\"\n      :is-clearing-data=\"isClearingData\"\n      :clear-data-progress=\"clearDataProgress\"\n      :cache-stats=\"cacheStats\"\n      :is-managing-cache=\"isManagingCache\"\n      @back=\"currentView = 'home'\"\n      @initialize-semantic-engine=\"initializeSemanticEngine\"\n      @switch-model=\"switchModel\"\n      @retry-model-initialization=\"retryModelInitialization\"\n      @show-clear-confirmation=\"showClearConfirmation = true\"\n      @cleanup-cache=\"cleanupCache\"\n      @clear-all-cache=\"clearAllCache\"\n    />\n\n    <ConfirmDialog\n      :visible=\"showClearConfirmation\"\n      :title=\"getMessage('confirmClearDataTitle')\"\n      :message=\"getMessage('clearDataWarningMessage')\"\n      :items=\"[\n        getMessage('clearDataList1'),\n        getMessage('clearDataList2'),\n        getMessage('clearDataList3'),\n      ]\"\n      :warning=\"getMessage('clearDataIrreversibleWarning')\"\n      icon=\"⚠️\"\n      :confirm-text=\"getMessage('confirmClearButton')\"\n      :cancel-text=\"getMessage('cancelButton')\"\n      :confirming-text=\"getMessage('clearingStatus')\"\n      :is-confirming=\"isClearingData\"\n      @confirm=\"confirmClearAllData\"\n      @cancel=\"hideClearDataConfirmation\"\n    />\n\n    <!-- 侧边栏承担工作流管理；编辑器在独立窗口中打开 -->\n\n    <!-- Coming Soon Toast -->\n    <Transition name=\"toast\">\n      <div v-if=\"comingSoonToast.show\" class=\"coming-soon-toast\">\n        <svg\n          class=\"toast-icon\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n        >\n          <circle cx=\"12\" cy=\"12\" r=\"10\" />\n          <path d=\"M12 6v6l4 2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n        </svg>\n        <span>{{ comingSoonToast.feature }} 功能开发中，敬请期待</span>\n      </div>\n    </Transition>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, onMounted, onUnmounted, computed } from 'vue';\nimport {\n  PREDEFINED_MODELS,\n  type ModelPreset,\n  getModelInfo,\n  getCacheStats,\n  clearModelCache,\n  cleanupModelCache,\n} from '@/utils/semantic-similarity-engine';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { LINKS } from '@/common/constants';\nimport { getMessage } from '@/utils/i18n';\nimport { useAgentTheme, type AgentThemeId } from '../sidepanel/composables/useAgentTheme';\n\nimport ConfirmDialog from './components/ConfirmDialog.vue';\nimport ProgressIndicator from './components/ProgressIndicator.vue';\nimport ModelCacheManagement from './components/ModelCacheManagement.vue';\nimport LocalModelPage from './components/LocalModelPage.vue';\nimport {\n  DocumentIcon,\n  DatabaseIcon,\n  BoltIcon,\n  TrashIcon,\n  CheckIcon,\n  TabIcon,\n  VectorIcon,\n  RecordIcon,\n  StopIcon,\n  WorkflowIcon,\n  RefreshIcon,\n  EditIcon,\n  MarkerIcon,\n} from './components/icons';\n\n// AgentChat theme - 从preload中获取，保持与sidepanel一致\nconst { theme: agentTheme, initTheme } = useAgentTheme();\n\n// 当前视图状态：首页 or 本地模型页\nconst currentView = ref<'home' | 'local-model'>('home');\n\n// Coming Soon Toast\nconst comingSoonToast = ref<{ show: boolean; feature: string }>({ show: false, feature: '' });\n\nfunction showComingSoonToast(feature: string) {\n  comingSoonToast.value = { show: true, feature };\n  setTimeout(() => {\n    comingSoonToast.value = { show: false, feature: '' };\n  }, 2000);\n}\n\n// Record & Replay state\nconst rrRecording = ref(false);\nconst rrFlows = ref<\n  Array<{ id: string; name: string; description?: string; meta?: any; variables?: any[] }>\n>([]);\nconst rrOnlyBound = ref(false);\nconst rrSearch = ref('');\nconst currentTabUrl = ref<string>('');\nconst filteredRrFlows = computed(() => {\n  const base = rrOnlyBound.value ? rrFlows.value.filter(isFlowBoundToCurrent) : rrFlows.value;\n  const q = rrSearch.value.trim().toLowerCase();\n  if (!q) return base;\n  return base.filter((f: any) => {\n    const name = String(f.name || '').toLowerCase();\n    const domain = String(f?.meta?.domain || '').toLowerCase();\n    const tags = ((f?.meta?.tags || []) as any[]).join(',').toLowerCase();\n    return name.includes(q) || domain.includes(q) || tags.includes(q);\n  });\n});\n\n// Flow editor在独立窗口中打开；在popup不再展示繁杂列表\n\nconst loadFlows = async () => {\n  try {\n    const res = await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS });\n    if (res && res.success) rrFlows.value = res.flows || [];\n  } catch (e) {\n    /* ignore */\n  }\n};\n\nfunction isFlowBoundToCurrent(flow: any) {\n  try {\n    const bindings = flow?.meta?.bindings || [];\n    if (!bindings.length) return false;\n    if (!currentTabUrl.value) return true;\n    const url = new URL(currentTabUrl.value);\n    return bindings.some((b: any) => {\n      if (b.type === 'domain') return url.hostname.includes(b.value);\n      if (b.type === 'path') return url.pathname.startsWith(b.value);\n      if (b.type === 'url') return (url.href || '').startsWith(b.value);\n      return false;\n    });\n  } catch {\n    return false;\n  }\n}\n\n// 运行记录与覆盖项在侧边栏页面查看\nconst startRecording = async () => {\n  // TODO: 录制回放功能开发中，暂时拦截\n  showComingSoonToast('录制回放');\n  return;\n  // if (rrRecording.value) return;\n  // try {\n  //   const res = await chrome.runtime.sendMessage({\n  //     type: BACKGROUND_MESSAGE_TYPES.RR_START_RECORDING,\n  //     meta: { name: '新录制' },\n  //   });\n  //   rrRecording.value = !!(res && res.success);\n  // } catch (e) {\n  //   console.error('开始录制失败:', e);\n  //   rrRecording.value = false;\n  // }\n};\n\nconst stopRecording = async () => {\n  // TODO: 录制回放功能开发中，暂时拦截\n  showComingSoonToast('录制回放');\n  return;\n  // if (!rrRecording.value) return;\n  // try {\n  //   const res = await chrome.runtime.sendMessage({\n  //     type: BACKGROUND_MESSAGE_TYPES.RR_STOP_RECORDING,\n  //   });\n  //   rrRecording.value = false;\n  //   if (res && res.success) await loadFlows();\n  // } catch (e) {\n  //   console.error('停止录制失败:', e);\n  //   rrRecording.value = false;\n  // }\n};\n\nconst runFlow = async (flowId: string) => {\n  try {\n    // load flow to get runOptions\n    let flow: any = null;\n    try {\n      const getRes = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.RR_GET_FLOW,\n        flowId,\n      });\n      if (getRes && getRes.success) flow = getRes.flow;\n    } catch {}\n    const runOptions = (flow && flow.meta && flow.meta.runOptions) || {};\n    // No per-run overrides in popup; sidepanel/editor manage advanced options\n    const ov: any = {};\n    const res = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.RR_RUN_FLOW,\n      flowId,\n      options: { ...runOptions, ...ov, returnLogs: true },\n    });\n    if (!(res && res.success)) {\n      console.warn('回放失败');\n      return;\n    }\n    // If failed, open builder and focus the failed node\n    try {\n      const result = res.result;\n      if (result && result.success === false) {\n        const logs = result.logs || [];\n        const failed = logs.find((l: any) => l.status === 'failed');\n        if (failed && failed.stepId) {\n          // 打开独立编辑窗口并定位失败节点\n          if (flow) openBuilderWindow(flow.id, String(failed.stepId));\n        }\n      } else if (result && result.success === true) {\n        // If run succeeded but selector fallback was used, suggest updating priorities\n        const logs = result.logs || [];\n        const fb = logs.find((l: any) => l.fallbackUsed && l.fallbackTo);\n        if (fb && flow) openBuilderWindow(flow.id, String(fb.stepId || ''));\n      }\n    } catch {}\n  } catch (e) {\n    console.error('回放失败:', e);\n  }\n};\n\n// 旧的“克隆/发布/定时/覆盖项”在侧边栏或编辑器中处理\n\nconst nativeConnectionStatus = ref<'unknown' | 'connected' | 'disconnected'>('unknown');\nconst isConnecting = ref(false);\nconst nativeServerPort = ref<number>(12306);\n\nconst serverStatus = ref<{\n  isRunning: boolean;\n  port?: number;\n  lastUpdated: number;\n}>({\n  isRunning: false,\n  lastUpdated: Date.now(),\n});\n\nconst showMcpConfig = computed(() => {\n  return nativeConnectionStatus.value === 'connected' && serverStatus.value.isRunning;\n});\n\nconst copyButtonText = ref(getMessage('copyConfigButton'));\n\nconst mcpConfigJson = computed(() => {\n  const port = serverStatus.value.port || nativeServerPort.value;\n  const config = {\n    mcpServers: {\n      'streamable-mcp-server': {\n        type: 'streamable-http',\n        url: `http://127.0.0.1:${port}/mcp`,\n      },\n    },\n  };\n  return JSON.stringify(config, null, 2);\n});\n\nconst currentModel = ref<ModelPreset | null>(null);\nconst isModelSwitching = ref(false);\nconst modelSwitchProgress = ref('');\n\nconst modelDownloadProgress = ref<number>(0);\nconst isModelDownloading = ref(false);\nconst modelInitializationStatus = ref<'idle' | 'downloading' | 'initializing' | 'ready' | 'error'>(\n  'idle',\n);\nconst modelErrorMessage = ref<string>('');\nconst modelErrorType = ref<'network' | 'file' | 'unknown' | ''>('');\n\nconst selectedVersion = ref<'quantized'>('quantized');\n\nconst storageStats = ref<{\n  indexedPages: number;\n  totalDocuments: number;\n  totalTabs: number;\n  indexSize: number;\n  isInitialized: boolean;\n} | null>(null);\nconst isRefreshingStats = ref(false);\nconst isClearingData = ref(false);\nconst showClearConfirmation = ref(false);\nconst clearDataProgress = ref('');\n\nconst semanticEngineStatus = ref<'idle' | 'initializing' | 'ready' | 'error'>('idle');\nconst isSemanticEngineInitializing = ref(false);\nconst semanticEngineInitProgress = ref('');\nconst semanticEngineLastUpdated = ref<number | null>(null);\n\n// Cache management\nconst isManagingCache = ref(false);\nconst cacheStats = ref<{\n  totalSize: number;\n  totalSizeMB: number;\n  entryCount: number;\n  entries: Array<{\n    url: string;\n    size: number;\n    sizeMB: number;\n    timestamp: number;\n    age: string;\n    expired: boolean;\n  }>;\n} | null>(null);\n\nconst availableModels = computed(() => {\n  return Object.entries(PREDEFINED_MODELS).map(([key, value]) => ({\n    preset: key as ModelPreset,\n    ...value,\n  }));\n});\n\nconst getStatusClass = () => {\n  if (nativeConnectionStatus.value === 'connected') {\n    if (serverStatus.value.isRunning) {\n      return 'bg-emerald-500';\n    } else {\n      return 'bg-yellow-500';\n    }\n  } else if (nativeConnectionStatus.value === 'disconnected') {\n    return 'bg-red-500';\n  } else {\n    return 'bg-gray-500';\n  }\n};\n\n// Open sidepanel and close popup\nasync function openSidepanelAndClose(tab: string) {\n  try {\n    const current = await chrome.windows.getCurrent();\n    if ((chrome.sidePanel as any)?.setOptions) {\n      await (chrome.sidePanel as any).setOptions({\n        path: `sidepanel.html?tab=${tab}`,\n        enabled: true,\n      });\n    }\n    if (chrome.sidePanel && (chrome.sidePanel as any).open) {\n      await (chrome.sidePanel as any).open({ windowId: current.id! });\n    }\n    // Close popup after opening sidepanel\n    window.close();\n  } catch (e) {\n    console.warn(`Failed to open sidepanel (${tab}):`, e);\n  }\n}\n\n// Open sidepanel from popup for workflow management\nfunction openWorkflowSidepanel() {\n  // TODO: 工作流功能开发中，暂时拦截\n  showComingSoonToast('工作流管理');\n  // openSidepanelAndClose('workflows');\n}\n\n// Open sidepanel for element marker management\nfunction openElementMarkerSidepanel() {\n  openSidepanelAndClose('element-markers');\n}\n\n// Open sidepanel for agent chat\nfunction openAgentSidepanel() {\n  openSidepanelAndClose('agent-chat');\n}\n\nasync function toggleWebEditor() {\n  try {\n    await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TOGGLE });\n  } catch (error) {\n    console.warn('切换网页编辑模式失败:', error);\n  }\n}\n\nasync function toggleElementMarker() {\n  try {\n    // 获取当前活动tab\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    if (!tab?.id) {\n      console.warn('无法获取当前tab');\n      return;\n    }\n\n    // 向background发送消息，启动元素标注\n    await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START,\n      tabId: tab.id,\n    });\n  } catch (error) {\n    console.warn('开启元素标注失败:', error);\n  }\n}\n\nasync function openWelcomePage() {\n  try {\n    await chrome.tabs.create({ url: chrome.runtime.getURL('welcome.html') });\n  } catch {\n    // ignore\n  }\n}\n\nasync function openTroubleshooting() {\n  try {\n    await chrome.tabs.create({ url: LINKS.TROUBLESHOOTING });\n  } catch {\n    // ignore\n  }\n}\n\nfunction openBuilderWindow(flowId?: string, focusNodeId?: string) {\n  const url = new URL(chrome.runtime.getURL('builder.html'));\n  if (flowId) url.searchParams.set('flowId', flowId);\n  if (focusNodeId) url.searchParams.set('focus', focusNodeId);\n  chrome.windows.create({ url: url.toString(), type: 'popup', width: 1280, height: 800 });\n}\n\nconst getStatusText = () => {\n  if (nativeConnectionStatus.value === 'connected') {\n    if (serverStatus.value.isRunning) {\n      return getMessage('serviceRunningStatus', [\n        (serverStatus.value.port || 'Unknown').toString(),\n      ]);\n    } else {\n      return getMessage('connectedServiceNotStartedStatus');\n    }\n  } else if (nativeConnectionStatus.value === 'disconnected') {\n    return getMessage('serviceNotConnectedStatus');\n  } else {\n    return getMessage('detectingStatus');\n  }\n};\n\nconst formatIndexSize = () => {\n  if (!storageStats.value?.indexSize) return '0 MB';\n  const sizeInMB = Math.round(storageStats.value.indexSize / (1024 * 1024));\n  return `${sizeInMB} MB`;\n};\n\nconst getModelDescription = (model: any) => {\n  switch (model.preset) {\n    case 'multilingual-e5-small':\n      return getMessage('lightweightModelDescription');\n    case 'multilingual-e5-base':\n      return getMessage('betterThanSmallDescription');\n    default:\n      return getMessage('multilingualModelDescription');\n  }\n};\n\nconst getPerformanceText = (performance: string) => {\n  switch (performance) {\n    case 'fast':\n      return getMessage('fastPerformance');\n    case 'balanced':\n      return getMessage('balancedPerformance');\n    case 'accurate':\n      return getMessage('accuratePerformance');\n    default:\n      return performance;\n  }\n};\n\nconst getSemanticEngineStatusText = () => {\n  switch (semanticEngineStatus.value) {\n    case 'ready':\n      return getMessage('semanticEngineReadyStatus');\n    case 'initializing':\n      return getMessage('semanticEngineInitializingStatus');\n    case 'error':\n      return getMessage('semanticEngineInitFailedStatus');\n    case 'idle':\n    default:\n      return getMessage('semanticEngineNotInitStatus');\n  }\n};\n\nconst getSemanticEngineStatusClass = () => {\n  switch (semanticEngineStatus.value) {\n    case 'ready':\n      return 'bg-emerald-500';\n    case 'initializing':\n      return 'bg-yellow-500';\n    case 'error':\n      return 'bg-red-500';\n    case 'idle':\n    default:\n      return 'bg-gray-500';\n  }\n};\n\nconst getActiveTabsCount = () => {\n  return storageStats.value?.totalTabs || 0;\n};\n\nconst getProgressText = () => {\n  if (isModelDownloading.value) {\n    return getMessage('downloadingModelStatus', [modelDownloadProgress.value.toString()]);\n  } else if (isModelSwitching.value) {\n    return modelSwitchProgress.value || getMessage('switchingModelStatus');\n  }\n  return '';\n};\n\nconst getErrorTypeText = () => {\n  switch (modelErrorType.value) {\n    case 'network':\n      return getMessage('networkErrorMessage');\n    case 'file':\n      return getMessage('modelCorruptedErrorMessage');\n    case 'unknown':\n    default:\n      return getMessage('unknownErrorMessage');\n  }\n};\n\nconst getSemanticEngineButtonText = () => {\n  switch (semanticEngineStatus.value) {\n    case 'ready':\n      return getMessage('reinitializeButton');\n    case 'initializing':\n      return getMessage('initializingStatus');\n    case 'error':\n      return getMessage('reinitializeButton');\n    case 'idle':\n    default:\n      return getMessage('initSemanticEngineButton');\n  }\n};\n\nconst loadCacheStats = async () => {\n  try {\n    cacheStats.value = await getCacheStats();\n  } catch (error) {\n    console.error('Failed to get cache stats:', error);\n    cacheStats.value = null;\n  }\n};\n\nconst cleanupCache = async () => {\n  if (isManagingCache.value) return;\n\n  isManagingCache.value = true;\n  try {\n    await cleanupModelCache();\n    // Refresh cache stats\n    await loadCacheStats();\n  } catch (error) {\n    console.error('Failed to cleanup cache:', error);\n  } finally {\n    isManagingCache.value = false;\n  }\n};\n\nconst clearAllCache = async () => {\n  if (isManagingCache.value) return;\n\n  isManagingCache.value = true;\n  try {\n    await clearModelCache();\n    // Refresh cache stats\n    await loadCacheStats();\n  } catch (error) {\n    console.error('Failed to clear cache:', error);\n  } finally {\n    isManagingCache.value = false;\n  }\n};\n\nconst saveSemanticEngineState = async () => {\n  try {\n    const semanticEngineState = {\n      status: semanticEngineStatus.value,\n      lastUpdated: semanticEngineLastUpdated.value,\n    };\n    // eslint-disable-next-line no-undef\n    await chrome.storage.local.set({ semanticEngineState });\n  } catch (error) {\n    console.error('保存语义引擎状态失败:', error);\n  }\n};\n\nconst initializeSemanticEngine = async () => {\n  if (isSemanticEngineInitializing.value) return;\n\n  const isReinitialization = semanticEngineStatus.value === 'ready';\n  console.log(\n    `🚀 User triggered semantic engine ${isReinitialization ? 'reinitialization' : 'initialization'}`,\n  );\n\n  isSemanticEngineInitializing.value = true;\n  semanticEngineStatus.value = 'initializing';\n  semanticEngineInitProgress.value = isReinitialization\n    ? getMessage('semanticEngineInitializingStatus')\n    : getMessage('semanticEngineInitializingStatus');\n  semanticEngineLastUpdated.value = Date.now();\n\n  await saveSemanticEngineState();\n\n  try {\n    // eslint-disable-next-line no-undef\n    chrome.runtime\n      .sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.INITIALIZE_SEMANTIC_ENGINE,\n      })\n      .catch((error) => {\n        console.error('❌ Error sending semantic engine initialization request:', error);\n      });\n\n    startSemanticEngineStatusPolling();\n\n    semanticEngineInitProgress.value = isReinitialization\n      ? getMessage('processingStatus')\n      : getMessage('processingStatus');\n  } catch (error: any) {\n    console.error('❌ Failed to send initialization request:', error);\n    semanticEngineStatus.value = 'error';\n    semanticEngineInitProgress.value = `Failed to send initialization request: ${error?.message || 'Unknown error'}`;\n\n    await saveSemanticEngineState();\n\n    setTimeout(() => {\n      semanticEngineInitProgress.value = '';\n    }, 5000);\n\n    isSemanticEngineInitializing.value = false;\n    semanticEngineLastUpdated.value = Date.now();\n    await saveSemanticEngineState();\n  }\n};\n\nconst checkSemanticEngineStatus = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.GET_MODEL_STATUS,\n    });\n\n    if (response && response.success && response.status) {\n      const status = response.status;\n\n      if (status.initializationStatus === 'ready') {\n        semanticEngineStatus.value = 'ready';\n        semanticEngineLastUpdated.value = Date.now();\n        isSemanticEngineInitializing.value = false;\n        semanticEngineInitProgress.value = getMessage('semanticEngineReadyStatus');\n        await saveSemanticEngineState();\n        stopSemanticEngineStatusPolling();\n        setTimeout(() => {\n          semanticEngineInitProgress.value = '';\n        }, 2000);\n      } else if (\n        status.initializationStatus === 'downloading' ||\n        status.initializationStatus === 'initializing'\n      ) {\n        semanticEngineStatus.value = 'initializing';\n        isSemanticEngineInitializing.value = true;\n        semanticEngineInitProgress.value = getMessage('semanticEngineInitializingStatus');\n        semanticEngineLastUpdated.value = Date.now();\n        await saveSemanticEngineState();\n      } else if (status.initializationStatus === 'error') {\n        semanticEngineStatus.value = 'error';\n        semanticEngineLastUpdated.value = Date.now();\n        isSemanticEngineInitializing.value = false;\n        semanticEngineInitProgress.value = getMessage('semanticEngineInitFailedStatus');\n        await saveSemanticEngineState();\n        stopSemanticEngineStatusPolling();\n        setTimeout(() => {\n          semanticEngineInitProgress.value = '';\n        }, 5000);\n      } else {\n        semanticEngineStatus.value = 'idle';\n        isSemanticEngineInitializing.value = false;\n        await saveSemanticEngineState();\n      }\n    } else {\n      semanticEngineStatus.value = 'idle';\n      isSemanticEngineInitializing.value = false;\n      await saveSemanticEngineState();\n    }\n  } catch (error) {\n    console.error('Popup: Failed to check semantic engine status:', error);\n    semanticEngineStatus.value = 'idle';\n    isSemanticEngineInitializing.value = false;\n    await saveSemanticEngineState();\n  }\n};\n\nconst retryModelInitialization = async () => {\n  if (!currentModel.value) return;\n\n  console.log('🔄 Retrying model initialization...');\n\n  modelErrorMessage.value = '';\n  modelErrorType.value = '';\n  modelInitializationStatus.value = 'downloading';\n  modelDownloadProgress.value = 0;\n  isModelDownloading.value = true;\n  await switchModel(currentModel.value);\n};\n\nconst updatePort = async (event: Event) => {\n  const target = event.target as HTMLInputElement;\n  const newPort = Number(target.value);\n  nativeServerPort.value = newPort;\n\n  await savePortPreference(newPort);\n};\n\nconst checkNativeConnection = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({ type: 'ping_native' });\n    nativeConnectionStatus.value = response?.connected ? 'connected' : 'disconnected';\n  } catch (error) {\n    console.error('检测 Native 连接状态失败:', error);\n    nativeConnectionStatus.value = 'disconnected';\n  }\n};\n\nconst checkServerStatus = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS,\n    });\n    if (response?.success && response.serverStatus) {\n      serverStatus.value = response.serverStatus;\n    }\n\n    if (response?.connected !== undefined) {\n      nativeConnectionStatus.value = response.connected ? 'connected' : 'disconnected';\n    }\n  } catch (error) {\n    console.error('检测服务器状态失败:', error);\n  }\n};\n\nconst refreshServerStatus = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS,\n    });\n    if (response?.success && response.serverStatus) {\n      serverStatus.value = response.serverStatus;\n    }\n\n    if (response?.connected !== undefined) {\n      nativeConnectionStatus.value = response.connected ? 'connected' : 'disconnected';\n    }\n  } catch (error) {\n    console.error('刷新服务器状态失败:', error);\n  }\n};\n\nconst copyMcpConfig = async () => {\n  try {\n    await navigator.clipboard.writeText(mcpConfigJson.value);\n    copyButtonText.value = '✅' + getMessage('configCopiedNotification');\n\n    setTimeout(() => {\n      copyButtonText.value = getMessage('copyConfigButton');\n    }, 2000);\n  } catch (error) {\n    console.error('复制配置失败:', error);\n    copyButtonText.value = '❌' + getMessage('networkErrorMessage');\n\n    setTimeout(() => {\n      copyButtonText.value = getMessage('copyConfigButton');\n    }, 2000);\n  }\n};\n\nconst testNativeConnection = async () => {\n  if (isConnecting.value) return;\n  isConnecting.value = true;\n  try {\n    if (nativeConnectionStatus.value === 'connected') {\n      // eslint-disable-next-line no-undef\n      await chrome.runtime.sendMessage({ type: 'disconnect_native' });\n      nativeConnectionStatus.value = 'disconnected';\n    } else {\n      console.log(`尝试连接到端口: ${nativeServerPort.value}`);\n      // eslint-disable-next-line no-undef\n      const response = await chrome.runtime.sendMessage({\n        type: 'connectNative',\n        port: nativeServerPort.value,\n      });\n      if (response && response.success) {\n        nativeConnectionStatus.value = 'connected';\n        console.log('连接成功:', response);\n        await savePortPreference(nativeServerPort.value);\n      } else {\n        nativeConnectionStatus.value = 'disconnected';\n        console.error('连接失败:', response);\n      }\n    }\n  } catch (error) {\n    console.error('测试连接失败:', error);\n    nativeConnectionStatus.value = 'disconnected';\n  } finally {\n    isConnecting.value = false;\n  }\n};\n\nconst loadModelPreference = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const result = await chrome.storage.local.get([\n      'selectedModel',\n      'selectedVersion',\n      'modelState',\n      'semanticEngineState',\n    ]);\n\n    if (result.selectedModel) {\n      const storedModel = result.selectedModel as string;\n      console.log('📋 Stored model from storage:', storedModel);\n\n      if (PREDEFINED_MODELS[storedModel as ModelPreset]) {\n        currentModel.value = storedModel as ModelPreset;\n        console.log(`✅ Loaded valid model: ${currentModel.value}`);\n      } else {\n        console.warn(\n          `⚠️ Stored model \"${storedModel}\" not found in PREDEFINED_MODELS, using default`,\n        );\n        currentModel.value = 'multilingual-e5-small';\n        await saveModelPreference(currentModel.value);\n      }\n    } else {\n      console.log('⚠️ No model found in storage, using default');\n      currentModel.value = 'multilingual-e5-small';\n      await saveModelPreference(currentModel.value);\n    }\n\n    selectedVersion.value = 'quantized';\n    console.log('✅ Using quantized version (fixed)');\n\n    await saveVersionPreference('quantized');\n\n    if (result.modelState) {\n      const modelState = result.modelState;\n\n      if (modelState.status === 'ready') {\n        modelInitializationStatus.value = 'ready';\n        modelDownloadProgress.value = modelState.downloadProgress || 100;\n        isModelDownloading.value = false;\n      } else {\n        modelInitializationStatus.value = 'idle';\n        modelDownloadProgress.value = 0;\n        isModelDownloading.value = false;\n\n        await saveModelState();\n      }\n    } else {\n      modelInitializationStatus.value = 'idle';\n      modelDownloadProgress.value = 0;\n      isModelDownloading.value = false;\n    }\n\n    if (result.semanticEngineState) {\n      const semanticState = result.semanticEngineState;\n      if (semanticState.status === 'ready') {\n        semanticEngineStatus.value = 'ready';\n        semanticEngineLastUpdated.value = semanticState.lastUpdated || Date.now();\n      } else if (semanticState.status === 'error') {\n        semanticEngineStatus.value = 'error';\n        semanticEngineLastUpdated.value = semanticState.lastUpdated || Date.now();\n      } else {\n        semanticEngineStatus.value = 'idle';\n      }\n    } else {\n      semanticEngineStatus.value = 'idle';\n    }\n  } catch (error) {\n    console.error('❌ 加载模型偏好失败:', error);\n  }\n};\n\nconst saveModelPreference = async (model: ModelPreset) => {\n  try {\n    // eslint-disable-next-line no-undef\n    await chrome.storage.local.set({ selectedModel: model });\n  } catch (error) {\n    console.error('保存模型偏好失败:', error);\n  }\n};\n\nconst saveVersionPreference = async (version: 'full' | 'quantized' | 'compressed') => {\n  try {\n    // eslint-disable-next-line no-undef\n    await chrome.storage.local.set({ selectedVersion: version });\n  } catch (error) {\n    console.error('保存版本偏好失败:', error);\n  }\n};\n\nconst savePortPreference = async (port: number) => {\n  try {\n    // eslint-disable-next-line no-undef\n    await chrome.storage.local.set({ nativeServerPort: port });\n    console.log(`端口偏好已保存: ${port}`);\n  } catch (error) {\n    console.error('保存端口偏好失败:', error);\n  }\n};\n\nconst loadPortPreference = async () => {\n  try {\n    // eslint-disable-next-line no-undef\n    const result = await chrome.storage.local.get(['nativeServerPort']);\n    if (result.nativeServerPort) {\n      nativeServerPort.value = result.nativeServerPort;\n      console.log(`端口偏好已加载: ${result.nativeServerPort}`);\n    }\n  } catch (error) {\n    console.error('加载端口偏好失败:', error);\n  }\n};\n\nconst saveModelState = async () => {\n  try {\n    const modelState = {\n      status: modelInitializationStatus.value,\n      downloadProgress: modelDownloadProgress.value,\n      isDownloading: isModelDownloading.value,\n      lastUpdated: Date.now(),\n    };\n    // eslint-disable-next-line no-undef\n    await chrome.storage.local.set({ modelState });\n  } catch (error) {\n    console.error('保存模型状态失败:', error);\n  }\n};\n\nlet statusMonitoringInterval: ReturnType<typeof setInterval> | null = null;\nlet semanticEngineStatusPollingInterval: ReturnType<typeof setInterval> | null = null;\n\nconst startModelStatusMonitoring = () => {\n  if (statusMonitoringInterval) {\n    clearInterval(statusMonitoringInterval);\n  }\n\n  statusMonitoringInterval = setInterval(async () => {\n    try {\n      // eslint-disable-next-line no-undef\n      const response = await chrome.runtime.sendMessage({\n        type: 'get_model_status',\n      });\n\n      if (response && response.success) {\n        const status = response.status;\n        modelInitializationStatus.value = status.initializationStatus || 'idle';\n        modelDownloadProgress.value = status.downloadProgress || 0;\n        isModelDownloading.value = status.isDownloading || false;\n\n        if (status.initializationStatus === 'error') {\n          modelErrorMessage.value = status.errorMessage || getMessage('modelFailedStatus');\n          modelErrorType.value = status.errorType || 'unknown';\n        } else {\n          modelErrorMessage.value = '';\n          modelErrorType.value = '';\n        }\n\n        await saveModelState();\n\n        if (status.initializationStatus === 'ready' || status.initializationStatus === 'error') {\n          stopModelStatusMonitoring();\n        }\n      }\n    } catch (error) {\n      console.error('获取模型状态失败:', error);\n    }\n  }, 1000);\n};\n\nconst stopModelStatusMonitoring = () => {\n  if (statusMonitoringInterval) {\n    clearInterval(statusMonitoringInterval);\n    statusMonitoringInterval = null;\n  }\n};\n\nconst startSemanticEngineStatusPolling = () => {\n  if (semanticEngineStatusPollingInterval) {\n    clearInterval(semanticEngineStatusPollingInterval);\n  }\n\n  semanticEngineStatusPollingInterval = setInterval(async () => {\n    try {\n      await checkSemanticEngineStatus();\n    } catch (error) {\n      console.error('Semantic engine status polling failed:', error);\n    }\n  }, 2000);\n};\n\nconst stopSemanticEngineStatusPolling = () => {\n  if (semanticEngineStatusPollingInterval) {\n    clearInterval(semanticEngineStatusPollingInterval);\n    semanticEngineStatusPollingInterval = null;\n  }\n};\n\nconst refreshStorageStats = async () => {\n  if (isRefreshingStats.value) return;\n\n  isRefreshingStats.value = true;\n  try {\n    console.log('🔄 Refreshing storage statistics...');\n\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: 'get_storage_stats',\n    });\n\n    if (response && response.success) {\n      storageStats.value = {\n        indexedPages: response.stats.indexedPages || 0,\n        totalDocuments: response.stats.totalDocuments || 0,\n        totalTabs: response.stats.totalTabs || 0,\n        indexSize: response.stats.indexSize || 0,\n        isInitialized: response.stats.isInitialized || false,\n      };\n      console.log('✅ Storage stats refreshed:', storageStats.value);\n    } else {\n      console.error('❌ Failed to get storage stats:', response?.error);\n      storageStats.value = {\n        indexedPages: 0,\n        totalDocuments: 0,\n        totalTabs: 0,\n        indexSize: 0,\n        isInitialized: false,\n      };\n    }\n  } catch (error) {\n    console.error('❌ Error refreshing storage stats:', error);\n    storageStats.value = {\n      indexedPages: 0,\n      totalDocuments: 0,\n      totalTabs: 0,\n      indexSize: 0,\n      isInitialized: false,\n    };\n  } finally {\n    isRefreshingStats.value = false;\n  }\n};\n\nconst hideClearDataConfirmation = () => {\n  showClearConfirmation.value = false;\n};\n\nconst confirmClearAllData = async () => {\n  if (isClearingData.value) return;\n\n  isClearingData.value = true;\n  clearDataProgress.value = getMessage('clearingStatus');\n\n  try {\n    console.log('🗑️ Starting to clear all data...');\n\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: 'clear_all_data',\n    });\n\n    if (response && response.success) {\n      clearDataProgress.value = getMessage('dataClearedNotification');\n      console.log('✅ All data cleared successfully');\n\n      await refreshStorageStats();\n\n      setTimeout(() => {\n        clearDataProgress.value = '';\n        hideClearDataConfirmation();\n      }, 2000);\n    } else {\n      throw new Error(response?.error || 'Failed to clear data');\n    }\n  } catch (error: any) {\n    console.error('❌ Failed to clear all data:', error);\n    clearDataProgress.value = `Failed to clear data: ${error?.message || 'Unknown error'}`;\n\n    setTimeout(() => {\n      clearDataProgress.value = '';\n    }, 5000);\n  } finally {\n    isClearingData.value = false;\n  }\n};\n\nconst switchModel = async (newModel: ModelPreset) => {\n  console.log(`🔄 switchModel called with newModel: ${newModel}`);\n\n  if (isModelSwitching.value) {\n    console.log('⏸️ Model switch already in progress, skipping');\n    return;\n  }\n\n  const isSameModel = newModel === currentModel.value;\n  const currentModelInfo = currentModel.value\n    ? getModelInfo(currentModel.value)\n    : getModelInfo('multilingual-e5-small');\n  const newModelInfo = getModelInfo(newModel);\n  const isDifferentDimension = currentModelInfo.dimension !== newModelInfo.dimension;\n\n  console.log(`📊 Switch analysis:`);\n  console.log(`   - Same model: ${isSameModel} (${currentModel.value} -> ${newModel})`);\n  console.log(\n    `   - Current dimension: ${currentModelInfo.dimension}, New dimension: ${newModelInfo.dimension}`,\n  );\n  console.log(`   - Different dimension: ${isDifferentDimension}`);\n\n  if (isSameModel && !isDifferentDimension) {\n    console.log('✅ Same model and dimension - no need to switch');\n    return;\n  }\n\n  const switchReasons = [];\n  if (!isSameModel) switchReasons.push('different model');\n  if (isDifferentDimension) switchReasons.push('different dimension');\n\n  console.log(`🚀 Switching model due to: ${switchReasons.join(', ')}`);\n  console.log(\n    `📋 Model: ${currentModel.value} (${currentModelInfo.dimension}D) -> ${newModel} (${newModelInfo.dimension}D)`,\n  );\n\n  isModelSwitching.value = true;\n  modelSwitchProgress.value = getMessage('switchingModelStatus');\n\n  modelInitializationStatus.value = 'downloading';\n  modelDownloadProgress.value = 0;\n  isModelDownloading.value = true;\n\n  try {\n    await saveModelPreference(newModel);\n    await saveVersionPreference('quantized');\n    await saveModelState();\n\n    modelSwitchProgress.value = getMessage('semanticEngineInitializingStatus');\n\n    startModelStatusMonitoring();\n\n    // eslint-disable-next-line no-undef\n    const response = await chrome.runtime.sendMessage({\n      type: 'switch_semantic_model',\n      modelPreset: newModel,\n      modelVersion: 'quantized',\n      modelDimension: newModelInfo.dimension,\n      previousDimension: currentModelInfo.dimension,\n    });\n\n    if (response && response.success) {\n      currentModel.value = newModel;\n      modelSwitchProgress.value = getMessage('successNotification');\n      console.log(\n        '模型切换成功:',\n        newModel,\n        'version: quantized',\n        'dimension:',\n        newModelInfo.dimension,\n      );\n\n      modelInitializationStatus.value = 'ready';\n      isModelDownloading.value = false;\n      await saveModelState();\n\n      setTimeout(() => {\n        modelSwitchProgress.value = '';\n      }, 2000);\n    } else {\n      throw new Error(response?.error || 'Model switch failed');\n    }\n  } catch (error: any) {\n    console.error('模型切换失败:', error);\n    modelSwitchProgress.value = `Model switch failed: ${error?.message || 'Unknown error'}`;\n\n    modelInitializationStatus.value = 'error';\n    isModelDownloading.value = false;\n\n    const errorMessage = error?.message || '未知错误';\n    if (\n      errorMessage.includes('network') ||\n      errorMessage.includes('fetch') ||\n      errorMessage.includes('timeout')\n    ) {\n      modelErrorType.value = 'network';\n      modelErrorMessage.value = getMessage('networkErrorMessage');\n    } else if (\n      errorMessage.includes('corrupt') ||\n      errorMessage.includes('invalid') ||\n      errorMessage.includes('format')\n    ) {\n      modelErrorType.value = 'file';\n      modelErrorMessage.value = getMessage('modelCorruptedErrorMessage');\n    } else {\n      modelErrorType.value = 'unknown';\n      modelErrorMessage.value = errorMessage;\n    }\n\n    await saveModelState();\n\n    setTimeout(() => {\n      modelSwitchProgress.value = '';\n    }, 8000);\n  } finally {\n    isModelSwitching.value = false;\n  }\n};\n\nconst setupServerStatusListener = () => {\n  // eslint-disable-next-line no-undef\n  const onMessage = (message: { type?: string; payload?: unknown }) => {\n    // Server status changes\n    if (message.type === BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED && message.payload) {\n      serverStatus.value = message.payload as any;\n      console.log('Server status updated:', message.payload);\n    }\n    // Flows changed - refresh list (IndexedDB-based notification)\n    if (message.type === BACKGROUND_MESSAGE_TYPES.RR_FLOWS_CHANGED) {\n      loadFlows();\n    }\n  };\n  chrome.runtime.onMessage.addListener(onMessage);\n  // Store reference for cleanup\n  (window as any).__rr_popup_onMessage = onMessage;\n};\n\nonMounted(async () => {\n  // 初始化主题\n  await initTheme();\n  await loadPortPreference();\n  await loadModelPreference();\n  await checkNativeConnection();\n  await checkServerStatus();\n  await refreshStorageStats();\n  await loadCacheStats();\n  await loadFlows();\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    currentTabUrl.value = tab?.url || '';\n  } catch {}\n\n  await checkSemanticEngineStatus();\n  setupServerStatusListener();\n  // Auto-refresh workflows list when storage rr_flows changes\n  try {\n    const onChanged = (changes: any, area: string) => {\n      try {\n        if (area !== 'local') return;\n        if (Object.prototype.hasOwnProperty.call(changes || {}, 'rr_flows')) loadFlows();\n      } catch {}\n    };\n    chrome.storage.onChanged.addListener(onChanged);\n    (window as any).__rr_popup_onChanged = onChanged;\n  } catch {}\n});\n\nonUnmounted(() => {\n  stopModelStatusMonitoring();\n  stopSemanticEngineStatusPolling();\n  // Clean up runtime message listener\n  try {\n    const msgFn = (window as any).__rr_popup_onMessage;\n    if (msgFn && chrome?.runtime?.onMessage?.removeListener) {\n      chrome.runtime.onMessage.removeListener(msgFn);\n    }\n  } catch {}\n  // Clean up storage change listener (legacy fallback)\n  try {\n    const fn = (window as any).__rr_popup_onChanged;\n    if (fn && chrome?.storage?.onChanged?.removeListener) {\n      chrome.storage.onChanged.removeListener(fn);\n    }\n  } catch {}\n});\n</script>\n\n<style scoped>\n.popup-container {\n  background: #f1f5f9;\n  border-radius: 24px;\n  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n}\n\n.header {\n  flex-shrink: 0;\n  padding-left: 20px;\n}\n\n.header-content {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.header-title {\n  font-size: 24px;\n  font-weight: 700;\n  color: #1e293b;\n  margin: 0;\n}\n\n.settings-button {\n  padding: 8px;\n  border-radius: 50%;\n  color: #64748b;\n  background: none;\n  border: none;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.settings-button:hover {\n  background: #e2e8f0;\n  color: #1e293b;\n}\n\n.content {\n  flex-grow: 1;\n  padding: 8px 24px;\n  overflow-y: auto;\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\n.content::-webkit-scrollbar {\n  display: none;\n}\n.status-card {\n  background: white;\n  border-radius: 16px;\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n  padding: 20px;\n  margin-bottom: 20px;\n}\n\n.status-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n  margin-bottom: 8px;\n}\n\n.status-info {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.status-dot {\n  height: 8px;\n  width: 8px;\n  border-radius: 50%;\n}\n\n.status-dot.bg-emerald-500 {\n  background-color: #10b981;\n}\n\n.status-dot.bg-red-500 {\n  background-color: #ef4444;\n}\n\n.status-dot.bg-yellow-500 {\n  background-color: #eab308;\n}\n\n.status-dot.bg-gray-500 {\n  background-color: #6b7280;\n}\n\n.status-text {\n  font-size: 16px;\n  font-weight: 600;\n  color: #1e293b;\n}\n\n.model-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n  margin-bottom: 4px;\n}\n\n.model-name {\n  font-weight: 600;\n  color: #7c3aed;\n}\n\n.stats-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 12px;\n}\n.stats-card {\n  background: white;\n  border-radius: 12px;\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n  padding: 16px;\n}\n\n.stats-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n\n.stats-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n}\n\n.stats-icon {\n  padding: 8px;\n  border-radius: 8px;\n}\n\n.stats-icon.violet {\n  background: #ede9fe;\n  color: #7c3aed;\n}\n\n.stats-icon.teal {\n  background: #ccfbf1;\n  color: #0d9488;\n}\n\n.stats-icon.blue {\n  background: #dbeafe;\n  color: #2563eb;\n}\n\n.stats-icon.green {\n  background: #dcfce7;\n  color: #16a34a;\n}\n\n.stats-value {\n  font-size: 30px;\n  font-weight: 700;\n  color: #0f172a;\n  margin: 0;\n}\n\n.section {\n  margin-bottom: 24px;\n}\n\n.secondary-button {\n  background: #f1f5f9;\n  color: #475569;\n  border: 1px solid #cbd5e1;\n  padding: 8px 16px;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.secondary-button:hover:not(:disabled) {\n  background: #e2e8f0;\n  border-color: #94a3b8;\n}\n\n.secondary-button:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.primary-button {\n  background: #3b82f6;\n  color: white;\n  border: none;\n  padding: 8px 16px;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.primary-button:hover {\n  background: #2563eb;\n}\n\n.section-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: #374151;\n  margin-bottom: 12px;\n}\n.current-model-card {\n  background: linear-gradient(135deg, #faf5ff, #f3e8ff);\n  border: 1px solid #e9d5ff;\n  border-radius: 12px;\n  padding: 16px;\n  margin-bottom: 16px;\n}\n\n.current-model-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n.current-model-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n  margin: 0;\n}\n\n.current-model-badge {\n  background: #8b5cf6;\n  color: white;\n  font-size: 12px;\n  font-weight: 600;\n  padding: 4px 8px;\n  border-radius: 6px;\n}\n\n.current-model-name {\n  font-size: 16px;\n  font-weight: 700;\n  color: #7c3aed;\n  margin: 0;\n}\n\n.model-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.model-card {\n  background: white;\n  border-radius: 12px;\n  padding: 16px;\n  cursor: pointer;\n  border: 1px solid #e5e7eb;\n  transition: all 0.2s ease;\n}\n\n.model-card:hover {\n  border-color: #8b5cf6;\n}\n\n.model-card.selected {\n  border: 2px solid #8b5cf6;\n  background: #faf5ff;\n}\n\n.model-card.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n\n.model-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n}\n\n.model-info {\n  flex: 1;\n}\n\n.model-name {\n  font-weight: 600;\n  color: #1e293b;\n  margin: 0 0 4px 0;\n}\n\n.model-name.selected-text {\n  color: #7c3aed;\n}\n\n.model-description {\n  font-size: 14px;\n  color: #64748b;\n  margin: 0;\n}\n\n.check-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  background: #8b5cf6;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.model-tags {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-top: 16px;\n}\n.model-tag {\n  display: inline-flex;\n  align-items: center;\n  border-radius: 9999px;\n  padding: 4px 10px;\n  font-size: 12px;\n  font-weight: 500;\n}\n\n.model-tag.performance {\n  background: #d1fae5;\n  color: #065f46;\n}\n\n.model-tag.size {\n  background: #ddd6fe;\n  color: #5b21b6;\n}\n\n.model-tag.dimension {\n  background: #e5e7eb;\n  color: #4b5563;\n}\n\n.config-card {\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n  padding: 16px;\n  display: flex;\n  flex-direction: column;\n  gap: 14px;\n}\n.semantic-engine-card {\n  background: white;\n  border-radius: 16px;\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n  padding: 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.semantic-engine-status {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.semantic-engine-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: #8b5cf6;\n  color: white;\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: 8px;\n  border: none;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n}\n\n.semantic-engine-button:hover:not(:disabled) {\n  background: #7c3aed;\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n}\n\n.semantic-engine-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.status-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n.refresh-status-button {\n  background: none;\n  border: none;\n  cursor: pointer;\n  padding: 4px 8px;\n  border-radius: 6px;\n  font-size: 14px;\n  color: #64748b;\n  transition: all 0.2s ease;\n}\n\n.refresh-status-button:hover {\n  background: #f1f5f9;\n  color: #374151;\n}\n\n.status-timestamp {\n  font-size: 12px;\n  color: #9ca3af;\n  margin-top: 4px;\n}\n\n.mcp-config-section {\n  border-top: 1px solid #f1f5f9;\n}\n\n.mcp-config-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n.mcp-config-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n  margin: 0;\n}\n\n.copy-config-button {\n  background: none;\n  border: none;\n  cursor: pointer;\n  padding: 4px 8px;\n  border-radius: 6px;\n  font-size: 14px;\n  color: #64748b;\n  transition: all 0.2s ease;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.copy-config-button:hover {\n  background: #f1f5f9;\n  color: #374151;\n}\n\n.mcp-config-content {\n  background: #f8fafc;\n  border: 1px solid #e2e8f0;\n  border-radius: 8px;\n  padding: 12px;\n  overflow-x: auto;\n}\n\n.mcp-config-json {\n  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\n  font-size: 12px;\n  line-height: 1.4;\n  color: #374151;\n  margin: 0;\n  white-space: pre;\n  overflow-x: auto;\n}\n\n.port-section {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.port-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n}\n\n.port-input {\n  display: block;\n  width: 100%;\n  border-radius: 8px;\n  border: 1px solid #d1d5db;\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  padding: 12px;\n  font-size: 14px;\n  background: #f8fafc;\n}\n\n.port-input:focus {\n  outline: none;\n  border-color: var(--ac-accent, #d97757);\n  box-shadow: 0 0 0 3px var(--ac-accent-subtle, rgba(217, 119, 87, 0.12));\n}\n\n.connect-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: var(--ac-accent, #d97757);\n  color: var(--ac-accent-contrast, white);\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: var(--ac-radius-button, 8px);\n  border: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n}\n\n.connect-button:hover:not(:disabled) {\n  background: var(--ac-accent-hover, #c4664a);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.05));\n}\n\n.connect-button:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n.error-card {\n  background: #fef2f2;\n  border: 1px solid #fecaca;\n  border-radius: 12px;\n  padding: 16px;\n  margin-bottom: 16px;\n  display: flex;\n  align-items: flex-start;\n  gap: 16px;\n}\n\n.error-content {\n  flex: 1;\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n}\n\n.error-icon {\n  font-size: 20px;\n  flex-shrink: 0;\n  margin-top: 2px;\n}\n\n.error-details {\n  flex: 1;\n}\n\n.error-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #dc2626;\n  margin: 0 0 4px 0;\n}\n\n.error-message {\n  font-size: 14px;\n  color: #991b1b;\n  margin: 0 0 8px 0;\n  font-weight: 500;\n}\n\n.error-suggestion {\n  font-size: 13px;\n  color: #7f1d1d;\n  margin: 0;\n  line-height: 1.4;\n}\n\n.retry-button {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  background: #dc2626;\n  color: white;\n  font-weight: 600;\n  padding: 8px 16px;\n  border-radius: 8px;\n  border: none;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  font-size: 14px;\n  flex-shrink: 0;\n}\n\n.retry-button:hover:not(:disabled) {\n  background: #b91c1c;\n}\n\n.retry-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n.danger-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: white;\n  border: 1px solid #d1d5db;\n  color: #374151;\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  margin-top: 16px;\n}\n\n.danger-button:hover:not(:disabled) {\n  border-color: #ef4444;\n  color: #dc2626;\n}\n\n.danger-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n/* Icon sizes - use :deep to apply to child components */\n:deep(.icon-small) {\n  width: 16px;\n  height: 16px;\n}\n\n:deep(.icon-default) {\n  width: 20px;\n  height: 20px;\n}\n\n:deep(.icon-medium) {\n  width: 24px;\n  height: 24px;\n}\n.footer {\n  padding: 16px;\n  margin-top: auto;\n}\n\n.footer-links {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  gap: 16px;\n  margin-bottom: 8px;\n}\n\n.footer-link {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  background: none;\n  border: none;\n  color: #64748b;\n  font-size: 12px;\n  cursor: pointer;\n  padding: 4px 8px;\n  border-radius: 6px;\n  transition: all 0.2s ease;\n}\n\n.footer-link:hover {\n  color: #8b5cf6;\n  background: #e2e8f0;\n}\n\n.footer-link svg {\n  width: 14px;\n  height: 14px;\n}\n\n.footer-text {\n  text-align: center;\n  font-size: 12px;\n  color: #94a3b8;\n  margin: 0;\n}\n\n@media (max-width: 320px) {\n  .popup-container {\n    width: 100%;\n    height: 100vh;\n    border-radius: 0;\n  }\n\n  .footer-links {\n    gap: 8px;\n  }\n\n  .rr-grid {\n    display: flex;\n    flex-direction: column;\n    gap: 12px;\n  }\n  .rr-controls {\n    display: flex;\n    gap: 8px;\n  }\n  .rr-list {\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n  }\n  .rr-item {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 8px;\n    border: 1px solid #eee;\n    border-radius: 6px;\n  }\n  .rr-runoverrides {\n    margin-top: 6px;\n    border: 1px dashed #e5e7eb;\n    border-radius: 8px;\n    padding: 8px;\n    background: #f9fafb;\n  }\n  .rr-meta {\n    display: flex;\n    flex-direction: column;\n  }\n  .rr-name {\n    font-weight: 600;\n  }\n  .rr-desc {\n    font-size: 12px;\n    color: #666;\n  }\n  .empty {\n    color: #888;\n    font-size: 13px;\n  }\n\n  .header {\n    padding: 24px 20px 12px;\n  }\n\n  .content {\n    padding: 8px 20px;\n  }\n\n  .stats-grid {\n    grid-template-columns: 1fr;\n    gap: 8px;\n  }\n\n  .config-card {\n    padding: 16px;\n    gap: 12px;\n  }\n\n  .current-model-card {\n    padding: 12px;\n    margin-bottom: 12px;\n  }\n\n  .stats-card {\n    padding: 12px;\n  }\n\n  .stats-value {\n    font-size: 24px;\n  }\n}\n\n/* 快捷工具icon按钮样式 */\n.rr-icon-buttons {\n  display: flex;\n  gap: 12px;\n  justify-content: flex-start;\n  padding: 16px;\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n}\n\n.rr-icon-btn {\n  width: 48px;\n  height: 48px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--ac-surface-muted, #f2f0eb);\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #6e6e6e);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.rr-icon-btn:hover:not(:disabled) {\n  transform: translateY(-2px);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.05));\n}\n\n.rr-icon-btn:disabled {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n.rr-icon-btn svg {\n  width: 24px;\n  height: 24px;\n}\n\n/* 录制按钮 - 红色 */\n.rr-icon-btn-record {\n  background: rgba(239, 68, 68, 0.1);\n  color: #ef4444;\n}\n\n.rr-icon-btn-record:hover:not(:disabled) {\n  background: rgba(239, 68, 68, 0.2);\n  color: #dc2626;\n}\n\n/* 录制中状态 - 脉冲动画 */\n.rr-icon-btn-recording {\n  animation: pulse-recording 1.5s ease-in-out infinite;\n}\n\n@keyframes pulse-recording {\n  0%,\n  100% {\n    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);\n  }\n  50% {\n    box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);\n  }\n}\n\n/* 停止按钮 - 深红色 */\n.rr-icon-btn-stop {\n  background: rgba(185, 28, 28, 0.1);\n  color: #b91c1c;\n}\n\n.rr-icon-btn-stop:hover:not(:disabled) {\n  background: rgba(185, 28, 28, 0.2);\n  color: #991b1b;\n}\n\n/* 编辑按钮 - 蓝色 */\n.rr-icon-btn-edit {\n  background: rgba(37, 99, 235, 0.1);\n  color: #2563eb;\n}\n\n.rr-icon-btn-edit:hover:not(:disabled) {\n  background: rgba(37, 99, 235, 0.2);\n  color: #1d4ed8;\n}\n\n/* 标注按钮 - 绿色 */\n.rr-icon-btn-marker {\n  background: rgba(16, 185, 129, 0.1);\n  color: #10b981;\n}\n\n.rr-icon-btn-marker:hover:not(:disabled) {\n  background: rgba(16, 185, 129, 0.2);\n  color: #059669;\n}\n\n/* Coming Soon 按钮样式 */\n.rr-icon-btn-coming-soon {\n  opacity: 0.5;\n  cursor: default !important;\n}\n\n.rr-icon-btn-coming-soon:hover {\n  transform: none !important;\n  box-shadow: none !important;\n  opacity: 0.6;\n}\n\n/* CSS Tooltip - instant display */\n.has-tooltip {\n  position: relative;\n}\n\n.has-tooltip::after {\n  content: attr(data-tooltip);\n  position: absolute;\n  bottom: calc(100% + 6px);\n  left: 50%;\n  transform: translateX(-50%);\n  padding: 6px 10px;\n  font-size: 12px;\n  font-weight: 500;\n  line-height: 1.3;\n  white-space: nowrap;\n  color: var(--ac-text-inverse, #ffffff);\n  background-color: var(--ac-text, #1a1a1a);\n  border-radius: var(--ac-radius-button, 8px);\n  opacity: 0;\n  visibility: hidden;\n  transition:\n    opacity 80ms ease,\n    visibility 80ms ease;\n  pointer-events: none;\n  z-index: 100;\n}\n\n.has-tooltip::before {\n  content: '';\n  position: absolute;\n  bottom: calc(100% + 2px);\n  left: 50%;\n  transform: translateX(-50%);\n  border: 4px solid transparent;\n  border-top-color: var(--ac-text, #1a1a1a);\n  opacity: 0;\n  visibility: hidden;\n  transition:\n    opacity 80ms ease,\n    visibility 80ms ease;\n  pointer-events: none;\n  z-index: 100;\n}\n\n.has-tooltip:hover::after,\n.has-tooltip:hover::before {\n  opacity: 1;\n  visibility: visible;\n}\n\n/* 首页视图 */\n.home-view {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n}\n\n/* 管理入口卡片样式 */\n.entry-card {\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n  overflow: hidden;\n}\n\n.entry-item {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 14px 16px;\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid var(--ac-border, #e7e5e4);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  text-align: left;\n}\n\n.entry-item:last-child {\n  border-bottom: none;\n}\n\n.entry-item:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n}\n\n.entry-icon {\n  width: 40px;\n  height: 40px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: var(--ac-radius-button, 8px);\n  flex-shrink: 0;\n}\n\n.entry-icon.agent {\n  background: rgba(217, 119, 87, 0.12);\n  color: var(--ac-accent, #d97757);\n}\n\n.entry-icon.workflow {\n  background: rgba(37, 99, 235, 0.12);\n  color: #2563eb;\n}\n\n.entry-icon.marker {\n  background: rgba(16, 185, 129, 0.12);\n  color: #10b981;\n}\n\n.entry-icon.model {\n  background: rgba(139, 92, 246, 0.12);\n  color: #8b5cf6;\n}\n\n.entry-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.entry-title {\n  display: block;\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--ac-text, #1a1a1a);\n  line-height: 1.3;\n}\n\n.entry-desc {\n  display: block;\n  font-size: 12px;\n  color: var(--ac-text-subtle, #a8a29e);\n  line-height: 1.3;\n  margin-top: 2px;\n}\n\n.entry-arrow {\n  color: var(--ac-text-subtle, #a8a29e);\n  flex-shrink: 0;\n}\n\n/* Coming Soon Badge */\n.coming-soon-badge {\n  display: inline-flex;\n  align-items: center;\n  margin-left: 6px;\n  padding: 2px 6px;\n  font-size: 9px;\n  font-weight: 600;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  color: var(--ac-accent, #d97757);\n  background: rgba(217, 119, 87, 0.12);\n  border-radius: 4px;\n  vertical-align: middle;\n}\n\n.entry-item-coming-soon {\n  opacity: 0.7;\n}\n\n.entry-item-coming-soon:hover {\n  opacity: 0.85;\n}\n\n/* Coming Soon Toast */\n.coming-soon-toast {\n  position: fixed;\n  bottom: 24px;\n  left: 50%;\n  transform: translateX(-50%);\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 12px 20px;\n  background: var(--ac-text, #1a1a1a);\n  color: var(--ac-text-inverse, #ffffff);\n  font-size: 13px;\n  font-weight: 500;\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.15));\n  z-index: 1000;\n  white-space: nowrap;\n}\n\n.toast-icon {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  color: var(--ac-accent, #d97757);\n}\n\n/* Toast transition */\n.toast-enter-active,\n.toast-leave-active {\n  transition: all 0.25s ease;\n}\n\n.toast-enter-from,\n.toast-leave-to {\n  opacity: 0;\n  transform: translateX(-50%) translateY(12px);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/ConfirmDialog.vue",
    "content": "<template>\n  <div v-if=\"visible\" class=\"confirmation-dialog\" @click.self=\"$emit('cancel')\">\n    <div class=\"dialog-content\">\n      <div class=\"dialog-header\">\n        <span class=\"dialog-icon\">{{ icon }}</span>\n        <h3 class=\"dialog-title\">{{ title }}</h3>\n      </div>\n\n      <div class=\"dialog-body\">\n        <p class=\"dialog-message\">{{ message }}</p>\n\n        <ul v-if=\"items && items.length > 0\" class=\"dialog-list\">\n          <li v-for=\"item in items\" :key=\"item\">{{ item }}</li>\n        </ul>\n\n        <div v-if=\"warning\" class=\"dialog-warning\">\n          <strong>{{ warning }}</strong>\n        </div>\n      </div>\n\n      <div class=\"dialog-actions\">\n        <button class=\"dialog-button cancel-button\" @click=\"$emit('cancel')\">\n          {{ cancelText }}\n        </button>\n        <button\n          class=\"dialog-button confirm-button\"\n          :disabled=\"isConfirming\"\n          @click=\"$emit('confirm')\"\n        >\n          {{ isConfirming ? confirmingText : confirmText }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { getMessage } from '@/utils/i18n';\ninterface Props {\n  visible: boolean;\n  title: string;\n  message: string;\n  items?: string[];\n  warning?: string;\n  icon?: string;\n  confirmText?: string;\n  cancelText?: string;\n  confirmingText?: string;\n  isConfirming?: boolean;\n}\n\ninterface Emits {\n  (e: 'confirm'): void;\n  (e: 'cancel'): void;\n}\n\nwithDefaults(defineProps<Props>(), {\n  icon: '⚠️',\n  confirmText: getMessage('confirmButton'),\n  cancelText: getMessage('cancelButton'),\n  confirmingText: getMessage('processingStatus'),\n  isConfirming: false,\n});\n\ndefineEmits<Emits>();\n</script>\n\n<style scoped>\n.confirmation-dialog {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.6);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 1000;\n  backdrop-filter: blur(8px);\n  animation: dialogFadeIn 0.3s ease-out;\n}\n\n@keyframes dialogFadeIn {\n  from {\n    opacity: 0;\n    backdrop-filter: blur(0px);\n  }\n  to {\n    opacity: 1;\n    backdrop-filter: blur(8px);\n  }\n}\n\n.dialog-content {\n  background: white;\n  border-radius: 12px;\n  padding: 24px;\n  max-width: 360px;\n  width: 90%;\n  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n  animation: dialogSlideIn 0.3s ease-out;\n  border: 1px solid rgba(255, 255, 255, 0.2);\n}\n\n@keyframes dialogSlideIn {\n  from {\n    opacity: 0;\n    transform: translateY(-30px) scale(0.9);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0) scale(1);\n  }\n}\n\n.dialog-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-bottom: 20px;\n}\n\n.dialog-icon {\n  font-size: 24px;\n  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));\n}\n\n.dialog-title {\n  font-size: 18px;\n  font-weight: 600;\n  color: #2d3748;\n  margin: 0;\n}\n\n.dialog-body {\n  margin-bottom: 24px;\n}\n\n.dialog-message {\n  font-size: 14px;\n  color: #4a5568;\n  margin: 0 0 16px 0;\n  line-height: 1.6;\n}\n\n.dialog-list {\n  margin: 16px 0;\n  padding-left: 24px;\n  background: linear-gradient(135deg, #f7fafc, #edf2f7);\n  border-radius: 6px;\n  padding: 12px 12px 12px 32px;\n  border-left: 3px solid #667eea;\n}\n\n.dialog-list li {\n  font-size: 13px;\n  color: #718096;\n  margin-bottom: 6px;\n  line-height: 1.4;\n}\n\n.dialog-list li:last-child {\n  margin-bottom: 0;\n}\n\n.dialog-warning {\n  font-size: 13px;\n  color: #e53e3e;\n  margin: 16px 0 0 0;\n  padding: 12px;\n  background: linear-gradient(135deg, rgba(245, 101, 101, 0.1), rgba(229, 62, 62, 0.05));\n  border-radius: 6px;\n  border-left: 3px solid #e53e3e;\n  border: 1px solid rgba(245, 101, 101, 0.2);\n}\n\n.dialog-actions {\n  display: flex;\n  gap: 12px;\n  justify-content: flex-end;\n}\n\n.dialog-button {\n  padding: 10px 20px;\n  border: none;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.3s ease;\n  min-width: 80px;\n}\n\n.cancel-button {\n  background: linear-gradient(135deg, #e2e8f0, #cbd5e0);\n  color: #4a5568;\n  border: 1px solid #cbd5e0;\n}\n\n.cancel-button:hover {\n  background: linear-gradient(135deg, #cbd5e0, #a0aec0);\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(160, 174, 192, 0.3);\n}\n\n.confirm-button {\n  background: linear-gradient(135deg, #f56565, #e53e3e);\n  color: white;\n  border: 1px solid #e53e3e;\n}\n\n.confirm-button:hover:not(:disabled) {\n  background: linear-gradient(135deg, #e53e3e, #c53030);\n  transform: translateY(-1px);\n  box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);\n}\n\n.confirm-button:disabled {\n  opacity: 0.7;\n  cursor: not-allowed;\n  transform: none;\n  box-shadow: none;\n}\n\n/* 响应式设计 */\n@media (max-width: 420px) {\n  .dialog-content {\n    padding: 20px;\n    max-width: 320px;\n  }\n\n  .dialog-header {\n    gap: 10px;\n    margin-bottom: 16px;\n  }\n\n  .dialog-icon {\n    font-size: 20px;\n  }\n\n  .dialog-title {\n    font-size: 16px;\n  }\n\n  .dialog-message {\n    font-size: 13px;\n  }\n\n  .dialog-list {\n    padding: 10px 10px 10px 28px;\n  }\n\n  .dialog-list li {\n    font-size: 12px;\n  }\n\n  .dialog-warning {\n    font-size: 12px;\n    padding: 10px;\n  }\n\n  .dialog-actions {\n    gap: 8px;\n    flex-direction: column-reverse;\n  }\n\n  .dialog-button {\n    width: 100%;\n    padding: 12px 16px;\n  }\n}\n\n/* 焦点样式 */\n.dialog-button:focus {\n  outline: none;\n  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);\n}\n\n.cancel-button:focus {\n  box-shadow: 0 0 0 3px rgba(160, 174, 192, 0.3);\n}\n\n.confirm-button:focus {\n  box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.3);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/ElementMarkerManagement.vue",
    "content": "<template>\n  <div class=\"section\">\n    <h2 class=\"section-title\">元素标注管理</h2>\n    <div class=\"config-card\">\n      <div class=\"status-section\" style=\"gap: 8px\">\n        <div class=\"status-header\">\n          <p class=\"status-label\">当前页面</p>\n          <span class=\"status-text\" style=\"opacity: 0.85\">{{ currentUrl }}</span>\n        </div>\n        <div class=\"status-header\">\n          <p class=\"status-label\">已标注元素</p>\n          <span class=\"status-text\">{{ markers.length }}</span>\n        </div>\n      </div>\n\n      <form class=\"mcp-config-section\" @submit.prevent=\"onAdd\">\n        <div class=\"mcp-config-header\">\n          <p class=\"mcp-config-label\">新增标注</p>\n        </div>\n        <div style=\"display: flex; gap: 8px; margin-bottom: 8px\">\n          <input v-model=\"form.name\" placeholder=\"名称，如 登录按钮\" class=\"port-input\" />\n          <select v-model=\"form.selectorType\" class=\"port-input\" style=\"max-width: 120px\">\n            <option value=\"css\">CSS</option>\n            <option value=\"xpath\">XPath</option>\n          </select>\n          <select v-model=\"form.matchType\" class=\"port-input\" style=\"max-width: 120px\">\n            <option value=\"prefix\">路径前缀</option>\n            <option value=\"exact\">精确匹配</option>\n            <option value=\"host\">域名</option>\n          </select>\n        </div>\n        <input v-model=\"form.selector\" placeholder=\"CSS 选择器\" class=\"port-input\" />\n        <div style=\"display: flex; gap: 8px; margin-top: 8px\">\n          <button class=\"semantic-engine-button\" :disabled=\"!form.selector\" type=\"submit\">\n            保存\n          </button>\n          <button class=\"danger-button\" type=\"button\" @click=\"resetForm\">清空</button>\n        </div>\n      </form>\n\n      <div v-if=\"markers.length\" class=\"model-list\" style=\"margin-top: 8px\">\n        <div\n          v-for=\"m in markers\"\n          :key=\"m.id\"\n          class=\"model-card\"\n          style=\"display: flex; align-items: center; justify-content: space-between; gap: 8px\"\n        >\n          <div style=\"display: flex; flex-direction: column; gap: 4px\">\n            <strong class=\"model-name\">{{ m.name }}</strong>\n            <code style=\"font-size: 12px; opacity: 0.85\">{{ m.selector }}</code>\n            <div style=\"display: flex; gap: 6px; margin-top: 2px\">\n              <span class=\"model-tag dimension\">{{ m.selectorType || 'css' }}</span>\n              <span class=\"model-tag dimension\">{{ m.matchType }}</span>\n            </div>\n          </div>\n          <div style=\"display: flex; gap: 6px\">\n            <button class=\"semantic-engine-button\" @click=\"validate(m)\">验证</button>\n            <button class=\"secondary-button\" @click=\"prefill(m)\">编辑</button>\n            <button class=\"danger-button\" @click=\"remove(m)\">删除</button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue';\nimport type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\n\nconst currentUrl = ref('');\nconst markers = ref<ElementMarker[]>([]);\n\nconst form = ref<UpsertMarkerRequest>({\n  url: '',\n  name: '',\n  selector: '',\n  matchType: 'prefix',\n});\n\nfunction resetForm() {\n  form.value = { url: currentUrl.value, name: '', selector: '', matchType: 'prefix' };\n}\n\nasync function load() {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const t = tabs[0];\n    currentUrl.value = String(t?.url || '');\n    form.value.url = currentUrl.value;\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL,\n      url: currentUrl.value,\n    });\n    if (res?.success) markers.value = res.markers || [];\n  } catch (e) {\n    /* ignore */\n  }\n}\n\nfunction prefill(m: ElementMarker) {\n  form.value = {\n    url: m.url,\n    name: m.name,\n    selector: m.selector,\n    selectorType: m.selectorType,\n    listMode: m.listMode,\n    matchType: m.matchType,\n    action: m.action,\n    id: m.id,\n  };\n}\n\nasync function onAdd() {\n  try {\n    if (!form.value.selector) return;\n    form.value.url = currentUrl.value;\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,\n      marker: form.value,\n    });\n    if (res?.success) {\n      resetForm();\n      await load();\n    }\n  } catch (e) {\n    /* ignore */\n  }\n}\n\nasync function remove(m: ElementMarker) {\n  try {\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,\n      id: m.id,\n    });\n    if (res?.success) await load();\n  } catch (e) {\n    /* ignore */\n  }\n}\n\nasync function validate(m: ElementMarker) {\n  try {\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,\n      selector: m.selector,\n      selectorType: m.selectorType || 'css',\n      action: 'hover',\n      listMode: !!m.listMode,\n    } as any);\n\n    // Trigger highlight in the page only if tool validation succeeded\n    if (res?.tool?.ok !== false) {\n      await highlightInTab(m);\n    }\n  } catch (e) {\n    /* ignore */\n  }\n}\n\nasync function highlightInTab(m: ElementMarker) {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs[0]?.id;\n    if (!tabId) return;\n\n    // Ensure element-marker.js is injected\n    try {\n      await chrome.scripting.executeScript({\n        target: { tabId, allFrames: true },\n        files: ['inject-scripts/element-marker.js'],\n        world: 'ISOLATED',\n      });\n    } catch {\n      // Already injected, ignore\n    }\n\n    // Send highlight message to content script\n    await chrome.tabs.sendMessage(tabId, {\n      action: 'element_marker_highlight',\n      selector: m.selector,\n      selectorType: m.selectorType || 'css',\n      listMode: !!m.listMode,\n    });\n  } catch (e) {\n    // Ignore errors (tab might not support content scripts)\n  }\n}\n\nonMounted(load);\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/LocalModelPage.vue",
    "content": "<template>\n  <div class=\"local-model-page\">\n    <!-- 返回按钮 -->\n    <div class=\"page-header\">\n      <button class=\"back-button\" @click=\"$emit('back')\" title=\"返回首页\">\n        <svg\n          viewBox=\"0 0 24 24\"\n          width=\"20\"\n          height=\"20\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n        >\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 19l-7-7 7-7\" />\n        </svg>\n        <span>返回</span>\n      </button>\n      <h2 class=\"page-title\">本地模型</h2>\n    </div>\n\n    <div class=\"page-content\">\n      <!-- 语义引擎 -->\n      <div class=\"section\">\n        <h3 class=\"section-title\">{{ getMessage('semanticEngineLabel') }}</h3>\n        <div class=\"semantic-engine-card\">\n          <div class=\"semantic-engine-status\">\n            <div class=\"status-info\">\n              <span :class=\"['status-dot', getSemanticEngineStatusClass()]\"></span>\n              <span class=\"status-text\">{{ getSemanticEngineStatusText() }}</span>\n            </div>\n            <div v-if=\"semanticEngineLastUpdated\" class=\"status-timestamp\">\n              {{ getMessage('lastUpdatedLabel') }}\n              {{ new Date(semanticEngineLastUpdated).toLocaleTimeString() }}\n            </div>\n          </div>\n\n          <ProgressIndicator\n            v-if=\"isSemanticEngineInitializing\"\n            :visible=\"isSemanticEngineInitializing\"\n            :text=\"semanticEngineInitProgress\"\n            :showSpinner=\"true\"\n          />\n\n          <button\n            class=\"primary-action-button\"\n            :disabled=\"isSemanticEngineInitializing\"\n            @click=\"$emit('initializeSemanticEngine')\"\n          >\n            <BoltIcon />\n            <span>{{ getSemanticEngineButtonText() }}</span>\n          </button>\n        </div>\n      </div>\n\n      <!-- Embedding模型选择 -->\n      <div class=\"section\">\n        <h3 class=\"section-title\">{{ getMessage('embeddingModelLabel') }}</h3>\n\n        <ProgressIndicator\n          v-if=\"isModelSwitching || isModelDownloading\"\n          :visible=\"isModelSwitching || isModelDownloading\"\n          :text=\"progressText\"\n          :showSpinner=\"true\"\n        />\n\n        <div v-if=\"modelInitializationStatus === 'error'\" class=\"error-card\">\n          <div class=\"error-content\">\n            <div class=\"error-icon\">⚠️</div>\n            <div class=\"error-details\">\n              <p class=\"error-title\">{{ getMessage('semanticEngineInitFailedStatus') }}</p>\n              <p class=\"error-message\">{{\n                modelErrorMessage || getMessage('semanticEngineInitFailedStatus')\n              }}</p>\n              <p class=\"error-suggestion\">{{ errorTypeText }}</p>\n            </div>\n          </div>\n          <button\n            class=\"retry-button\"\n            @click=\"$emit('retryModelInitialization')\"\n            :disabled=\"isModelSwitching || isModelDownloading\"\n          >\n            <span>🔄</span>\n            <span>{{ getMessage('retryButton') }}</span>\n          </button>\n        </div>\n\n        <div class=\"model-list\">\n          <div\n            v-for=\"model in availableModels\"\n            :key=\"model.preset\"\n            :class=\"[\n              'model-card',\n              {\n                selected: currentModel === model.preset,\n                disabled: isModelSwitching || isModelDownloading,\n              },\n            ]\"\n            @click=\"!isModelSwitching && !isModelDownloading && $emit('switchModel', model.preset)\"\n          >\n            <div class=\"model-header\">\n              <div class=\"model-info\">\n                <p class=\"model-name\" :class=\"{ 'selected-text': currentModel === model.preset }\">\n                  {{ model.preset }}\n                </p>\n                <p class=\"model-description\">{{ getModelDescription(model) }}</p>\n              </div>\n              <div v-if=\"currentModel === model.preset\" class=\"check-icon\">\n                <CheckIcon class=\"text-white\" />\n              </div>\n            </div>\n            <div class=\"model-tags\">\n              <span class=\"model-tag performance\">{{ getPerformanceText(model.performance) }}</span>\n              <span class=\"model-tag size\">{{ model.size }}</span>\n              <span class=\"model-tag dimension\">{{ model.dimension }}D</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- 索引数据管理 -->\n      <div class=\"section\">\n        <h3 class=\"section-title\">{{ getMessage('indexDataManagementLabel') }}</h3>\n        <div class=\"stats-grid\">\n          <div class=\"stats-card\">\n            <div class=\"stats-header\">\n              <p class=\"stats-label\">{{ getMessage('indexedPagesLabel') }}</p>\n              <span class=\"stats-icon violet\">\n                <DocumentIcon />\n              </span>\n            </div>\n            <p class=\"stats-value\">{{ storageStats?.indexedPages || 0 }}</p>\n          </div>\n\n          <div class=\"stats-card\">\n            <div class=\"stats-header\">\n              <p class=\"stats-label\">{{ getMessage('indexSizeLabel') }}</p>\n              <span class=\"stats-icon teal\">\n                <DatabaseIcon />\n              </span>\n            </div>\n            <p class=\"stats-value\">{{ formatIndexSize() }}</p>\n          </div>\n\n          <div class=\"stats-card\">\n            <div class=\"stats-header\">\n              <p class=\"stats-label\">{{ getMessage('activeTabsLabel') }}</p>\n              <span class=\"stats-icon blue\">\n                <TabIcon />\n              </span>\n            </div>\n            <p class=\"stats-value\">{{ storageStats?.totalTabs || 0 }}</p>\n          </div>\n\n          <div class=\"stats-card\">\n            <div class=\"stats-header\">\n              <p class=\"stats-label\">{{ getMessage('vectorDocumentsLabel') }}</p>\n              <span class=\"stats-icon green\">\n                <VectorIcon />\n              </span>\n            </div>\n            <p class=\"stats-value\">{{ storageStats?.totalDocuments || 0 }}</p>\n          </div>\n        </div>\n\n        <ProgressIndicator\n          v-if=\"isClearingData && clearDataProgress\"\n          :visible=\"isClearingData\"\n          :text=\"clearDataProgress\"\n          :showSpinner=\"true\"\n        />\n\n        <button\n          class=\"danger-action-button\"\n          :disabled=\"isClearingData\"\n          @click=\"$emit('showClearConfirmation')\"\n        >\n          <TrashIcon />\n          <span>{{\n            isClearingData ? getMessage('clearingStatus') : getMessage('clearAllDataButton')\n          }}</span>\n        </button>\n      </div>\n\n      <!-- 模型缓存管理 -->\n      <ModelCacheManagement\n        :cache-stats=\"cacheStats\"\n        :is-managing-cache=\"isManagingCache\"\n        @cleanup-cache=\"$emit('cleanupCache')\"\n        @clear-all-cache=\"$emit('clearAllCache')\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport { getMessage } from '@/utils/i18n';\nimport ProgressIndicator from './ProgressIndicator.vue';\nimport ModelCacheManagement from './ModelCacheManagement.vue';\nimport {\n  DocumentIcon,\n  DatabaseIcon,\n  BoltIcon,\n  TrashIcon,\n  CheckIcon,\n  TabIcon,\n  VectorIcon,\n} from './icons';\n\ninterface Props {\n  // 语义引擎\n  semanticEngineStatus: 'idle' | 'initializing' | 'ready' | 'error';\n  isSemanticEngineInitializing: boolean;\n  semanticEngineInitProgress: string;\n  semanticEngineLastUpdated: number | null;\n  // 模型\n  availableModels: Array<{\n    preset: string;\n    performance: string;\n    size: string;\n    dimension: number;\n  }>;\n  currentModel: string | null;\n  isModelSwitching: boolean;\n  isModelDownloading: boolean;\n  modelDownloadProgress: number;\n  modelInitializationStatus: string;\n  modelErrorMessage: string;\n  modelErrorType: string;\n  // 存储统计\n  storageStats: {\n    indexedPages: number;\n    totalDocuments: number;\n    totalTabs: number;\n    indexSize: number;\n    isInitialized: boolean;\n  } | null;\n  isClearingData: boolean;\n  clearDataProgress: string;\n  // 缓存\n  cacheStats: any;\n  isManagingCache: boolean;\n}\n\nconst props = defineProps<Props>();\n\ndefineEmits<{\n  (e: 'back'): void;\n  (e: 'initializeSemanticEngine'): void;\n  (e: 'switchModel', preset: string): void;\n  (e: 'retryModelInitialization'): void;\n  (e: 'showClearConfirmation'): void;\n  (e: 'cleanupCache'): void;\n  (e: 'clearAllCache'): void;\n}>();\n\n// 计算属性\nconst getSemanticEngineStatusClass = () => {\n  switch (props.semanticEngineStatus) {\n    case 'ready':\n      return 'bg-emerald-500';\n    case 'initializing':\n      return 'bg-yellow-500';\n    case 'error':\n      return 'bg-red-500';\n    case 'idle':\n    default:\n      return 'bg-gray-500';\n  }\n};\n\nconst getSemanticEngineStatusText = () => {\n  switch (props.semanticEngineStatus) {\n    case 'ready':\n      return getMessage('semanticEngineReadyStatus');\n    case 'initializing':\n      return getMessage('semanticEngineInitializingStatus');\n    case 'error':\n      return getMessage('semanticEngineInitFailedStatus');\n    case 'idle':\n    default:\n      return getMessage('semanticEngineNotInitStatus');\n  }\n};\n\nconst getSemanticEngineButtonText = () => {\n  switch (props.semanticEngineStatus) {\n    case 'ready':\n      return getMessage('reinitializeButton');\n    case 'initializing':\n      return getMessage('initializingStatus');\n    case 'error':\n      return getMessage('reinitializeButton');\n    case 'idle':\n    default:\n      return getMessage('initSemanticEngineButton');\n  }\n};\n\nconst progressText = computed(() => {\n  if (props.isModelDownloading) {\n    return getMessage('downloadingModelStatus', [props.modelDownloadProgress.toString()]);\n  } else if (props.isModelSwitching) {\n    return getMessage('switchingModelStatus');\n  }\n  return '';\n});\n\nconst errorTypeText = computed(() => {\n  switch (props.modelErrorType) {\n    case 'network':\n      return getMessage('networkErrorMessage');\n    case 'file':\n      return getMessage('modelCorruptedErrorMessage');\n    case 'unknown':\n    default:\n      return getMessage('unknownErrorMessage');\n  }\n});\n\nconst getModelDescription = (model: any) => {\n  switch (model.preset) {\n    case 'multilingual-e5-small':\n      return getMessage('lightweightModelDescription');\n    case 'multilingual-e5-base':\n      return getMessage('betterThanSmallDescription');\n    default:\n      return getMessage('multilingualModelDescription');\n  }\n};\n\nconst getPerformanceText = (performance: string) => {\n  switch (performance) {\n    case 'fast':\n      return getMessage('fastPerformance');\n    case 'balanced':\n      return getMessage('balancedPerformance');\n    case 'accurate':\n      return getMessage('accuratePerformance');\n    default:\n      return performance;\n  }\n};\n\nconst formatIndexSize = () => {\n  if (!props.storageStats?.indexSize) return '0 MB';\n  const sizeInMB = Math.round(props.storageStats.indexSize / (1024 * 1024));\n  return `${sizeInMB} MB`;\n};\n</script>\n\n<style scoped>\n.local-model-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n}\n\n.page-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--ac-border, #e7e5e4);\n  background: var(--ac-surface, #ffffff);\n}\n\n.back-button {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 8px 12px;\n  background: var(--ac-surface-muted, #f2f0eb);\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #6e6e6e);\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.back-button:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n  color: var(--ac-text, #1a1a1a);\n}\n\n.page-title {\n  font-size: 18px;\n  font-weight: 600;\n  color: var(--ac-text, #1a1a1a);\n  margin: 0;\n}\n\n.page-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px 20px;\n}\n\n.section {\n  margin-bottom: 24px;\n}\n\n.section-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--ac-text, #374151);\n  margin-bottom: 12px;\n}\n\n.semantic-engine-card {\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n  padding: 16px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.semantic-engine-status {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.status-info {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.status-dot {\n  height: 8px;\n  width: 8px;\n  border-radius: 50%;\n}\n\n.status-dot.bg-emerald-500 {\n  background-color: #10b981;\n}\n.status-dot.bg-yellow-500 {\n  background-color: #eab308;\n}\n.status-dot.bg-red-500 {\n  background-color: #ef4444;\n}\n.status-dot.bg-gray-500 {\n  background-color: #6b7280;\n}\n\n.status-text {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--ac-text, #1a1a1a);\n}\n\n.status-timestamp {\n  font-size: 12px;\n  color: var(--ac-text-subtle, #9ca3af);\n}\n\n.primary-action-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: var(--ac-accent, #d97757);\n  color: var(--ac-accent-contrast, white);\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: var(--ac-radius-button, 8px);\n  border: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.primary-action-button:hover:not(:disabled) {\n  background: var(--ac-accent-hover, #c4664a);\n}\n\n.primary-action-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.danger-action-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: var(--ac-surface, white);\n  border: 1px solid var(--ac-border, #d1d5db);\n  color: var(--ac-text, #374151);\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: var(--ac-radius-button, 8px);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  margin-top: 12px;\n}\n\n.danger-action-button:hover:not(:disabled) {\n  border-color: #ef4444;\n  color: #dc2626;\n}\n\n.danger-action-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n/* 模型列表 */\n.model-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.model-card {\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  padding: 16px;\n  cursor: pointer;\n  border: 1px solid var(--ac-border, #e5e7eb);\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.model-card:hover {\n  border-color: var(--ac-accent, #d97757);\n}\n\n.model-card.selected {\n  border: 2px solid var(--ac-accent, #d97757);\n  background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.08));\n}\n\n.model-card.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n\n.model-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n}\n\n.model-info {\n  flex: 1;\n}\n\n.model-name {\n  font-weight: 600;\n  color: var(--ac-text, #1e293b);\n  margin: 0 0 4px 0;\n}\n\n.model-name.selected-text {\n  color: var(--ac-accent, #d97757);\n}\n\n.model-description {\n  font-size: 14px;\n  color: var(--ac-text-muted, #64748b);\n  margin: 0;\n}\n\n.check-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  background: var(--ac-accent, #d97757);\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.model-tags {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-top: 12px;\n}\n\n.model-tag {\n  display: inline-flex;\n  align-items: center;\n  border-radius: 9999px;\n  padding: 4px 10px;\n  font-size: 12px;\n  font-weight: 500;\n}\n\n.model-tag.performance {\n  background: #d1fae5;\n  color: #065f46;\n}\n\n.model-tag.size {\n  background: var(--ac-accent-subtle, #ddd6fe);\n  color: var(--ac-accent, #5b21b6);\n}\n\n.model-tag.dimension {\n  background: var(--ac-surface-muted, #e5e7eb);\n  color: var(--ac-text-muted, #4b5563);\n}\n\n/* 统计网格 */\n.stats-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 12px;\n}\n\n.stats-card {\n  background: var(--ac-surface, white);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n  padding: 16px;\n}\n\n.stats-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n\n.stats-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--ac-text-muted, #64748b);\n  margin: 0;\n}\n\n.stats-icon {\n  padding: 8px;\n  border-radius: 8px;\n}\n\n.stats-icon.violet {\n  background: #ede9fe;\n  color: #7c3aed;\n}\n.stats-icon.teal {\n  background: #ccfbf1;\n  color: #0d9488;\n}\n.stats-icon.blue {\n  background: #dbeafe;\n  color: #2563eb;\n}\n.stats-icon.green {\n  background: #dcfce7;\n  color: #16a34a;\n}\n\n.stats-value {\n  font-size: 24px;\n  font-weight: 700;\n  color: var(--ac-text, #0f172a);\n  margin: 0;\n}\n\n/* 错误卡片 */\n.error-card {\n  background: #fef2f2;\n  border: 1px solid #fecaca;\n  border-radius: var(--ac-radius-card, 12px);\n  padding: 16px;\n  margin-bottom: 16px;\n  display: flex;\n  align-items: flex-start;\n  gap: 16px;\n}\n\n.error-content {\n  flex: 1;\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n}\n\n.error-icon {\n  font-size: 20px;\n  flex-shrink: 0;\n}\n\n.error-details {\n  flex: 1;\n}\n\n.error-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #dc2626;\n  margin: 0 0 4px 0;\n}\n\n.error-message {\n  font-size: 14px;\n  color: #991b1b;\n  margin: 0 0 8px 0;\n  font-weight: 500;\n}\n\n.error-suggestion {\n  font-size: 13px;\n  color: #7f1d1d;\n  margin: 0;\n  line-height: 1.4;\n}\n\n.retry-button {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  background: #dc2626;\n  color: white;\n  font-weight: 600;\n  padding: 8px 16px;\n  border-radius: 8px;\n  border: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  font-size: 14px;\n  flex-shrink: 0;\n}\n\n.retry-button:hover:not(:disabled) {\n  background: #b91c1c;\n}\n\n.retry-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/ModelCacheManagement.vue",
    "content": "<template>\n  <div class=\"model-cache-section\">\n    <h2 class=\"section-title\">{{ getMessage('modelCacheManagementLabel') }}</h2>\n\n    <!-- Cache Statistics Grid -->\n    <div class=\"stats-grid\">\n      <div class=\"stats-card\">\n        <div class=\"stats-header\">\n          <p class=\"stats-label\">{{ getMessage('cacheSizeLabel') }}</p>\n          <span class=\"stats-icon orange\">\n            <DatabaseIcon />\n          </span>\n        </div>\n        <p class=\"stats-value\">{{ cacheStats?.totalSizeMB || 0 }} MB</p>\n      </div>\n\n      <div class=\"stats-card\">\n        <div class=\"stats-header\">\n          <p class=\"stats-label\">{{ getMessage('cacheEntriesLabel') }}</p>\n          <span class=\"stats-icon purple\">\n            <VectorIcon />\n          </span>\n        </div>\n        <p class=\"stats-value\">{{ cacheStats?.entryCount || 0 }}</p>\n      </div>\n    </div>\n\n    <!-- Cache Entries Details -->\n    <div v-if=\"cacheStats && cacheStats.entries.length > 0\" class=\"cache-details\">\n      <h3 class=\"cache-details-title\">{{ getMessage('cacheDetailsLabel') }}</h3>\n      <div class=\"cache-entries\">\n        <div v-for=\"entry in cacheStats.entries\" :key=\"entry.url\" class=\"cache-entry\">\n          <div class=\"entry-info\">\n            <div class=\"entry-url\">{{ getModelNameFromUrl(entry.url) }}</div>\n            <div class=\"entry-details\">\n              <span class=\"entry-size\">{{ entry.sizeMB }} MB</span>\n              <span class=\"entry-age\">{{ entry.age }}</span>\n              <span v-if=\"entry.expired\" class=\"entry-expired\">{{ getMessage('expiredLabel') }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- No Cache Message -->\n    <div v-else-if=\"cacheStats && cacheStats.entries.length === 0\" class=\"no-cache\">\n      <p>{{ getMessage('noCacheDataMessage') }}</p>\n    </div>\n\n    <!-- Loading State -->\n    <div v-else-if=\"!cacheStats\" class=\"loading-cache\">\n      <p>{{ getMessage('loadingCacheInfoStatus') }}</p>\n    </div>\n\n    <!-- Progress Indicator -->\n    <ProgressIndicator\n      v-if=\"isManagingCache\"\n      :visible=\"isManagingCache\"\n      :text=\"isManagingCache ? getMessage('processingCacheStatus') : ''\"\n      :showSpinner=\"true\"\n    />\n\n    <!-- Action Buttons -->\n    <div class=\"cache-actions\">\n      <div class=\"secondary-button\" :disabled=\"isManagingCache\" @click=\"$emit('cleanup-cache')\">\n        <span class=\"stats-icon\"><DatabaseIcon /></span>\n        <span>{{\n          isManagingCache ? getMessage('cleaningStatus') : getMessage('cleanExpiredCacheButton')\n        }}</span>\n      </div>\n\n      <div class=\"danger-button\" :disabled=\"isManagingCache\" @click=\"$emit('clear-all-cache')\">\n        <span class=\"stats-icon\"><TrashIcon /></span>\n        <span>{{ isManagingCache ? getMessage('clearingStatus') : getMessage('clearAllCacheButton') }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport ProgressIndicator from './ProgressIndicator.vue';\nimport { DatabaseIcon, VectorIcon, TrashIcon } from './icons';\nimport { getMessage } from '@/utils/i18n';\n\ninterface CacheEntry {\n  url: string;\n  size: number;\n  sizeMB: number;\n  timestamp: number;\n  age: string;\n  expired: boolean;\n}\n\ninterface CacheStats {\n  totalSize: number;\n  totalSizeMB: number;\n  entryCount: number;\n  entries: CacheEntry[];\n}\n\ninterface Props {\n  cacheStats: CacheStats | null;\n  isManagingCache: boolean;\n}\n\ninterface Emits {\n  (e: 'cleanup-cache'): void;\n  (e: 'clear-all-cache'): void;\n}\n\ndefineProps<Props>();\ndefineEmits<Emits>();\n\nconst getModelNameFromUrl = (url: string) => {\n  // Extract model name from HuggingFace URL\n  const match = url.match(/huggingface\\.co\\/([^/]+\\/[^/]+)/);\n  if (match) {\n    return match[1];\n  }\n  return url.split('/').pop() || url;\n};\n</script>\n\n<style scoped>\n.model-cache-section {\n  margin-bottom: 24px;\n}\n\n.section-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: #374151;\n  margin-bottom: 12px;\n}\n\n.stats-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 12px;\n  margin-bottom: 16px;\n}\n\n.stats-card {\n  background: white;\n  border-radius: 12px;\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n  padding: 16px;\n}\n\n.stats-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n\n.stats-label {\n  font-size: 14px;\n  font-weight: 500;\n  color: #64748b;\n}\n\n.stats-icon {\n  padding: 8px;\n  border-radius: 8px;\n  width: 36px;\n  height: 36px;\n}\n\n.stats-icon.orange {\n  background: #fed7aa;\n  color: #ea580c;\n}\n\n.stats-icon.purple {\n  background: #e9d5ff;\n  color: #9333ea;\n}\n\n.stats-value {\n  font-size: 30px;\n  font-weight: 700;\n  color: #0f172a;\n  margin: 0;\n}\n\n.cache-details {\n  margin-bottom: 16px;\n}\n\n.cache-details-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #374151;\n  margin: 0 0 12px 0;\n}\n\n.cache-entries {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.cache-entry {\n  background: white;\n  border: 1px solid #e5e7eb;\n  border-radius: 8px;\n  padding: 12px;\n}\n\n.entry-info {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.entry-url {\n  font-weight: 500;\n  color: #1f2937;\n  font-size: 14px;\n}\n\n.entry-details {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n  font-size: 12px;\n}\n\n.entry-size {\n  background: #dbeafe;\n  color: #1e40af;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n.entry-age {\n  color: #6b7280;\n}\n\n.entry-expired {\n  background: #fee2e2;\n  color: #dc2626;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n.no-cache,\n.loading-cache {\n  text-align: center;\n  color: #6b7280;\n  padding: 20px;\n  background: #f8fafc;\n  border-radius: 8px;\n  border: 1px solid #e2e8f0;\n  margin-bottom: 16px;\n}\n\n.cache-actions {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.secondary-button {\n  background: #f1f5f9;\n  color: #475569;\n  border: 1px solid #cbd5e1;\n  padding: 8px 16px;\n  border-radius: 8px;\n  font-size: 14px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  justify-content: center;\n  user-select: none;\n  cursor: pointer;\n}\n\n.secondary-button:hover:not(:disabled) {\n  background: #e2e8f0;\n  border-color: #94a3b8;\n}\n\n.secondary-button:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.danger-button {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  background: white;\n  border: 1px solid #d1d5db;\n  color: #374151;\n  font-weight: 600;\n  padding: 12px 16px;\n  border-radius: 8px;\n  cursor: pointer;\n  user-select: none;\n  transition: all 0.2s ease;\n}\n\n.danger-button:hover:not(:disabled) {\n  border-color: #ef4444;\n  color: #dc2626;\n}\n\n.danger-button:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/ProgressIndicator.vue",
    "content": "<template>\n  <div v-if=\"visible\" class=\"progress-section\">\n    <div class=\"progress-indicator\">\n      <div class=\"spinner\" v-if=\"showSpinner\"></div>\n      <span class=\"progress-text\">{{ text }}</span>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  visible?: boolean;\n  text: string;\n  showSpinner?: boolean;\n}\n\nwithDefaults(defineProps<Props>(), {\n  visible: true,\n  showSpinner: true,\n});\n</script>\n\n<style scoped>\n.progress-section {\n  margin-top: 16px;\n  animation: slideIn 0.3s ease-out;\n}\n\n.progress-indicator {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 16px;\n  background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1));\n  border-radius: 8px;\n  border-left: 4px solid #667eea;\n  backdrop-filter: blur(10px);\n  border: 1px solid rgba(102, 126, 234, 0.2);\n}\n\n.spinner {\n  width: 20px;\n  height: 20px;\n  border: 3px solid rgba(102, 126, 234, 0.2);\n  border-top: 3px solid #667eea;\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n  flex-shrink: 0;\n}\n\n@keyframes spin {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n.progress-text {\n  font-size: 14px;\n  color: #4a5568;\n  font-weight: 500;\n  line-height: 1.4;\n}\n\n@keyframes slideIn {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* 响应式设计 */\n@media (max-width: 420px) {\n  .progress-indicator {\n    padding: 12px;\n    gap: 8px;\n  }\n\n  .spinner {\n    width: 16px;\n    height: 16px;\n    border-width: 2px;\n  }\n\n  .progress-text {\n    font-size: 13px;\n  }\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/ScheduleDialog.vue",
    "content": "<template>\n  <div v-if=\"visible\" class=\"rr-modal\">\n    <div class=\"rr-dialog\">\n      <div class=\"rr-header\">\n        <div class=\"title\">定时执行</div>\n        <button class=\"close\" @click=\"$emit('close')\">✕</button>\n      </div>\n      <div class=\"rr-body\">\n        <div class=\"row\">\n          <label>启用</label>\n          <label class=\"chk\"><input type=\"checkbox\" v-model=\"enabled\" />启用定时</label>\n        </div>\n        <div class=\"row\">\n          <label>类型</label>\n          <select v-model=\"type\">\n            <option value=\"interval\">每隔 N 分钟</option>\n            <option value=\"daily\">每天固定时间</option>\n            <option value=\"once\">只执行一次</option>\n          </select>\n        </div>\n        <div class=\"row\" v-if=\"type === 'interval'\">\n          <label>间隔(分钟)</label>\n          <input type=\"number\" v-model.number=\"intervalMinutes\" />\n        </div>\n        <div class=\"row\" v-if=\"type === 'daily'\">\n          <label>时间(HH:mm)</label>\n          <input v-model=\"dailyTime\" placeholder=\"例如 09:30\" />\n        </div>\n        <div class=\"row\" v-if=\"type === 'once'\">\n          <label>时间(ISO)</label>\n          <input v-model=\"onceAt\" placeholder=\"例如 2025-10-05T10:00:00\" />\n        </div>\n        <div class=\"row\">\n          <label>参数(JSON)</label>\n          <textarea v-model=\"argsJson\" placeholder='{ \"username\": \"xx\" }'></textarea>\n        </div>\n        <div class=\"section\">\n          <div class=\"section-title\">已有计划</div>\n          <div class=\"sched-list\">\n            <div class=\"sched-row\" v-for=\"s in schedules\" :key=\"s.id\">\n              <div class=\"meta\">\n                <span class=\"badge\" :class=\"{ on: s.enabled, off: !s.enabled }\">{{ s.type }}</span>\n                <span class=\"desc\">{{ describe(s) }}</span>\n              </div>\n              <div class=\"actions\">\n                <button class=\"small danger\" @click=\"$emit('remove', s.id)\">删除</button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n      <div class=\"rr-footer\">\n        <button class=\"primary\" @click=\"save\">保存</button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watch } from 'vue';\n\nconst props = defineProps<{ visible: boolean; flowId: string | null; schedules: any[] }>();\nconst emit = defineEmits(['close', 'save', 'remove']);\n\nconst enabled = ref(true);\nconst type = ref<'interval' | 'daily' | 'once'>('interval');\nconst intervalMinutes = ref(30);\nconst dailyTime = ref('09:00');\nconst onceAt = ref('');\nconst argsJson = ref('');\n\nwatch(\n  () => props.visible,\n  (v) => {\n    if (v) {\n      enabled.value = true;\n      type.value = 'interval';\n      intervalMinutes.value = 30;\n      dailyTime.value = '09:00';\n      onceAt.value = '';\n      argsJson.value = '';\n    }\n  },\n);\n\nfunction save() {\n  if (!props.flowId) return;\n  const schedule = {\n    id: `sch_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n    flowId: props.flowId,\n    type: type.value,\n    enabled: enabled.value,\n    when:\n      type.value === 'interval'\n        ? String(intervalMinutes.value)\n        : type.value === 'daily'\n          ? dailyTime.value\n          : onceAt.value,\n    args: safeParse(argsJson.value),\n  } as any;\n  emit('save', schedule);\n}\n\nfunction safeParse(s: string) {\n  if (!s || !s.trim()) return {};\n  try {\n    return JSON.parse(s);\n  } catch {\n    return {};\n  }\n}\n\nfunction describe(s: any) {\n  if (s.type === 'interval') return `每 ${s.when} 分钟`;\n  if (s.type === 'daily') return `每天 ${s.when}`;\n  if (s.type === 'once') return `一次 ${s.when}`;\n  return '';\n}\n</script>\n\n<style scoped>\n.rr-modal {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.35);\n  z-index: 2147483646;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n.rr-dialog {\n  background: #fff;\n  border-radius: 8px;\n  max-width: 720px;\n  width: 96vw;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n}\n.rr-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 12px 16px;\n  border-bottom: 1px solid #e5e7eb;\n}\n.rr-header .title {\n  font-weight: 600;\n}\n.rr-header .close {\n  border: none;\n  background: #f3f4f6;\n  border-radius: 6px;\n  padding: 4px 8px;\n  cursor: pointer;\n}\n.rr-body {\n  padding: 12px 16px;\n  overflow: auto;\n}\n.row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n  margin: 6px 0;\n}\n.row > label {\n  width: 120px;\n  color: #374151;\n}\n.row > input,\n.row > textarea,\n.row > select {\n  flex: 1;\n  border: 1px solid #d1d5db;\n  border-radius: 6px;\n  padding: 6px 8px;\n}\n.row > textarea {\n  min-height: 64px;\n}\n.chk {\n  display: inline-flex;\n  gap: 6px;\n  align-items: center;\n}\n.sched-list .sched-row {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 6px 8px;\n  border: 1px solid #e5e7eb;\n  border-radius: 6px;\n  margin: 4px 0;\n}\n.badge {\n  padding: 2px 6px;\n  border-radius: 6px;\n  background: #e5e7eb;\n}\n.badge.on {\n  background: #dcfce7;\n}\n.badge.off {\n  background: #fee2e2;\n}\n.small {\n  font-size: 12px;\n  padding: 4px 8px;\n  border: 1px solid #d1d5db;\n  background: #fff;\n  border-radius: 6px;\n  cursor: pointer;\n}\n.danger {\n  background: #fee2e2;\n  border-color: #fecaca;\n}\n.primary {\n  background: #111;\n  color: #fff;\n  border: none;\n  border-radius: 6px;\n  padding: 8px 16px;\n  cursor: pointer;\n}\n.rr-footer {\n  padding: 12px 16px;\n  border-top: 1px solid #e5e7eb;\n  display: flex;\n  justify-content: flex-end;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/Canvas.vue",
    "content": "<template>\n  <section class=\"canvas rr-dot-grid\">\n    <VueFlow\n      v-model:nodes=\"vfNodes\"\n      v-model:edges=\"vfEdges\"\n      :min-zoom=\"0.2\"\n      :max-zoom=\"1.5\"\n      :fit-view-on-init=\"true\"\n      :node-types=\"nodeTypes\"\n      snap-to-grid\n      :snap-grid=\"[24, 24]\"\n      @connect=\"onConnectInternal\"\n      @node-drag-stop=\"onNodeDragStopInternal\"\n      @dragover.prevent=\"onDragOver\"\n      @drop=\"onDrop\"\n      @pane-click=\"onPaneClick\"\n      @edge-click=\"onEdgeClick\"\n    >\n      <Background patternColor=\"#cdcdcd\" :gap=\"32\" pattern-class=\"canvas-pattern\" />\n    </VueFlow>\n  </section>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watch, watchEffect, markRaw } from 'vue';\nimport {\n  VueFlow,\n  type Node as VFNode,\n  type Edge as VFEdge,\n  type Connection,\n  useVueFlow,\n} from '@vue-flow/core';\nimport { Background } from '@vue-flow/background';\nimport '@vue-flow/core/dist/style.css';\nimport '@vue-flow/core/dist/theme-default.css';\n// Note: background package doesn't expose style.css via exports in Vite 6.\n// The component works without its dedicated CSS; keep core/minimap/controls styles.\n\nimport type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';\nimport NodeCard from './nodes/NodeCard.vue';\nimport NodeIf from './nodes/NodeIf.vue';\nimport { NODE_UI_LIST, canvasTypeKey } from '@/entrypoints/popup/components/builder/model/ui-nodes';\nimport { EDGE_LABELS } from 'chrome-mcp-shared';\n\nconst props = defineProps<{\n  nodes: NodeBase[];\n  edges: EdgeV2[];\n  nodeErrors?: Record<string, string[]>;\n  focusNodeId?: string | null;\n  fitSeq?: number;\n}>();\nconst emit = defineEmits<{\n  (e: 'selectNode', id: string | null): void;\n  (e: 'selectEdge', id: string | null): void;\n  (e: 'duplicateNode', id: string): void;\n  (e: 'removeNode', id: string): void;\n  (e: 'connectFrom', id: string, label: 'default' | 'true' | 'false' | 'onError'): void;\n  (e: 'connect', src: string, dst: string, label?: string): void;\n  (e: 'nodeDragged', id: string, x: number, y: number): void;\n  (e: 'addNodeAt', type: string, x: number, y: number): void;\n}>();\n\nconst vfNodes = ref<VFNode[]>([]);\nconst vfEdges = ref<VFEdge[]>([]);\ndefineOptions({ name: 'BuilderCanvas' });\nconst api = useVueFlow();\nconst { fitView, getNodes, project } = api;\n\n// Map our custom types to components for VueFlow via registry\nconst nodeTypes = (() => {\n  const base: Record<string, any> = {};\n  for (const n of NODE_UI_LIST) {\n    const key = canvasTypeKey(n.type);\n    // fallback: if a type doesn't specify a special canvas component, use NodeCard/NodeIf\n    const comp = n.canvas || (n.type === 'if' ? (NodeIf as any) : (NodeCard as any));\n    // Avoid making component instances reactive; VueFlow expects raw component refs\n    base[key] = markRaw(comp);\n  }\n  return base;\n})();\n\nwatchEffect(() => {\n  // Build VueFlow nodes; attach node + edges to data for custom components\n  const list = props.nodes || [];\n  const edgesRef = props.edges || [];\n  vfNodes.value = list.map((n) => ({\n    id: n.id,\n    position: { x: n.ui?.x || 0, y: n.ui?.y || 0 },\n    type: canvasTypeKey(n.type as any),\n    data: {\n      node: n,\n      edges: edgesRef,\n      onSelect: (id: string) => emit('selectNode', id),\n      errors: (props.nodeErrors || ({} as any))[n.id] || [],\n    },\n    class: 'rr-node-plain',\n  }));\n});\nwatchEffect(() => {\n  // Map edges reactively; tracks length and label/style updates\n  const list = props.edges || [];\n  const textFor = (lab?: string) => {\n    const l = lab || 'default';\n    if (l === EDGE_LABELS.TRUE) return '✓';\n    if (l === EDGE_LABELS.FALSE) return '✗';\n    if (l === EDGE_LABELS.ON_ERROR) return '!';\n    return '';\n  };\n  const labelFor = (e: any) => {\n    const raw = String(e?.label || '');\n    // Branch label: case:<id> -> resolve to branch name on source node\n    if (raw.startsWith('case:')) {\n      // For conditional branches, do not render edge labels per UX requirement\n      return '';\n    }\n    if (raw === 'else') return '';\n    return textFor(raw);\n  };\n  vfEdges.value = list.map((e) => ({\n    id: e.id,\n    source: e.from,\n    target: e.to,\n    // Keep VueFlow aware of which specific handle an edge originates from\n    // so that multiple branch edges do not collapse onto the default handle.\n    sourceHandle:\n      typeof e.label === 'string' && e.label.startsWith('case:') ? String(e.label) : undefined,\n    label: labelFor(e),\n    labelShowBg: true,\n    labelBgPadding: [4, 6],\n    labelBgStyle: { fill: '#e5e5e5', fillOpacity: 0.95, stroke: '#ffffff', strokeWidth: 1 },\n    labelStyle: { fill: '#666666', fontWeight: 600, fontSize: 11 },\n    style: {\n      stroke: '#cdcdcd',\n      strokeWidth: 1.5,\n    },\n    animated: false,\n    // Use bezier to draw smooth curves between nodes\n    type: 'bezier',\n  }));\n});\n\nwatch(\n  () => props.focusNodeId,\n  (id) => {\n    if (!id) return;\n    const nd = getNodes.value.find((n) => n.id === id);\n    if (!nd) return;\n    try {\n      fitView({ nodes: [nd.id], duration: 300, padding: 0.2 });\n    } catch {}\n  },\n);\n\nwatch(\n  () => props.fitSeq,\n  () => {\n    try {\n      fitView({ duration: 300, padding: 0.2 });\n    } catch {}\n  },\n);\n\n// if node helpers (for labeling)\nfunction getIfBranches(node: any): Array<{ id: string; name?: string; expr?: string }> {\n  try {\n    const arr = (node?.config?.branches || []) as Array<any>;\n    return Array.isArray(arr)\n      ? arr.map((x: any) => ({ id: String(x.id || ''), name: x.name, expr: x.expr }))\n      : [];\n  } catch {\n    return [];\n  }\n}\n// (no additional helpers required in Canvas after node-type split)\n\nfunction onNodeDragStopInternal(evt: any) {\n  const node = evt?.node as VFNode | undefined;\n  if (!node) return;\n  emit('nodeDragged', node.id, Math.round(node.position.x), Math.round(node.position.y));\n}\n\nfunction onConnectInternal(conn: Connection) {\n  if (!conn.source || !conn.target) return;\n  // Prefer sourceHandle as label so conditional branches can be identified\n  const lab = (conn as any).sourceHandle || 'default';\n  emit('connect', conn.source, conn.target, String(lab));\n  // 边更新由上层状态驱动，这里无需直接修改本地 vfEdges\n}\n\nfunction onDragOver(e: DragEvent) {\n  // Hint browser/OS we are copying an item into the canvas\n  try {\n    if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';\n  } catch {}\n}\n\nfunction onDrop(e: DragEvent) {\n  // Prevent default to ensure drop is handled by our canvas\n  try {\n    e.preventDefault();\n  } catch {}\n  const dt = e.dataTransfer;\n  // Read from multiple types for robustness across environments\n  const type = (\n    dt?.getData('application/node-type') ||\n    dt?.getData('text/node-type') ||\n    dt?.getData('text/plain') ||\n    ''\n  ).trim();\n  if (!type) return;\n  // translate screen to flow coords\n  try {\n    const pos = project({ x: e.clientX, y: e.clientY } as any) as any;\n    emit('addNodeAt', type, Math.round(pos.x || 0), Math.round(pos.y || 0));\n  } catch {\n    emit('addNodeAt', type, 200, 120);\n  }\n}\n\nfunction onPaneClick() {\n  // Deselect when clicking empty canvas area\n  emit('selectNode', null);\n  emit('selectEdge', null);\n}\n\nfunction onEdgeClick(evt: any) {\n  try {\n    const id = evt?.edge?.id || null;\n    emit('selectEdge', id ? String(id) : null);\n  } catch {\n    emit('selectEdge', null);\n  }\n}\n\n// Expose zoom helpers for external toolbar\nfunction zoomIn() {\n  try {\n    (api as any).zoomIn?.();\n  } catch {}\n}\nfunction zoomOut() {\n  try {\n    (api as any).zoomOut?.();\n  } catch {}\n}\nfunction fitAll() {\n  try {\n    fitView({ duration: 300, padding: 0.2 });\n  } catch {}\n}\ndefineExpose({ zoomIn, zoomOut, fitAll });\n</script>\n\n<style scoped>\n.canvas {\n  position: relative;\n  overflow: hidden;\n  /* Use fixed background as requested */\n  background: #ededed;\n  /* Ensure VueFlow gets a non-zero layout size */\n  width: 100%;\n  height: 100%;\n}\n\n:deep(.workflow-node) {\n  max-width: 400px;\n  background: #fff;\n  border: 1px solid var(--rr-border);\n  border-radius: 16px;\n  /* Requested node spacing */\n  padding: 10px 16px 10px 10px;\n  /* Text look */\n  color: #8f8f8f;\n  font-size: 12px;\n  /* Interaction */\n  transition:\n    box-shadow 0.15s var(--cubic-enter, cubic-bezier(0.4, 0, 0.2, 1)),\n    background-color 1s var(--cubic-enter, cubic-bezier(0.4, 0, 0.2, 1));\n  cursor: pointer;\n  position: relative;\n}\n\n/* Per-node error indicator (shield-x) */\n:deep(.node-error) {\n  position: absolute;\n  top: -12px;\n  right: 3px;\n  width: 12px;\n  height: 12px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--rr-danger, #ef4444);\n  cursor: help;\n  z-index: 5;\n}\n\n/* Tooltip for error details */\n:deep(.node-error .tooltip) {\n  display: none;\n  position: absolute;\n  top: 22px;\n  right: 0;\n  max-width: 280px;\n  padding: 8px 10px;\n  border-radius: 8px;\n  border: 1px solid var(--rr-border, #e5e7eb);\n  background: var(--rr-card, #fff);\n  color: var(--rr-text, #111827);\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);\n  font-size: 12px;\n  line-height: 1.4;\n  white-space: normal;\n}\n:deep(.node-error:hover .tooltip) {\n  display: block;\n}\n:deep(.node-error .tooltip .item) {\n  color: var(--rr-danger, #ef4444);\n}\n\n:deep(.workflow-node:hover) {\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);\n  border-color: rgba(0, 0, 0, 0.06);\n}\n\n:deep(.workflow-node.selected) {\n  /* Remove current border color and use subtle ring */\n  border-color: transparent !important;\n  box-shadow: 0 0 0 1px #afafaf;\n}\n\n/* 节点容器 */\n:deep(.node-container) {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  /* Padding moved to .workflow-node to match requested style */\n  padding: 0;\n}\n\n/* Node icon: keep container size; shrink inner icon via font-size */\n:deep(.node-icon) {\n  width: 28px;\n  height: 28px;\n  border-radius: 8px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  color: #fff;\n  font-size: 14px; /* inner svg is 1em; smaller but container unchanged */\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n}\n\n/* 图标颜色方案 - 参考图片风格 */\n/* Solid color icon backgrounds (no gradients) */\n:deep(.icon-navigate) {\n  background: #667eea;\n}\n:deep(.icon-click) {\n  background: #f5576c;\n}\n:deep(.icon-fill) {\n  background: #4facfe;\n}\n:deep(.icon-wait) {\n  background: #43e97b;\n}\n:deep(.icon-extract) {\n  background: #fa709a;\n}\n:deep(.icon-http) {\n  background: #30cfd0;\n}\n:deep(.icon-script) {\n  background: #a8edea;\n  color: #111;\n}\n:deep(.icon-screenshot) {\n  background: #06b6d4;\n}\n:deep(.icon-trigger) {\n  background: #f59e0b;\n}\n:deep(.icon-attr) {\n  background: #8b5cf6;\n}\n:deep(.icon-loop) {\n  background: #22c55e;\n}\n:deep(.icon-frame) {\n  background: #64748b;\n}\n:deep(.icon-download) {\n  background: #34d399;\n}\n:deep(.icon-if) {\n  background: #ff9a56;\n}\n:deep(.icon-foreach),\n:deep(.icon-while) {\n  background: #fcb69f;\n  color: #111;\n}\n:deep(.icon-assert) {\n  background: #16a34a;\n}\n:deep(.icon-key) {\n  background: #8ec5fc;\n  color: #111;\n}\n:deep(.icon-dblclick) {\n  background: #fe5196;\n}\n:deep(.icon-drag) {\n  background: #f97316;\n}\n:deep(.icon-scroll) {\n  background: #0ea5e9;\n}\n:deep(.icon-openTab),\n:deep(.icon-switchTab),\n:deep(.icon-closeTab) {\n  background: #96fbc4;\n  color: #111;\n}\n:deep(.icon-delay) {\n  background: #f6d365;\n  color: #111;\n}\n\n/* Missing canvas classes for tool node types whose icon class is based on type */\n:deep(.icon-triggerEvent) {\n  background: #f59e0b;\n}\n:deep(.icon-setAttribute) {\n  background: #8b5cf6;\n}\n:deep(.icon-loopElements) {\n  background: #22c55e;\n}\n:deep(.icon-switchFrame) {\n  background: #64748b;\n}\n:deep(.icon-handleDownload) {\n  background: #34d399;\n}\n\n/* 节点主体 */\n:deep(.node-body) {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n}\n\n:deep(.node-name) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #0d0d0d;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  letter-spacing: -0.01em;\n  text-align: left;\n}\n\n:deep(.node-subtitle) {\n  font-size: 10px;\n  color: #8f8f8f;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-align: left;\n}\n\n/* Connection handles */\n:deep(.node-handle.vue-flow__handle) {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  background: #fff;\n  box-shadow: 0 0 0 1px #cdcdcd;\n  transition: all 0.15s ease;\n  will-change: transform;\n}\n\n/* Input (target, left) scales from bottom-right; Output (source, right) scales from bottom-left */\n:deep(.vue-flow__handle-left.node-handle) {\n  transform-origin: bottom right;\n}\n\n:deep(.vue-flow__handle-right.node-handle) {\n  transform-origin: bottom left;\n}\n\n/* Always show unconnected handles (override default theme) */\n:deep(.vue-flow__node .node-handle.unconnected.vue-flow__handle) {\n  opacity: 1 !important;\n}\n\n/* Hide connected handles by default to ensure they only appear on hover */\n:deep(.vue-flow__node .node-handle.connected.vue-flow__handle) {\n  opacity: 0 !important;\n}\n\n/* Show all handles when hovering the whole vue-flow node wrapper */\n:deep(.vue-flow__node:hover .node-handle.vue-flow__handle) {\n  opacity: 1 !important;\n}\n\n/* Hover style on individual handle */\n:deep(.node-handle.vue-flow__handle:hover) {\n  box-shadow: 0 0 0 2px #cdcdcd;\n  transform: scale(1.4);\n}\n\n:deep(.vue-flow__edge.selected .vue-flow__edge-path) {\n  stroke: #8f8f8f !important;\n}\n\n/* 背景网格 */\n:deep(.vue-flow__background) {\n  background-color: #ededed;\n}\n\n/* Override default VueFlow node box to avoid extra white box behind custom node */\n:deep(.vue-flow__node.rr-node-plain) {\n  background: transparent !important;\n  border: none !important;\n  box-shadow: none !important;\n  padding: 0 !important;\n}\n\n:deep(.vue-flow__node.rr-node-plain.selected) {\n  box-shadow: none !important;\n}\n\n/* If/else case list inside node */\n:deep(.if-cases) {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  margin-top: 6px;\n}\n\n:deep(.case-row) {\n  position: relative;\n  height: 26px;\n  border-radius: 6px;\n  background: rgba(0, 0, 0, 0.03);\n  color: #8f8f8f;\n  display: flex;\n  align-items: center;\n  padding: 0 8px;\n}\n\n:deep(.case-row.else-row) {\n  opacity: 0.85;\n}\n\n:deep(.case-label) {\n  font-size: 12px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/EdgePropertyPanel.vue",
    "content": "<template>\n  <aside class=\"property-panel\">\n    <div v-if=\"edge\" class=\"panel-content\">\n      <div class=\"panel-header\">\n        <div>\n          <div class=\"header-title\">Edge</div>\n          <div class=\"header-id\">{{ edge.id }}</div>\n        </div>\n        <button class=\"btn-delete\" type=\"button\" title=\"删除边\" @click.stop=\"onRemove\">\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n            <path\n              d=\"m4 4 8 8M12 4 4 12\"\n              stroke=\"currentColor\"\n              stroke-width=\"1.8\"\n              stroke-linecap=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <div class=\"form-section\">\n        <div class=\"form-group\">\n          <label class=\"form-label\">Source</label>\n          <div class=\"text\">{{ srcName }}</div>\n        </div>\n        <div class=\"form-group\">\n          <label class=\"form-label\">Target</label>\n          <div class=\"text\">{{ dstName }}</div>\n        </div>\n        <div class=\"form-group\">\n          <label class=\"form-label\">Connection status</label>\n          <div class=\"status\" :class=\"{ ok: isValid, bad: !isValid }\">\n            {{ isValid ? 'Valid' : 'Invalid' }}\n          </div>\n        </div>\n        <div class=\"form-group\">\n          <label class=\"form-label\">Branch</label>\n          <div class=\"text\">{{ labelPretty }}</div>\n        </div>\n      </div>\n      <div class=\"divider\"></div>\n\n      <div class=\"form-section\">\n        <div class=\"text-xs text-slate-500\" style=\"padding: 0 20px\">\n          Inspect connection only. Editing of branch/handles will be supported in a later pass.\n        </div>\n      </div>\n    </div>\n    <div v-else class=\"panel-empty\">\n      <div class=\"empty-text\">未选择边</div>\n    </div>\n  </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { Edge as EdgeV2, NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ edge: EdgeV2 | null; nodes: NodeBase[] }>();\nconst emit = defineEmits<{ (e: 'remove-edge', id: string): void }>();\n\nconst src = computed(() => props.nodes?.find?.((n) => n.id === (props.edge as any)?.from) || null);\nconst dst = computed(() => props.nodes?.find?.((n) => n.id === (props.edge as any)?.to) || null);\nconst srcName = computed(() =>\n  src.value ? src.value.name || `${src.value.type} (${src.value.id})` : 'Unknown',\n);\nconst dstName = computed(() =>\n  dst.value ? dst.value.name || `${dst.value.type} (${dst.value.id})` : 'Unknown',\n);\nconst isValid = computed(() => !!(src.value && dst.value && src.value.id !== dst.value.id));\nconst labelPretty = computed(() => {\n  const raw = String((props.edge as any)?.label || 'default');\n  if (raw === 'default') return 'default';\n  if (raw === 'true') return 'true ✓';\n  if (raw === 'false') return 'false ✗';\n  if (raw === 'onError') return 'onError !';\n  if (raw === 'else') return 'else';\n  if (raw.startsWith('case:')) {\n    const id = raw.slice('case:'.length);\n    const ifNode = src.value && (src.value as any).type === 'if' ? (src.value as any) : null;\n    const found = ifNode?.config?.branches?.find?.((b: any) => String(b.id) === id);\n    if (found) return `case: ${found.name || found.expr || id}`;\n    return `case: ${id}`;\n  }\n  return raw;\n});\n\nfunction onRemove() {\n  if (!props.edge) return;\n  emit('remove-edge', props.edge.id);\n}\n</script>\n\n<style scoped>\n.property-panel {\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  border-radius: 16px;\n  margin: 16px;\n  padding: 0;\n  width: 380px;\n  display: flex;\n  flex-direction: column;\n  max-height: calc(100vh - 72px);\n  overflow-y: auto;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n  flex-shrink: 0;\n  scrollbar-width: none;\n  scrollbar-color: rgba(0, 0, 0, 0.25) transparent;\n}\n.panel-content {\n  display: flex;\n  flex-direction: column;\n}\n.panel-header {\n  padding: 12px 12px 12px 20px;\n  border-bottom: 1px solid var(--rr-border);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n.header-title {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--rr-text);\n  margin-bottom: 4px;\n}\n.header-id {\n  font-size: 11px;\n  color: var(--rr-text-weak);\n  font-family: 'Monaco', monospace;\n  opacity: 0.7;\n}\n.btn-delete {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-danger);\n  border-radius: 6px;\n  cursor: pointer;\n}\n.btn-delete:hover {\n  background: rgba(239, 68, 68, 0.08);\n  border-color: rgba(239, 68, 68, 0.3);\n}\n.form-section {\n  padding: 16px 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 14px;\n}\n.form-group {\n  display: grid;\n  grid-template-columns: 110px 1fr;\n  align-items: center;\n  gap: 8px;\n}\n.form-label {\n  color: var(--rr-text-secondary);\n  font-size: 13px;\n  font-weight: 500;\n}\n.text {\n  font-size: 13px;\n}\n.status.ok {\n  color: #059669;\n  font-weight: 600;\n}\n.status.bad {\n  color: #ef4444;\n  font-weight: 600;\n}\n.divider {\n  height: 1px;\n  background: var(--rr-border);\n}\n.panel-empty {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 40px 20px;\n}\n.empty-text {\n  color: var(--rr-text-secondary);\n}\n\n/* Hide scrollbars in WebKit while keeping scrollability */\n.property-panel :deep(::-webkit-scrollbar) {\n  width: 0;\n  height: 0;\n}\n.property-panel :deep(::-webkit-scrollbar-thumb) {\n  background-color: rgba(0, 0, 0, 0.25);\n  border-radius: 6px;\n}\n.property-panel :deep(::-webkit-scrollbar-track) {\n  background: transparent !important;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/KeyValueEditor.vue",
    "content": "<template>\n  <div class=\"kve\">\n    <div v-for=\"(item, i) in rows\" :key=\"i\" class=\"kve-row\">\n      <input class=\"kve-key\" v-model=\"item.k\" placeholder=\"变量名\" />\n      <input class=\"kve-val\" v-model=\"item.v\" placeholder=\"结果路径（如 data.items[0].id）\" />\n      <button class=\"mini\" @click=\"move(i, -1)\" :disabled=\"i === 0\">↑</button>\n      <button class=\"mini\" @click=\"move(i, 1)\" :disabled=\"i === rows.length - 1\">↓</button>\n      <button class=\"mini danger\" @click=\"remove(i)\">删</button>\n    </div>\n    <button class=\"mini\" @click=\"add\">添加映射</button>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { watch, reactive } from 'vue';\n\nconst props = defineProps<{ modelValue: Record<string, string> | undefined }>();\nconst emit = defineEmits(['update:modelValue']);\n\nconst rows = reactive<Array<{ k: string; v: string }>>([]);\n\nfunction syncFromModel() {\n  rows.splice(0, rows.length);\n  const obj = props.modelValue || {};\n  for (const [k, v] of Object.entries(obj)) rows.push({ k, v: String(v) });\n}\nfunction syncToModel() {\n  const out: Record<string, string> = {};\n  for (const r of rows) if (r.k) out[r.k] = r.v || '';\n  emit('update:modelValue', out);\n}\nwatch(() => props.modelValue, syncFromModel, { immediate: true, deep: true });\nwatch(rows, syncToModel, { deep: true });\n\nfunction add() {\n  rows.push({ k: '', v: '' });\n}\nfunction remove(i: number) {\n  rows.splice(i, 1);\n}\nfunction move(i: number, d: number) {\n  const j = i + d;\n  if (j < 0 || j >= rows.length) return;\n  const t = rows[i];\n  rows[i] = rows[j];\n  rows[j] = t;\n}\n</script>\n\n<style scoped>\n.kve {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.kve-row {\n  display: grid;\n  grid-template-columns: 160px 1fr auto auto auto;\n  gap: 6px;\n  align-items: center;\n}\n.kve-key,\n.kve-val {\n  border: 1px solid #d1d5db;\n  border-radius: 6px;\n  padding: 6px;\n}\n.mini {\n  font-size: 12px;\n  padding: 4px 8px;\n  border: 1px solid #d1d5db;\n  background: #fff;\n  border-radius: 6px;\n  cursor: pointer;\n}\n.mini.danger {\n  background: #fee2e2;\n  border-color: #fecaca;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/PropertyPanel.vue",
    "content": "<template>\n  <!-- eslint-disable vue/no-mutating-props -->\n  <aside class=\"property-panel\">\n    <div v-if=\"node\" class=\"panel-content\">\n      <div class=\"panel-header\">\n        <div>\n          <div class=\"header-title\">节点属性</div>\n          <div class=\"header-id\">{{ node.id }}</div>\n        </div>\n        <button class=\"btn-delete\" type=\"button\" title=\"删除节点\" @click.stop=\"onRemove\">\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n            <path\n              d=\"m4 4 8 8M12 4 4 12\"\n              stroke=\"currentColor\"\n              stroke-width=\"1.8\"\n              stroke-linecap=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <div class=\"form-section\">\n        <div class=\"form-group\">\n          <label class=\"form-label\">节点名称</label>\n          <input class=\"form-input\" v-model=\"node.name\" placeholder=\"输入节点名称\" />\n        </div>\n      </div>\n\n      <div class=\"divider\"></div>\n\n      <!-- 属性表单：统一使用 NodeSpec 驱动的表单引擎渲染 -->\n      <PropertyFromSpec\n        v-if=\"node\"\n        :key=\"node.type + ':' + node.id\"\n        :node=\"node\"\n        :variables=\"variables\"\n      />\n      <div class=\"divider\"></div>\n\n      <!-- 通用设置 -->\n      <div class=\"form-section\">\n        <div class=\"section-title\">通用设置</div>\n        <div class=\"form-group\">\n          <label class=\"form-label\">超时 (ms)</label>\n          <input\n            class=\"form-input\"\n            type=\"number\"\n            v-model.number=\"(node.config as any).timeoutMs\"\n            min=\"0\"\n            placeholder=\"默认使用全局超时\"\n          />\n        </div>\n        <div class=\"form-group checkbox-group\">\n          <label class=\"checkbox-label\">\n            <input type=\"checkbox\" v-model=\"(node.config as any).screenshotOnFail\" />\n            <span>失败时截图</span>\n          </label>\n        </div>\n      </div>\n\n      <div v-if=\"nodeErrors.length > 0\" class=\"error-box\">\n        <div class=\"error-title\">⚠️ 配置错误</div>\n        <div v-for=\"e in nodeErrors\" :key=\"e\" class=\"error-item\">{{ e }}</div>\n      </div>\n    </div>\n    <div v-else class=\"panel-empty\">\n      <svg class=\"empty-icon\" width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\">\n        <rect\n          x=\"8\"\n          y=\"8\"\n          width=\"32\"\n          height=\"32\"\n          rx=\"4\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          opacity=\"0.3\"\n        />\n        <path\n          d=\"M18 20h12M18 24h12M18 28h8\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          opacity=\"0.3\"\n        />\n      </svg>\n      <div class=\"empty-text\">选择一个节点<br />查看和编辑属性</div>\n    </div>\n  </aside>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed, watch, onMounted, ref } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { validateNodeWithRegistry } from '@/entrypoints/popup/components/builder/model/ui-nodes';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport PropertyFromSpec from '@/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue';\n\nconst props = defineProps<{\n  node: NodeBase | null;\n  highlightField?: string | null;\n  subflowIds?: string[];\n  variables?: Array<{ key: string }>;\n}>();\nconst emit = defineEmits<{\n  // Use kebab-case event names to match parent listeners\n  (e: 'create-subflow', id: string): void;\n  (e: 'switch-to-subflow', id: string): void;\n  (e: 'remove-node', id: string): void;\n}>();\n\nfunction onRemove() {\n  // Emit remove event only when node exists\n  const n = props.node;\n  if (!n) return;\n  // Emit kebab-case event to match parent template listener\n  emit('remove-node', n.id);\n}\n\nconst waitJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'wait') return '';\n    try {\n      return JSON.stringify(n.config?.condition || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'wait') return;\n    try {\n      n.config = { ...(n.config || {}), condition: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n\nconst assertJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'assert') return '';\n    try {\n      return JSON.stringify(n.config?.assert || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'assert') return;\n    try {\n      n.config = { ...(n.config || {}), assert: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n\nconst ifJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'if') return '';\n    try {\n      return JSON.stringify((n as any).config?.condition || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'if') return;\n    try {\n      (n as any).config = { ...((n as any).config || {}), condition: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n\n// --- if node helpers ---\nconst variables = computed(() => props.variables || []);\nconst ops = ['==', '!=', '>', '>=', '<', '<=', '&&', '||'];\nconst ifBranches = computed<any[]>({\n  get() {\n    const n = props.node as any;\n    if (!n || n.type !== 'if') return [];\n    if (!n.config) n.config = {};\n    if (!Array.isArray(n.config.branches))\n      n.config.branches = [\n        { id: `c_${Math.random().toString(36).slice(2, 6)}`, name: '', expr: '' },\n      ];\n    return n.config.branches;\n  },\n  set(v: any[]) {\n    const n = props.node as any;\n    if (!n || n.type !== 'if') return;\n    n.config.branches = v;\n  },\n});\nconst elseEnabled = computed({\n  get() {\n    const n = props.node as any;\n    if (!n || n.type !== 'if') return true;\n    return n.config?.else !== false;\n  },\n  set(v: boolean) {\n    const n = props.node as any;\n    if (!n || n.type !== 'if') return;\n    n.config = { ...(n.config || {}), else: !!v };\n  },\n});\nfunction addIfCase() {\n  ifBranches.value = [\n    ...ifBranches.value,\n    { id: `c_${Math.random().toString(36).slice(2, 6)}`, name: '', expr: '' },\n  ];\n}\nfunction removeIfCase(i: number) {\n  const arr = [...ifBranches.value];\n  if (arr.length <= 1) {\n    arr[0] = arr[0] || { id: `c_${Math.random().toString(36).slice(2, 6)}` };\n    arr[0].expr = '';\n    arr[0].name = '';\n    ifBranches.value = arr;\n    return;\n  }\n  arr.splice(i, 1);\n  ifBranches.value = arr;\n}\nfunction insertVar(key: string, idx: number) {\n  if (!key) return;\n  const arr = ifBranches.value;\n  const c = arr[idx];\n  if (!c) return;\n  const token = `workflow.${key}`;\n  c.expr = (c.expr ? c.expr + ' ' : '') + token;\n}\nfunction insertOp(op: string, idx: number) {\n  if (!op) return;\n  const arr = ifBranches.value;\n  const c = arr[idx];\n  if (!c) return;\n  c.expr = (c.expr ? c.expr + ' ' : '') + op + ' ';\n}\n\nconst whileJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'while') return '';\n    try {\n      return JSON.stringify((n as any).config?.condition || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'while') return;\n    try {\n      (n as any).config = { ...((n as any).config || {}), condition: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n\nfunction onCreateSubflow() {\n  const id = prompt('请输入新子流ID');\n  if (!id) return;\n  // Emit kebab-case event to match parent template listener\n  emit('create-subflow', id);\n  const n = props.node as any;\n  if (n && n.config) n.config.subflowId = id;\n}\n\nconst nodeErrors = computed(() => (props.node ? validateNodeWithRegistry(props.node) : []));\nconst extractErrors = computed(() => {\n  const n = props.node;\n  if (!n || n.type !== 'extract') return [] as string[];\n  const errs: string[] = [];\n  if (!n.config?.saveAs) errs.push('需填写保存变量名');\n  if (!n.config?.selector && !n.config?.js) errs.push('需提供 selector 或 js');\n  return errs;\n});\nconst switchTabError = computed(() => {\n  const n = props.node;\n  if (!n || n.type !== 'switchTab') return false;\n  return !(n.config?.tabId || n.config?.urlContains || n.config?.titleContains);\n});\n\n// retry helpers mapped into node.config.retry\nconst retryCount = computed(() => Number((props.node as any)?.config?.retry?.count ?? 0));\nconst retryInterval = computed(() => Number((props.node as any)?.config?.retry?.intervalMs ?? 0));\nconst retryBackoff = computed(() => String((props.node as any)?.config?.retry?.backoff ?? 'none'));\nfunction ensureRetry() {\n  const n = props.node;\n  if (!n) return;\n  if (!n.config) n.config = {};\n  if (!n.config.retry) n.config.retry = { count: 0, intervalMs: 0, backoff: 'none' };\n}\nfunction onRetryCount(v: string) {\n  const n = props.node as any;\n  if (!n) return;\n  ensureRetry();\n  n.config.retry.count = Math.max(0, Number(v || 0));\n}\nfunction onRetryInterval(v: string) {\n  const n = props.node as any;\n  if (!n) return;\n  ensureRetry();\n  n.config.retry.intervalMs = Math.max(0, Number(v || 0));\n}\nfunction onRetryBackoff(v: string) {\n  const n = props.node as any;\n  if (!n) return;\n  ensureRetry();\n  n.config.retry.backoff = v === 'exp' ? 'exp' : 'none';\n}\n\nfunction swapCand(arr: any[], i: number, j: number) {\n  if (j < 0 || j >= arr.length) return;\n  const t = arr[i];\n  arr[i] = arr[j];\n  arr[j] = t;\n}\n\n// Element picker integration\nasync function ensurePickerInjected(tabId: number) {\n  try {\n    const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);\n    if (pong && pong.status === 'pong') return;\n  } catch {}\n  try {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: ['inject-scripts/accessibility-tree-helper.js'],\n      world: 'ISOLATED',\n    } as any);\n  } catch (e) {\n    console.warn('inject picker helper failed:', e);\n  }\n}\n\nasync function pickFromPage() {\n  try {\n    if (!props.node) return;\n    const t = props.node.type;\n    if (t !== 'click' && t !== 'fill' && t !== 'triggerEvent' && t !== 'setAttribute') return;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') return;\n    await ensurePickerInjected(tabId);\n    const resp: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);\n    if (!resp || !resp.success) return;\n    const n: any = props.node;\n    if (!n.config) n.config = {};\n    if (!n.config.target) n.config.target = { candidates: [] };\n    if (!Array.isArray(n.config.target.candidates)) n.config.target.candidates = [];\n    const arr = Array.isArray(resp.candidates) ? resp.candidates : [];\n    const seen = new Set<string>();\n    const merged: any[] = [];\n    for (const c of arr) {\n      if (!c || !c.type || !c.value) continue;\n      const key = `${c.type}|${c.value}`;\n      if (!seen.has(key)) {\n        seen.add(key);\n        merged.push({ type: String(c.type), value: String(c.value) });\n      }\n    }\n    n.config.target.candidates = merged;\n  } catch (e) {\n    console.warn('pickFromPage failed:', e);\n  }\n}\n\n// http json helpers\nconst headersJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'http') return '';\n    try {\n      return JSON.stringify(n.config?.headers || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'http') return;\n    try {\n      n.config.headers = JSON.parse(v || '{}');\n    } catch {}\n  },\n});\nconst bodyJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'http') return '';\n    try {\n      return JSON.stringify(n.config?.body ?? null, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'http') return;\n    try {\n      n.config.body = v ? JSON.parse(v) : null;\n    } catch {}\n  },\n});\n\nconst formDataJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'http') return '';\n    try {\n      return (n as any).config?.formData ? JSON.stringify((n as any).config.formData, null, 2) : '';\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'http') return;\n    try {\n      (n as any).config.formData = v ? JSON.parse(v) : undefined;\n    } catch {}\n  },\n});\n\n// script assign json helper\nconst scriptAssignJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'script') return '';\n    try {\n      return JSON.stringify(n.config?.assign || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'script') return;\n    try {\n      n.config.assign = JSON.parse(v || '{}');\n    } catch {}\n  },\n});\n\n// executeFlow args json\nconst execArgsJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'executeFlow') return '';\n    try {\n      return JSON.stringify((n as any).config?.args || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'executeFlow') return;\n    try {\n      (n as any).config.args = v ? JSON.parse(v) : {};\n    } catch {}\n  },\n});\n\n// flows for selection\ntype FlowLite = { id: string; name?: string };\nconst flows = ref<FlowLite[]>([]);\nonMounted(async () => {\n  try {\n    const res = await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS });\n    if (res && res.success) flows.value = res.flows || [];\n  } catch {}\n});\n// 高亮并滚动到指定字段\nwatch(\n  () => props.highlightField,\n  (field) => {\n    if (!field) return;\n    try {\n      const root = (document?.querySelector?.('.panel') as HTMLElement) || null;\n      const esc =\n        (globalThis as any).CSS && typeof (globalThis as any).CSS.escape === 'function'\n          ? (globalThis as any).CSS.escape(field)\n          : String(field).replace(/[\"\\\\]/g, '\\\\$&');\n      const el = (root || document).querySelector(`[data-field=\"${esc}\"]`) as HTMLElement | null;\n      if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' });\n      if (el) {\n        el.classList.add('hl');\n        setTimeout(() => el.classList.remove('hl'), 1200);\n      }\n    } catch {}\n  },\n);\n\n// Scrollbars remain hidden but content is scrollable; no runtime handling needed.\n</script>\n\n<style scoped>\n.property-panel {\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  border-radius: 16px;\n  margin: 16px;\n  padding: 0;\n  width: 380px;\n  display: flex;\n  flex-direction: column;\n  /* Cap panel height to viewport to avoid overflow; scroll internally */\n  max-height: calc(100vh - 72px);\n  overflow-y: auto;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n  flex-shrink: 0;\n  /* Always hide scrollbars (Firefox), keep scrolling */\n  scrollbar-width: none;\n  scrollbar-color: rgba(0, 0, 0, 0.25) transparent;\n}\n\n/* 头部 */\n.panel-header {\n  padding: 12px 12px 12px 20px;\n  border-bottom: 1px solid var(--rr-border);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n.header-title {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--rr-text);\n  margin-bottom: 4px;\n}\n.header-id {\n  font-size: 11px;\n  color: var(--rr-text-weak);\n  font-family: 'Monaco', monospace;\n  opacity: 0.7;\n}\n\n.btn-delete {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-danger);\n  border-radius: 6px;\n  cursor: pointer;\n}\n.btn-delete:hover {\n  background: rgba(239, 68, 68, 0.08);\n  border-color: rgba(239, 68, 68, 0.3);\n}\n\n/* 内容区 */\n.panel-content {\n  display: flex;\n  flex-direction: column;\n}\n\n.panel-content :deep(.if-case-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  padding: 8px 12px;\n}\n.panel-content :deep(.if-case-item) {\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  border-radius: 8px;\n  padding: 8px;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.panel-content :deep(.if-case-header) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.panel-content :deep(.if-case-expr) {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.panel-content :deep(.if-toolbar) {\n  display: flex;\n  gap: 8px;\n}\n.panel-content :deep(.if-case-else) {\n  padding: 6px 12px;\n  color: var(--rr-text-secondary);\n}\n\n/* 表单区域 */\n.panel-content :deep(.form-section) {\n  padding: 16px 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 14px;\n}\n\n/* 区域头部 */\n.panel-content :deep(.section-header) {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n.panel-content :deep(.section-title) {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--rr-text);\n}\n\n/* 表单组 */\n.panel-content :deep(.form-group) {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.panel-content :deep(.form-label) {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--rr-text-secondary);\n}\n\n/* 表单输入 */\n.panel-content :deep(.form-input),\n.panel-content :deep(.form-select),\n.panel-content :deep(.form-textarea) {\n  width: 100%;\n  padding: 8px 12px;\n  border: 1px solid var(--rr-border);\n  border-radius: 8px;\n  background: var(--rr-card);\n  font-size: 14px;\n  color: var(--rr-text);\n  outline: none;\n  transition: all 0.15s;\n}\n.panel-content :deep(.form-input:focus),\n.panel-content :deep(.form-select:focus),\n.panel-content :deep(.form-textarea:focus) {\n  border-color: var(--rr-accent);\n  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);\n}\n.panel-content :deep(.form-input::placeholder),\n.panel-content :deep(.form-textarea::placeholder) {\n  color: var(--rr-text-weak);\n}\n.panel-content :deep(.form-textarea) {\n  resize: vertical;\n  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\n  font-size: 13px;\n  line-height: 1.5;\n}\n.panel-content :deep(.form-group.invalid .form-input),\n.panel-content :deep(.form-group.invalid .form-select) {\n  border-color: var(--rr-danger);\n  background: rgba(239, 68, 68, 0.04);\n}\n\n/* Checkbox */\n.panel-content :deep(.checkbox-group) {\n  padding: 4px 0;\n}\n.panel-content :deep(.checkbox-label) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  font-size: 14px;\n  color: var(--rr-text);\n}\n.panel-content :deep(.checkbox-label input[type='checkbox']) {\n  width: 16px;\n  height: 16px;\n  cursor: pointer;\n}\n\n/* 选择器列表 */\n.panel-content :deep(.selector-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n.panel-content :deep(.selector-item) {\n  display: grid;\n  grid-template-columns: 100px 1fr auto auto auto;\n  gap: 6px;\n  align-items: center;\n}\n.panel-content :deep(.form-input-sm),\n.panel-content :deep(.form-select-sm) {\n  padding: 6px 10px;\n  border: 1px solid var(--rr-border);\n  border-radius: 6px;\n  background: var(--rr-card);\n  font-size: 13px;\n  outline: none;\n  transition: all 0.15s;\n}\n.panel-content :deep(.form-input-sm:focus),\n.panel-content :deep(.form-select-sm:focus) {\n  border-color: var(--rr-accent);\n  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08);\n}\n.flex-1 {\n  flex: 1;\n}\n\n/* 按钮 */\n.panel-content :deep(.btn-sm) {\n  padding: 6px 12px;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text);\n  border-radius: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.panel-content :deep(.btn-sm:hover) {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n}\n.panel-content :deep(.btn-sm.btn-primary) {\n  background: var(--rr-accent);\n  color: #fff;\n  border-color: var(--rr-accent);\n}\n.panel-content :deep(.btn-sm.btn-primary:hover) {\n  background: #2563eb;\n  box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);\n}\n.panel-content :deep(.btn-icon-sm) {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text-secondary);\n  border-radius: 6px;\n  font-size: 12px;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.panel-content :deep(.btn-icon-sm:hover:not(:disabled)) {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n  color: var(--rr-text);\n}\n.panel-content :deep(.btn-icon-sm:disabled) {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n.panel-content :deep(.btn-icon-sm.danger:hover:not(:disabled)) {\n  background: rgba(239, 68, 68, 0.08);\n  border-color: rgba(239, 68, 68, 0.3);\n  color: var(--rr-danger);\n}\n\n/* 分割线 */\n.divider {\n  height: 1px;\n  background: var(--rr-border);\n}\n\n/* 错误提示 */\n.error-box {\n  margin: 0 20px 20px;\n  padding: 12px;\n  background: rgba(239, 68, 68, 0.06);\n  border: 1px solid rgba(239, 68, 68, 0.2);\n  border-radius: 8px;\n}\n.error-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--rr-danger);\n  margin-bottom: 6px;\n}\n.error-item {\n  font-size: 12px;\n  color: var(--rr-danger);\n  line-height: 1.5;\n  margin: 4px 0;\n}\n\n/* 空状态 */\n.panel-empty {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 40px 20px;\n  text-align: center;\n}\n.empty-icon {\n  color: var(--rr-text-weak);\n  margin-bottom: 16px;\n}\n.empty-text {\n  font-size: 14px;\n  color: var(--rr-text-secondary);\n  line-height: 1.6;\n}\n\n/* 高亮字段 */\n.panel-content :where([data-field].hl) {\n  outline: 2px solid var(--rr-warn);\n  background: rgba(245, 158, 11, 0.08);\n  border-radius: 6px;\n  transition: all 0.3s ease;\n}\n\n/* Always hide scrollbar (WebKit/Blink); still scrollable */\n.property-panel :deep(::-webkit-scrollbar) {\n  width: 0;\n  height: 0;\n}\n.property-panel :deep(::-webkit-scrollbar-thumb) {\n  background-color: rgba(0, 0, 0, 0.25);\n  border-radius: 6px;\n}\n.property-panel :deep(::-webkit-scrollbar-track) {\n  background: transparent !important;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/Sidebar.vue",
    "content": "<template>\n  <aside class=\"sidebar\">\n    <div class=\"search-box\">\n      <svg class=\"search-icon\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n        <circle cx=\"7\" cy=\"7\" r=\"4\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n        <path d=\"m10 10 3 3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" />\n      </svg>\n      <input class=\"search-input\" placeholder=\"Insert node...\" v-model=\"q\" />\n    </div>\n\n    <!-- Flow (only show if there are nodes in this category) -->\n    <template v-if=\"filtered.Flow.length > 0\">\n      <div class=\"section-divider\">\n        <span class=\"divider-label\">Flow</span>\n      </div>\n      <div class=\"nodes-section\">\n        <button\n          v-for=\"n in filtered.Flow\"\n          :key=\"n.type\"\n          class=\"node-btn\"\n          draggable=\"true\"\n          @dragstart=\"onDragStart(n.type, $event)\"\n          @click=\"$emit('addNode', n.type)\"\n          :title=\"n.label\"\n        >\n          <div class=\"btn-icon\" :class=\"n.iconClass\">\n            <component :is=\"iconComp(n.type)\" />\n          </div>\n          <span class=\"btn-label\">{{ n.label }}</span>\n        </button>\n      </div>\n    </template>\n\n    <!-- Actions -->\n    <div class=\"nodes-section\">\n      <button\n        v-for=\"n in filtered.Actions\"\n        :key=\"n.type\"\n        class=\"node-btn\"\n        draggable=\"true\"\n        @dragstart=\"onDragStart(n.type, $event)\"\n        @click=\"$emit('addNode', n.type)\"\n        :title=\"n.label\"\n      >\n        <div class=\"btn-icon\" :class=\"n.iconClass\">\n          <component :is=\"iconComp(n.type)\" />\n        </div>\n        <span class=\"btn-label\">{{ n.label }}</span>\n      </button>\n    </div>\n\n    <div class=\"section-divider\">\n      <span class=\"divider-label\">Tools</span>\n    </div>\n\n    <div class=\"nodes-section\">\n      <button\n        v-for=\"n in filtered.Tools\"\n        :key=\"n.type\"\n        class=\"node-btn\"\n        draggable=\"true\"\n        @dragstart=\"onDragStart(n.type, $event)\"\n        @click=\"$emit('addNode', n.type)\"\n        :title=\"n.label\"\n      >\n        <div class=\"btn-icon\" :class=\"n.iconClass\">\n          <component :is=\"iconComp(n.type)\" />\n        </div>\n        <span class=\"btn-label\">{{ n.label }}</span>\n      </button>\n    </div>\n\n    <div class=\"section-divider\">\n      <span class=\"divider-label\">Tabs</span>\n    </div>\n\n    <div class=\"nodes-section\">\n      <button\n        v-for=\"n in filtered.Tabs\"\n        :key=\"n.type\"\n        class=\"node-btn\"\n        draggable=\"true\"\n        @dragstart=\"onDragStart(n.type, $event)\"\n        @click=\"$emit('addNode', n.type)\"\n        :title=\"n.label\"\n      >\n        <div class=\"btn-icon\" :class=\"n.iconClass\">\n          <component :is=\"iconComp(n.type)\" />\n        </div>\n        <span class=\"btn-label\">{{ n.label }}</span>\n      </button>\n    </div>\n\n    <div class=\"section-divider\">\n      <span class=\"divider-label\">Logic</span>\n    </div>\n\n    <div class=\"nodes-section\">\n      <button\n        v-for=\"n in filtered.Logic\"\n        :key=\"n.type\"\n        class=\"node-btn\"\n        draggable=\"true\"\n        @dragstart=\"onDragStart(n.type, $event)\"\n        @click=\"$emit('addNode', n.type)\"\n        :title=\"n.label\"\n      >\n        <div class=\"btn-icon\" :class=\"n.iconClass\">\n          <component :is=\"iconComp(n.type)\" />\n        </div>\n        <span class=\"btn-label\">{{ n.label }}</span>\n      </button>\n    </div>\n  </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\nimport type { Flow as FlowV2, NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { NODE_UI_LIST } from '@/entrypoints/popup/components/builder/model/ui-nodes';\nimport { iconComp } from './nodes/node-util';\n\nconst props = defineProps<{\n  flow: FlowV2;\n  paletteTypes: NodeBase['type'][];\n  subflowIds?: string[];\n  currentSubflowId?: string | null;\n}>();\ndefineEmits<{\n  (e: 'addNode', t: NodeBase['type']): void;\n  (e: 'switchMain'): void;\n  (e: 'switchSubflow', id: string): void;\n  (e: 'addSubflow', id: string): void;\n  (e: 'removeSubflow', id: string): void;\n}>();\ndefineOptions({ name: 'BuilderSidebar' });\n\nfunction onDragStart(t: NodeBase['type'], e: DragEvent) {\n  try {\n    const dt = e.dataTransfer;\n    if (!dt) return;\n    dt.setData('application/node-type', String(t));\n    dt.setData('text/node-type', String(t));\n    dt.setData('text/plain', String(t));\n    dt.effectAllowed = 'copy';\n  } catch {}\n}\n\nconst q = ref('');\nconst filtered = computed(() => {\n  const allow = new Set((props.paletteTypes || []) as string[]);\n  const items = NODE_UI_LIST.filter((n) => allow.size === 0 || allow.has(n.type));\n  const term = q.value.trim().toLowerCase();\n  const list = term\n    ? items.filter(\n        (n) => n.label.toLowerCase().includes(term) || n.type.toLowerCase().includes(term),\n      )\n    : items;\n  return {\n    Flow: list.filter((x) => x.category === 'Flow'),\n    Actions: list.filter((x) => x.category === 'Actions'),\n    Tools: list.filter((x) => x.category === 'Tools'),\n    Tabs: list.filter((x) => x.category === 'Tabs'),\n    Logic: list.filter((x) => x.category === 'Logic'),\n    Page: list.filter((x) => x.category === 'Page'),\n  };\n});\n</script>\n\n<style scoped>\n.sidebar {\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  border-radius: 16px;\n  padding: 16px 12px;\n  margin: 16px;\n  width: 240px;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  overflow-y: auto;\n  /* Ensure the sidebar never exceeds viewport height; allow internal scroll */\n  max-height: calc(100vh - 72px);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n  flex-shrink: 0;\n  /* Always hide scrollbars (Firefox), keep scrolling */\n  scrollbar-width: none;\n  scrollbar-color: rgba(0, 0, 0, 0.25) transparent;\n}\n\n/* 搜索框 */\n.search-box {\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n.search-icon {\n  position: absolute;\n  left: 10px;\n  color: var(--rr-text-weak);\n  pointer-events: none;\n}\n.search-input {\n  width: 100%;\n  padding: 8px 10px 8px 32px;\n  border: 1px solid var(--rr-border);\n  border-radius: 8px;\n  background: var(--rr-subtle);\n  font-size: 13px;\n  outline: none;\n  transition: all 0.15s;\n}\n.search-input:focus {\n  background: var(--rr-card);\n  border-color: var(--rr-text-weak);\n  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.06);\n}\n\n/* 节点区域 */\n.nodes-section {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n/* 节点按钮 */\n.node-btn {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 8px 10px;\n  border: none;\n  background: transparent;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.15s;\n  text-align: left;\n  position: relative;\n}\n\n.node-btn:hover {\n  background: var(--rr-hover);\n}\n\n.node-btn:active {\n  transform: scale(0.98);\n}\n\n/* 节点图标 - 彩色圆形 */\n.btn-icon {\n  width: 30px;\n  height: 30px;\n  border-radius: 8px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  color: #fff;\n}\n\n.icon-navigate {\n  background: #667eea;\n}\n.icon-click {\n  background: #f5576c;\n}\n.icon-fill {\n  background: #4facfe;\n}\n.icon-wait {\n  background: #43e97b;\n}\n.icon-extract {\n  background: #fa709a;\n}\n.icon-http {\n  background: #30cfd0;\n}\n.icon-download {\n  background: #34d399;\n}\n.icon-script {\n  background: #a8edea;\n  color: #111;\n}\n.icon-screenshot {\n  background: #06b6d4;\n}\n.icon-trigger {\n  background: #f59e0b;\n}\n.icon-attr {\n  background: #8b5cf6;\n}\n.icon-loop {\n  background: #22c55e;\n}\n.icon-frame {\n  background: #64748b;\n}\n.icon-exec {\n  background: #111827;\n}\n.icon-key {\n  background: #8ec5fc;\n  color: #111;\n}\n.icon-scroll {\n  background: #0ea5e9;\n}\n.icon-drag {\n  background: #f97316;\n}\n.icon-assert {\n  background: #16a34a;\n}\n.icon-delay {\n  background: #f6d365;\n  color: #111;\n}\n.icon-if {\n  background: #ff9a56;\n}\n.icon-foreach,\n.icon-while {\n  background: #fcb69f;\n  color: #111;\n}\n.icon-openTab,\n.icon-switchTab,\n.icon-closeTab {\n  background: #96fbc4;\n  color: #111;\n}\n\n/* Always hide scrollbar (WebKit/Blink); still scrollable */\n.sidebar :deep(::-webkit-scrollbar) {\n  width: 0;\n  height: 0;\n}\n.sidebar :deep(::-webkit-scrollbar-thumb) {\n  background-color: rgba(0, 0, 0, 0.25);\n  border-radius: 6px;\n}\n.sidebar :deep(::-webkit-scrollbar-track) {\n  background: transparent !important;\n}\n\n/* 节点标签 */\n.btn-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--rr-text);\n  flex: 1;\n}\n\n/* 分割线 */\n.section-divider {\n  display: flex;\n  align-items: center;\n  margin: 12px 0 8px;\n}\n\n.divider-label {\n  font-size: 10px;\n  font-weight: 700;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: var(--rr-text-weak);\n  white-space: nowrap;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/TriggerPanel.vue",
    "content": "/** * @fileoverview Trigger Panel Component for Builder * @description * A floating panel for\nmanaging V3 triggers in the Builder interface. * * Features: * - Lists all triggers for the current\nflow * - Enable/disable toggle for all trigger types * - Create/edit/delete for panel-managed\ntriggers (interval, once) * - Manual trigger support for 'manual' type triggers * * Ownership model:\n* - Node-managed triggers (ID prefix: trg_/sch_): Created by trigger node sync, read-only in panel *\n- Panel-managed triggers (interval, once): Full CRUD in panel */\n<template>\n  <aside class=\"trigger-panel\">\n    <div class=\"panel-header\">\n      <div class=\"header-left\">\n        <div class=\"header-title\">Triggers</div>\n        <div class=\"header-sub\">{{ flowId }}</div>\n      </div>\n      <div class=\"header-right\">\n        <button class=\"btn-sm\" type=\"button\" :disabled=\"loading\" @click=\"refresh\"> Refresh </button>\n        <button class=\"btn-close\" type=\"button\" title=\"Close\" @click=\"emit('close')\">\n          <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n            <path\n              d=\"m4 4 8 8M12 4 4 12\"\n              stroke=\"currentColor\"\n              stroke-width=\"1.8\"\n              stroke-linecap=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n    </div>\n\n    <div class=\"panel-content\">\n      <!-- Create Section (interval/once only) -->\n      <div class=\"form-section\">\n        <div class=\"section-header\">\n          <div class=\"section-title\">Add Trigger</div>\n          <div class=\"section-actions\">\n            <button class=\"btn-sm\" type=\"button\" @click=\"openCreate('interval')\">+ Interval</button>\n            <button class=\"btn-sm\" type=\"button\" @click=\"openCreate('once')\">+ Once</button>\n          </div>\n        </div>\n        <div class=\"hint\">\n          Other types (url/cron/command/contextMenu/dom) are configured via trigger nodes.\n        </div>\n      </div>\n\n      <div class=\"divider\"></div>\n\n      <!-- Trigger List -->\n      <div class=\"form-section\">\n        <div class=\"section-header\">\n          <div class=\"section-title\">Current Triggers ({{ triggers.length }})</div>\n        </div>\n\n        <div v-if=\"loading\" class=\"muted\">Loading...</div>\n        <div v-else-if=\"triggers.length === 0\" class=\"muted\">No triggers configured</div>\n\n        <div v-else class=\"trigger-list\">\n          <div v-for=\"trigger in sortedTriggers\" :key=\"trigger.id\" class=\"trigger-row\">\n            <div class=\"trigger-main\">\n              <div class=\"trigger-top\">\n                <span class=\"badge\" :data-kind=\"trigger.kind\">{{ trigger.kind }}</span>\n                <span class=\"trigger-id\">{{ trigger.id }}</span>\n                <span\n                  v-if=\"ownerOf(trigger) !== 'panel'\"\n                  class=\"ownership\"\n                  :data-owner=\"ownerOf(trigger)\"\n                >\n                  {{ ownerLabel(ownerOf(trigger)) }}\n                </span>\n              </div>\n              <div class=\"trigger-desc\">{{ describeTrigger(trigger) }}</div>\n            </div>\n\n            <div class=\"trigger-actions\">\n              <label\n                class=\"toggle\"\n                :class=\"{ readonly: ownerOf(trigger) === 'triggerNode' }\"\n                :title=\"\n                  ownerOf(trigger) === 'triggerNode' ? 'Edit via trigger node in Builder' : ''\n                \"\n              >\n                <input\n                  type=\"checkbox\"\n                  :checked=\"trigger.enabled\"\n                  :disabled=\"busyIds[trigger.id] || ownerOf(trigger) === 'triggerNode'\"\n                  @change=\"onToggleEnabled(trigger, ($event.target as HTMLInputElement).checked)\"\n                />\n                <span>Enabled</span>\n              </label>\n\n              <button\n                v-if=\"trigger.kind === 'manual'\"\n                class=\"btn-sm btn-primary\"\n                type=\"button\"\n                :disabled=\"busyIds[trigger.id] || !trigger.enabled\"\n                @click=\"fireManual(trigger)\"\n              >\n                Fire\n              </button>\n\n              <template v-if=\"isPanelManaged(trigger)\">\n                <button\n                  class=\"btn-icon-sm\"\n                  type=\"button\"\n                  title=\"Edit\"\n                  :disabled=\"busyIds[trigger.id]\"\n                  @click=\"openEdit(trigger)\"\n                >\n                  <svg\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                  >\n                    <path d=\"M12 20h9\" />\n                    <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z\" />\n                  </svg>\n                </button>\n                <button\n                  class=\"btn-icon-sm danger\"\n                  type=\"button\"\n                  title=\"Delete\"\n                  :disabled=\"busyIds[trigger.id]\"\n                  @click=\"removePanelTrigger(trigger)\"\n                >\n                  <svg\n                    width=\"14\"\n                    height=\"14\"\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"2\"\n                  >\n                    <path\n                      d=\"M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"\n                    />\n                  </svg>\n                </button>\n              </template>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- Editor Modal -->\n    <div v-if=\"editorOpen\" class=\"rr-modal\" @click.self=\"closeEditor\">\n      <div class=\"rr-dialog small\">\n        <div class=\"rr-header\">\n          <div class=\"title\">{{ editorTitle }}</div>\n          <button class=\"close\" type=\"button\" @click=\"closeEditor\">\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n              <path\n                d=\"m4 4 8 8M12 4 4 12\"\n                stroke=\"currentColor\"\n                stroke-width=\"1.8\"\n                stroke-linecap=\"round\"\n              />\n            </svg>\n          </button>\n        </div>\n        <div class=\"rr-body\">\n          <div class=\"form-group\">\n            <label class=\"form-label\">Type</label>\n            <select class=\"form-select\" v-model=\"editorKind\" :disabled=\"editorMode === 'edit'\">\n              <option value=\"interval\">interval</option>\n              <option value=\"once\">once</option>\n            </select>\n          </div>\n          <div class=\"form-group checkbox-group\">\n            <label class=\"checkbox-label\">\n              <input type=\"checkbox\" v-model=\"editorEnabled\" />\n              <span>Enabled</span>\n            </label>\n          </div>\n\n          <template v-if=\"editorKind === 'interval'\">\n            <div class=\"form-group\">\n              <label class=\"form-label\">Interval (minutes)</label>\n              <input\n                class=\"form-input\"\n                type=\"number\"\n                min=\"1\"\n                step=\"1\"\n                v-model.number=\"editorPeriodMinutes\"\n              />\n            </div>\n            <div class=\"hint\">Uses chrome.alarms.periodInMinutes for repeating triggers.</div>\n          </template>\n\n          <template v-else>\n            <div class=\"form-group\">\n              <label class=\"form-label\">Trigger Time</label>\n              <input class=\"form-input\" type=\"datetime-local\" v-model=\"editorWhenLocal\" />\n            </div>\n            <div class=\"hint\"> Will auto-disable after firing. Time is in local timezone. </div>\n          </template>\n        </div>\n        <div class=\"rr-footer\">\n          <button class=\"btn-cancel\" type=\"button\" @click=\"closeEditor\">Cancel</button>\n          <button class=\"btn-primary\" type=\"button\" :disabled=\"editorSaving\" @click=\"submitEditor\">\n            Save\n          </button>\n        </div>\n      </div>\n    </div>\n  </aside>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, watch } from 'vue';\n\nimport type { FlowId, TriggerId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';\nimport type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport { useRRV3Rpc } from '@/entrypoints/shared/composables';\nimport { toast } from '@/entrypoints/popup/components/builder/model/toast';\n\n// ==================== Types ====================\n\ntype PanelEditableKind = 'interval' | 'once';\ntype TriggerOwner = 'panel' | 'triggerNode' | 'external';\n\n// ==================== Props & Emits ====================\n\nconst props = defineProps<{\n  flowId: string;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'close'): void;\n}>();\n\ndefineOptions({ name: 'TriggerPanel' });\n\n// ==================== RPC & State ====================\n\nconst rpc = useRRV3Rpc({ autoConnect: true });\n\nconst loading = ref(false);\nconst triggers = ref<TriggerSpec[]>([]);\nconst busyIds = ref<Record<string, boolean>>({});\n\nconst sortedTriggers = computed(() => {\n  return [...triggers.value].sort((a, b) => {\n    const kindOrder = a.kind.localeCompare(b.kind);\n    if (kindOrder !== 0) return kindOrder;\n    return a.id.localeCompare(b.id);\n  });\n});\n\n// ==================== Editor State ====================\n\nconst editorOpen = ref(false);\nconst editorSaving = ref(false);\nconst editorMode = ref<'create' | 'edit'>('create');\nconst editorKind = ref<PanelEditableKind>('interval');\nconst editorEditingId = ref<TriggerId | null>(null);\nconst editorEnabled = ref(true);\nconst editorPeriodMinutes = ref(5);\nconst editorWhenLocal = ref('');\n\nconst editorTitle = computed(() => {\n  const mode = editorMode.value === 'create' ? 'Create' : 'Edit';\n  return `${mode} ${editorKind.value} Trigger`;\n});\n\n// ==================== Utilities ====================\n\nfunction setBusy(triggerId: string, value: boolean): void {\n  busyIds.value = { ...busyIds.value, [triggerId]: value };\n}\n\nfunction formatLocalDateTime(ms: number): string {\n  const date = new Date(ms);\n  if (!Number.isFinite(date.getTime())) return String(ms);\n  return date.toLocaleString();\n}\n\nfunction pad2(value: number): string {\n  return String(value).padStart(2, '0');\n}\n\nfunction unixMsToDatetimeLocal(ms: number): string {\n  const date = new Date(ms);\n  if (!Number.isFinite(date.getTime())) return '';\n  const year = date.getFullYear();\n  const month = pad2(date.getMonth() + 1);\n  const day = pad2(date.getDate());\n  const hour = pad2(date.getHours());\n  const minute = pad2(date.getMinutes());\n  return `${year}-${month}-${day}T${hour}:${minute}`;\n}\n\nfunction datetimeLocalToUnixMs(value: string): number | null {\n  const raw = String(value || '').trim();\n  const match = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2})(?::(\\d{2}))?$/);\n  if (!match) return null;\n  const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr] = match;\n  const date = new Date(\n    Number(yearStr),\n    Number(monthStr) - 1,\n    Number(dayStr),\n    Number(hourStr),\n    Number(minuteStr),\n    Number(secondStr || 0),\n    0,\n  );\n  const ms = date.getTime();\n  return Number.isFinite(ms) ? ms : null;\n}\n\n// ==================== Trigger Ownership ====================\n\nfunction isPanelManaged(trigger: TriggerSpec): boolean {\n  return trigger.kind === 'interval' || trigger.kind === 'once';\n}\n\nfunction ownerOf(trigger: TriggerSpec): TriggerOwner {\n  const flowId = String(props.flowId || '');\n  const trigPrefix = `trg_${flowId}_`;\n  const schPrefix = `sch_${flowId}_`;\n\n  if (trigger.id.startsWith(trigPrefix) || trigger.id.startsWith(schPrefix)) {\n    return 'triggerNode';\n  }\n  if (isPanelManaged(trigger)) {\n    return 'panel';\n  }\n  return 'external';\n}\n\nfunction ownerLabel(owner: TriggerOwner): string {\n  switch (owner) {\n    case 'triggerNode':\n      return 'via trigger node';\n    case 'external':\n      return 'external';\n    default:\n      return '';\n  }\n}\n\n// ==================== Trigger Description ====================\n\nfunction describeTrigger(trigger: TriggerSpec): string {\n  switch (trigger.kind) {\n    case 'url': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'url' }>;\n      const rules = spec.match || [];\n      return `URL match rules: ${rules.length}`;\n    }\n    case 'cron': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'cron' }>;\n      return spec.timezone ? `cron: ${spec.cron} (${spec.timezone})` : `cron: ${spec.cron}`;\n    }\n    case 'interval': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;\n      return `Every ${spec.periodMinutes} minute(s)`;\n    }\n    case 'once': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;\n      return `At ${formatLocalDateTime(Number(spec.whenMs))}`;\n    }\n    case 'command': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'command' }>;\n      return `commandKey: ${spec.commandKey}`;\n    }\n    case 'contextMenu': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'contextMenu' }>;\n      return `title: ${spec.title}`;\n    }\n    case 'dom': {\n      const spec = trigger as Extract<TriggerSpec, { kind: 'dom' }>;\n      return `selector: ${spec.selector}`;\n    }\n    case 'manual':\n      return 'Manual trigger (fire via button)';\n    default:\n      return '';\n  }\n}\n\n// ==================== Data Actions ====================\n\nasync function refresh(): Promise<void> {\n  const flowId = String(props.flowId || '').trim();\n  if (!flowId) return;\n\n  loading.value = true;\n  try {\n    await rpc.ensureConnected();\n    const result = (await rpc.request('rr_v3.listTriggers', {\n      flowId: flowId as FlowId,\n    })) as TriggerSpec[] | null;\n    triggers.value = Array.isArray(result) ? result : [];\n  } catch (e) {\n    toast(e instanceof Error ? e.message : String(e), 'error');\n  } finally {\n    loading.value = false;\n  }\n}\n\nasync function onToggleEnabled(trigger: TriggerSpec, enabled: boolean): Promise<void> {\n  if (busyIds.value[trigger.id]) return;\n  setBusy(trigger.id, true);\n\n  try {\n    // Node-managed triggers have toggle disabled, so this only applies to panel-managed\n    await rpc.ensureConnected();\n    const method = enabled ? 'rr_v3.enableTrigger' : 'rr_v3.disableTrigger';\n    await rpc.request(method, { triggerId: trigger.id as TriggerId });\n    await refresh();\n  } catch (e) {\n    toast(e instanceof Error ? e.message : String(e), 'error');\n  } finally {\n    setBusy(trigger.id, false);\n  }\n}\n\nasync function fireManual(trigger: TriggerSpec): Promise<void> {\n  if (trigger.kind !== 'manual') return;\n  if (busyIds.value[trigger.id]) return;\n  setBusy(trigger.id, true);\n\n  try {\n    await rpc.ensureConnected();\n    const result = (await rpc.request('rr_v3.fireTrigger', {\n      triggerId: trigger.id as TriggerId,\n    })) as { runId?: string } | null;\n    toast(`Triggered: ${result?.runId ?? 'run enqueued'}`, 'info');\n  } catch (e) {\n    toast(e instanceof Error ? e.message : String(e), 'error');\n  } finally {\n    setBusy(trigger.id, false);\n  }\n}\n\n// ==================== Editor Actions ====================\n\nfunction openCreate(kind: PanelEditableKind): void {\n  editorMode.value = 'create';\n  editorKind.value = kind;\n  editorEditingId.value = null;\n  editorEnabled.value = true;\n  editorPeriodMinutes.value = 5;\n  editorWhenLocal.value = unixMsToDatetimeLocal(Date.now() + 5 * 60 * 1000);\n  editorOpen.value = true;\n}\n\nfunction openEdit(trigger: TriggerSpec): void {\n  if (!isPanelManaged(trigger)) return;\n  editorMode.value = 'edit';\n  editorKind.value = trigger.kind as PanelEditableKind;\n  editorEditingId.value = trigger.id as TriggerId;\n  editorEnabled.value = !!trigger.enabled;\n\n  if (trigger.kind === 'interval') {\n    const spec = trigger as Extract<TriggerSpec, { kind: 'interval' }>;\n    editorPeriodMinutes.value = Number(spec.periodMinutes) || 1;\n  } else {\n    const spec = trigger as Extract<TriggerSpec, { kind: 'once' }>;\n    editorWhenLocal.value = unixMsToDatetimeLocal(Number(spec.whenMs));\n  }\n  editorOpen.value = true;\n}\n\nfunction closeEditor(): void {\n  if (editorSaving.value) return;\n  editorOpen.value = false;\n}\n\nasync function submitEditor(): Promise<void> {\n  if (editorSaving.value) return;\n\n  const flowId = String(props.flowId || '').trim();\n  if (!flowId) {\n    toast('Flow ID is empty', 'error');\n    return;\n  }\n\n  editorSaving.value = true;\n  try {\n    await rpc.ensureConnected();\n\n    let payload: Record<string, unknown>;\n\n    if (editorKind.value === 'interval') {\n      const periodMinutes = Math.max(1, Math.floor(Number(editorPeriodMinutes.value || 1)));\n      payload = {\n        kind: 'interval',\n        enabled: !!editorEnabled.value,\n        flowId: flowId as FlowId,\n        periodMinutes,\n      };\n      if (editorEditingId.value) {\n        payload.id = editorEditingId.value;\n      }\n    } else {\n      const whenMs = datetimeLocalToUnixMs(editorWhenLocal.value);\n      if (whenMs === null) {\n        toast('Invalid trigger time format', 'error');\n        return;\n      }\n      if (whenMs < Date.now()) {\n        toast('Trigger time is in the past. It may fire immediately.', 'warn');\n      }\n      payload = {\n        kind: 'once',\n        enabled: !!editorEnabled.value,\n        flowId: flowId as FlowId,\n        whenMs,\n      };\n      if (editorEditingId.value) {\n        payload.id = editorEditingId.value;\n      }\n    }\n\n    if (editorMode.value === 'create') {\n      await rpc.request('rr_v3.createTrigger', { trigger: payload as unknown as JsonObject });\n    } else {\n      await rpc.request('rr_v3.updateTrigger', { trigger: payload as unknown as JsonObject });\n    }\n\n    editorOpen.value = false;\n    await refresh();\n  } catch (e) {\n    toast(e instanceof Error ? e.message : String(e), 'error');\n  } finally {\n    editorSaving.value = false;\n  }\n}\n\nasync function removePanelTrigger(trigger: TriggerSpec): Promise<void> {\n  if (!isPanelManaged(trigger)) return;\n\n  const confirmed = confirm(`Delete trigger?\\n\\n${trigger.id}`);\n  if (!confirmed) return;\n\n  if (busyIds.value[trigger.id]) return;\n  setBusy(trigger.id, true);\n\n  try {\n    await rpc.ensureConnected();\n    await rpc.request('rr_v3.deleteTrigger', { triggerId: trigger.id });\n    await refresh();\n  } catch (e) {\n    toast(e instanceof Error ? e.message : String(e), 'error');\n  } finally {\n    setBusy(trigger.id, false);\n  }\n}\n\n// ==================== Lifecycle ====================\n\nwatch(\n  () => props.flowId,\n  () => {\n    void refresh();\n  },\n  { immediate: true },\n);\n</script>\n\n<style scoped>\n.trigger-panel {\n  background: var(--rr-card);\n  border: 1px solid var(--rr-border);\n  border-radius: 16px;\n  margin: 16px;\n  padding: 0;\n  width: 420px;\n  max-height: calc(100vh - 72px);\n  overflow-y: auto;\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);\n  scrollbar-width: none;\n}\n.trigger-panel::-webkit-scrollbar {\n  width: 0;\n  height: 0;\n}\n\n/* Header */\n.panel-header {\n  padding: 12px 12px 12px 20px;\n  border-bottom: 1px solid var(--rr-border);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n.header-title {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--rr-text);\n  margin-bottom: 4px;\n}\n.header-sub {\n  font-size: 11px;\n  color: var(--rr-text-weak);\n  font-family: 'Monaco', monospace;\n  opacity: 0.7;\n  word-break: break-all;\n}\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.btn-close {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text-secondary);\n  border-radius: 6px;\n  cursor: pointer;\n}\n.btn-close:hover {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n  color: var(--rr-text);\n}\n\n/* Content */\n.panel-content {\n  display: flex;\n  flex-direction: column;\n}\n\n.form-section {\n  padding: 16px 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n.section-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n.section-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--rr-text);\n}\n.section-actions {\n  display: flex;\n  gap: 8px;\n}\n\n.hint {\n  font-size: 12px;\n  color: var(--rr-text-weak);\n  line-height: 1.5;\n}\n.muted {\n  font-size: 12px;\n  color: var(--rr-text-weak);\n}\n.divider {\n  height: 1px;\n  background: var(--rr-border);\n}\n\n/* Trigger List */\n.trigger-list {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n.trigger-row {\n  border: 1px solid var(--rr-border);\n  border-radius: 10px;\n  padding: 10px 12px;\n  display: grid;\n  grid-template-columns: 1fr auto;\n  gap: 10px;\n  align-items: start;\n}\n.trigger-top {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n}\n.badge {\n  font-size: 11px;\n  font-weight: 600;\n  padding: 2px 6px;\n  border-radius: 999px;\n  background: rgba(59, 130, 246, 0.12);\n  color: var(--rr-text);\n}\n.trigger-id {\n  font-size: 11px;\n  color: var(--rr-text-weak);\n  font-family: 'Monaco', monospace;\n  opacity: 0.85;\n  word-break: break-all;\n}\n.ownership {\n  font-size: 11px;\n  color: var(--rr-text-weak);\n  padding: 2px 6px;\n  border: 1px dashed var(--rr-border);\n  border-radius: 999px;\n}\n.trigger-desc {\n  margin-top: 6px;\n  font-size: 12px;\n  color: var(--rr-text-secondary);\n  line-height: 1.5;\n  word-break: break-word;\n}\n\n.trigger-actions {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.toggle {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 12px;\n  color: var(--rr-text-secondary);\n  user-select: none;\n}\n.toggle input {\n  width: 16px;\n  height: 16px;\n}\n.toggle.readonly {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n.toggle.readonly input {\n  cursor: not-allowed;\n}\n\n/* Buttons */\n.btn-sm {\n  padding: 6px 10px;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text);\n  border-radius: 6px;\n  font-size: 12px;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.btn-sm:hover:not(:disabled) {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n}\n.btn-sm:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n.btn-sm.btn-primary {\n  background: var(--rr-accent);\n  color: #fff;\n  border-color: var(--rr-accent);\n}\n.btn-sm.btn-primary:hover:not(:disabled) {\n  background: #2563eb;\n}\n\n.btn-icon-sm {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text-secondary);\n  border-radius: 6px;\n  cursor: pointer;\n  transition: all 0.15s;\n}\n.btn-icon-sm:hover:not(:disabled) {\n  background: var(--rr-hover);\n  border-color: var(--rr-text-weak);\n  color: var(--rr-text);\n}\n.btn-icon-sm.danger:hover:not(:disabled) {\n  background: rgba(239, 68, 68, 0.08);\n  border-color: rgba(239, 68, 68, 0.3);\n  color: var(--rr-danger);\n}\n.btn-icon-sm:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n/* Modal */\n.rr-modal {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.5);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 1000;\n}\n.rr-dialog {\n  background: var(--rr-card);\n  border-radius: 12px;\n  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);\n  min-width: 360px;\n  max-width: 90vw;\n}\n.rr-dialog.small {\n  min-width: 320px;\n}\n.rr-header {\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--rr-border);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n.rr-header .title {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--rr-text);\n}\n.rr-header .close {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  background: transparent;\n  color: var(--rr-text-secondary);\n  border-radius: 6px;\n  cursor: pointer;\n}\n.rr-header .close:hover {\n  background: var(--rr-hover);\n  color: var(--rr-text);\n}\n.rr-body {\n  padding: 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 14px;\n}\n.rr-footer {\n  padding: 16px 20px;\n  border-top: 1px solid var(--rr-border);\n  display: flex;\n  justify-content: flex-end;\n  gap: 8px;\n}\n\n/* Form Elements */\n.form-group {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n.form-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--rr-text-secondary);\n}\n.form-input,\n.form-select {\n  width: 100%;\n  padding: 8px 12px;\n  border: 1px solid var(--rr-border);\n  border-radius: 8px;\n  background: var(--rr-card);\n  font-size: 14px;\n  color: var(--rr-text);\n  outline: none;\n  transition: all 0.15s;\n}\n.form-input:focus,\n.form-select:focus {\n  border-color: var(--rr-accent);\n  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);\n}\n.checkbox-group {\n  flex-direction: row;\n  align-items: center;\n}\n.checkbox-label {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 13px;\n  color: var(--rr-text);\n  cursor: pointer;\n}\n.checkbox-label input {\n  width: 16px;\n  height: 16px;\n}\n\n.btn-cancel {\n  padding: 8px 16px;\n  border: 1px solid var(--rr-border);\n  background: var(--rr-card);\n  color: var(--rr-text);\n  border-radius: 8px;\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n}\n.btn-cancel:hover {\n  background: var(--rr-hover);\n}\n.btn-primary {\n  padding: 8px 16px;\n  border: none;\n  background: var(--rr-accent);\n  color: #fff;\n  border-radius: 8px;\n  font-size: 13px;\n  font-weight: 500;\n  cursor: pointer;\n}\n.btn-primary:hover:not(:disabled) {\n  background: #2563eb;\n}\n.btn-primary:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeCard.vue",
    "content": "<template>\n  <div\n    :class=\"['workflow-node', selected ? 'selected' : '', `type-${data.node.type}`]\"\n    @click=\"onSelect()\"\n  >\n    <div v-if=\"hasErrors\" class=\"node-error\" :title=\"errorsTitle\">\n      <ILucideShieldX />\n      <div class=\"tooltip\">\n        <div class=\"item\" v-for=\"e in errList\" :key=\"e\">• {{ e }}</div>\n      </div>\n    </div>\n    <div class=\"node-container\">\n      <div :class=\"['node-icon', `icon-${data.node.type}`]\">\n        <component :is=\"iconComp(data.node.type)\" />\n      </div>\n      <div class=\"node-body\">\n        <div class=\"node-name\">{{ data.node.name || getTypeLabel(data.node.type) }}</div>\n        <div class=\"node-subtitle\">{{ subtitle }}</div>\n      </div>\n    </div>\n\n    <!-- Hide left target handle for trigger (no inputs allowed) -->\n    <Handle\n      v-if=\"data.node.type !== 'trigger'\"\n      type=\"target\"\n      :position=\"Position.Left\"\n      :class=\"['node-handle', hasIncoming ? 'connected' : 'unconnected']\"\n    />\n    <Handle\n      v-if=\"data.node.type !== 'if'\"\n      type=\"source\"\n      :position=\"Position.Right\"\n      :class=\"['node-handle', hasOutgoing ? 'connected' : 'unconnected']\"\n    />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n// Reusable card-like node for most operation nodes\nimport { computed } from 'vue';\nimport type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';\nimport { Handle, Position } from '@vue-flow/core';\nimport { iconComp, getTypeLabel, nodeSubtitle } from './node-util';\nimport ILucideShieldX from '~icons/lucide/shield-x';\n\nconst props = defineProps<{\n  id: string;\n  data: { node: NodeBase; edges: EdgeV2[]; onSelect: (id: string) => void; errors?: string[] };\n  selected?: boolean;\n}>();\n\nconst subtitle = computed(() => nodeSubtitle(props.data.node));\nconst hasIncoming = computed(\n  () => props.data.edges?.some?.((e) => e && e.to === props.data.node.id) || false,\n);\nconst hasOutgoing = computed(\n  () => props.data.edges?.some?.((e) => e && e.from === props.data.node.id) || false,\n);\nconst errList = computed(() => (props.data.errors || []) as string[]);\nconst hasErrors = computed(() => errList.value.length > 0);\nconst errorsTitle = computed(() => errList.value.join('\\n'));\n\nfunction onSelect() {\n  // keep event as function to avoid emitting through VueFlow slots\n  try {\n    props.data.onSelect(props.id);\n  } catch {}\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/nodes/NodeIf.vue",
    "content": "<template>\n  <div\n    :class=\"['workflow-node', selected ? 'selected' : '', `type-${data.node.type}`]\"\n    @click=\"onSelect()\"\n  >\n    <div v-if=\"hasErrors\" class=\"node-error\" :title=\"errorsTitle\">\n      <ILucideShieldX />\n      <div class=\"tooltip\">\n        <div class=\"item\" v-for=\"e in errList\" :key=\"e\">• {{ e }}</div>\n      </div>\n    </div>\n    <div class=\"node-container\">\n      <div :class=\"['node-icon', `icon-${data.node.type}`]\">\n        <component :is=\"iconComp(data.node.type)\" />\n      </div>\n      <div class=\"node-body\">\n        <div class=\"node-name\">{{ data.node.name || getTypeLabel(data.node.type) }}</div>\n        <div class=\"node-subtitle\">{{ subtitle }}</div>\n      </div>\n    </div>\n\n    <div class=\"if-cases\">\n      <div v-for=\"(b, idx) in branches\" :key=\"b.id\" class=\"case-row\">\n        <div class=\"case-label\">{{ b.name || `条件${idx + 1}` }}</div>\n        <Handle\n          type=\"source\"\n          :position=\"Position.Right\"\n          :id=\"`case:${b.id}`\"\n          :class=\"['node-handle', hasOutgoingLabel(`case:${b.id}`) ? 'connected' : 'unconnected']\"\n        />\n      </div>\n      <div v-if=\"hasElse\" class=\"case-row else-row\">\n        <div class=\"case-label\">Else</div>\n        <Handle\n          type=\"source\"\n          :position=\"Position.Right\"\n          id=\"case:else\"\n          :class=\"['node-handle', hasOutgoingLabel('case:else') ? 'connected' : 'unconnected']\"\n        />\n      </div>\n    </div>\n\n    <Handle\n      type=\"target\"\n      :position=\"Position.Left\"\n      :class=\"['node-handle', hasIncoming ? 'connected' : 'unconnected']\"\n    />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { NodeBase, Edge as EdgeV2 } from '@/entrypoints/background/record-replay/types';\nimport { Handle, Position } from '@vue-flow/core';\nimport { iconComp, getTypeLabel, nodeSubtitle } from './node-util';\nimport ILucideShieldX from '~icons/lucide/shield-x';\n\nconst props = defineProps<{\n  id: string;\n  data: { node: NodeBase; edges: EdgeV2[]; onSelect: (id: string) => void; errors?: string[] };\n  selected?: boolean;\n}>();\n\nconst hasIncoming = computed(\n  () => props.data.edges?.some?.((e) => e && e.to === props.data.node.id) || false,\n);\nconst branches = computed(() => {\n  try {\n    return Array.isArray((props.data.node as any)?.config?.branches)\n      ? ((props.data.node as any).config.branches as any[]).map((x) => ({\n          id: String(x.id || ''),\n          name: x.name,\n          expr: x.expr,\n        }))\n      : [];\n  } catch {\n    return [];\n  }\n});\nconst hasElse = computed(() => {\n  try {\n    return (props.data.node as any)?.config?.else !== false;\n  } catch {\n    return true;\n  }\n});\nconst subtitle = computed(() => nodeSubtitle(props.data.node));\nconst errList = computed(() => (props.data.errors || []) as string[]);\nconst hasErrors = computed(() => errList.value.length > 0);\nconst errorsTitle = computed(() => errList.value.join('\\n'));\n\nfunction hasOutgoingLabel(label: string) {\n  try {\n    return (props.data.edges || []).some(\n      (e: any) => e && e.from === props.data.node.id && String(e.label || '') === String(label),\n    );\n  } catch {\n    return false;\n  }\n}\n\nfunction onSelect() {\n  try {\n    props.data.onSelect(props.id);\n  } catch {}\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/nodes/node-util.ts",
    "content": "// node-util.ts - shared UI helpers for node components\n// Note: comments in English\n\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { summarizeNode as summarize } from '../../model/transforms';\nimport ILucideMousePointerClick from '~icons/lucide/mouse-pointer-click';\nimport ILucideEdit3 from '~icons/lucide/edit-3';\nimport ILucideKeyboard from '~icons/lucide/keyboard';\nimport ILucideCompass from '~icons/lucide/compass';\nimport ILucideGlobe from '~icons/lucide/globe';\nimport ILucideFileCode2 from '~icons/lucide/file-code-2';\nimport ILucideScan from '~icons/lucide/scan';\nimport ILucideHourglass from '~icons/lucide/hourglass';\nimport ILucideCheckCircle2 from '~icons/lucide/check-circle-2';\nimport ILucideGitBranch from '~icons/lucide/git-branch';\nimport ILucideRepeat from '~icons/lucide/repeat';\nimport ILucideRefreshCcw from '~icons/lucide/refresh-ccw';\nimport ILucideSquare from '~icons/lucide/square';\nimport ILucideArrowLeftRight from '~icons/lucide/arrow-left-right';\nimport ILucideX from '~icons/lucide/x';\nimport ILucideZap from '~icons/lucide/zap';\nimport ILucideCamera from '~icons/lucide/camera';\nimport ILucideBell from '~icons/lucide/bell';\nimport ILucideWrench from '~icons/lucide/wrench';\nimport ILucideFrame from '~icons/lucide/frame';\nimport ILucideDownload from '~icons/lucide/download';\nimport ILucideArrowUpDown from '~icons/lucide/arrow-up-down';\nimport ILucideMoveVertical from '~icons/lucide/move-vertical';\n\nexport function iconComp(t?: string) {\n  switch (t) {\n    case 'trigger':\n      return ILucideZap;\n    case 'click':\n    case 'dblclick':\n      return ILucideMousePointerClick;\n    case 'fill':\n      return ILucideEdit3;\n    case 'drag':\n      return ILucideArrowUpDown;\n    case 'scroll':\n      return ILucideMoveVertical;\n    case 'key':\n      return ILucideKeyboard;\n    case 'navigate':\n      return ILucideCompass;\n    case 'http':\n      return ILucideGlobe;\n    case 'script':\n      return ILucideFileCode2;\n    case 'screenshot':\n      return ILucideCamera;\n    case 'triggerEvent':\n      return ILucideBell;\n    case 'setAttribute':\n      return ILucideWrench;\n    case 'loopElements':\n      return ILucideRepeat;\n    case 'switchFrame':\n      return ILucideFrame;\n    case 'handleDownload':\n      return ILucideDownload;\n    case 'extract':\n      return ILucideScan;\n    case 'wait':\n      return ILucideHourglass;\n    case 'assert':\n      return ILucideCheckCircle2;\n    case 'if':\n      return ILucideGitBranch;\n    case 'foreach':\n      return ILucideRepeat;\n    case 'while':\n      return ILucideRefreshCcw;\n    case 'openTab':\n      return ILucideSquare;\n    case 'switchTab':\n      return ILucideArrowLeftRight;\n    case 'closeTab':\n      return ILucideX;\n    case 'delay':\n      return ILucideHourglass;\n    default:\n      return ILucideSquare;\n  }\n}\n\nexport function getTypeLabel(type?: string) {\n  const labels: Record<string, string> = {\n    trigger: '触发器',\n    click: '点击',\n    fill: '填充',\n    navigate: '导航',\n    wait: '等待',\n    extract: '提取',\n    http: 'HTTP',\n    script: '脚本',\n    if: '条件',\n    foreach: '循环',\n    assert: '断言',\n    key: '键盘',\n    drag: '拖拽',\n    dblclick: '双击',\n    openTab: '打开标签',\n    switchTab: '切换标签',\n    closeTab: '关闭标签',\n    delay: '延迟',\n    scroll: '滚动',\n    while: '循环',\n  };\n  return labels[String(type || '')] || type || '';\n}\n\nexport function nodeSubtitle(node?: NodeBase | null): string {\n  if (!node) return '';\n  const summary = summarize(node);\n  if (!summary) return node.type || '';\n  return summary.length > 40 ? summary.slice(0, 40) + '...' : summary;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">断言条件 (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"assertJson\"\n        rows=\"4\"\n        placeholder='{\"exists\":\"#id\"}'\n      ></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">失败策略</label>\n      <select class=\"form-select\" v-model=\"(node as any).config.failStrategy\">\n        <option value=\"stop\">stop</option>\n        <option value=\"warn\">warn</option>\n        <option value=\"retry\">retry</option>\n      </select>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nconst assertJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'assert') return '';\n    try {\n      return JSON.stringify((n as any).config?.assert || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'assert') return;\n    try {\n      (n as any).config = { ...((n as any).config || {}), assert: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyClick.vue",
    "content": "<template>\n  <div>\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport SelectorEditor from './SelectorEditor.vue';\n\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">按 URL 关闭（可选）</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.url\" placeholder=\"子串匹配 URL\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">Tab IDs（JSON 数组，可选）</label>\n      <textarea class=\"form-textarea\" v-model=\"tabIdsJson\" rows=\"2\" placeholder=\"[1,2]\"></textarea>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nconst tabIdsJson = computed({\n  get() {\n    try {\n      const arr = (props.node as any).config?.tabIds;\n      return Array.isArray(arr) ? JSON.stringify(arr) : '';\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config.tabIds = v ? JSON.parse(v) : [];\n    } catch {}\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">延迟 (ms)</label>\n      <input class=\"form-input\" type=\"number\" v-model.number=\"(node as any).config.ms\" min=\"0\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue",
    "content": "<template>\n  <div>\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" title=\"起点选择器\" targetKey=\"start\" />\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" title=\"终点选择器\" targetKey=\"end\" />\n    <div class=\"hint\">\n      <small>提示：路径（path）通常在录制时自动生成，手动创建时可留空。</small>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport SelectorEditor from './SelectorEditor.vue';\n\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped>\n.hint {\n  color: #64748b;\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">目标工作流</label>\n      <select class=\"form-select\" v-model=\"(node as any).config.flowId\">\n        <option value=\"\">请选择</option>\n        <option v-for=\"f in flows\" :key=\"f.id\" :value=\"f.id\">{{ f.name || f.id }}</option>\n      </select>\n    </div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"(node as any).config.inline\" />\n        内联执行（共享上下文变量）</label\n      >\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">传参 (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"execArgsJson\"\n        rows=\"3\"\n        placeholder='{\"k\": \"v\"}'\n      ></textarea>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed, onMounted, ref } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\ntype FlowLite = { id: string; name?: string };\nconst flows = ref<FlowLite[]>([]);\nonMounted(async () => {\n  try {\n    const res = await chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.RR_LIST_FLOWS });\n    if (res && res.success) flows.value = res.flows || [];\n  } catch {}\n});\n\nconst execArgsJson = computed({\n  get() {\n    try {\n      return JSON.stringify((props.node as any).config?.args || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config.args = v ? JSON.parse(v) : {};\n    } catch {}\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">元素选择器（可选）</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.selector\" placeholder=\"CSS 选择器\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">属性</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.attr\"\n        placeholder=\"text/textContent 或属性名\"\n      />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">自定义 JS（返回值）</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"(node as any).config.js\"\n        rows=\"3\"\n        placeholder=\"return document.title\"\n      ></textarea>\n    </div>\n    <div class=\"form-group\" :class=\"{ invalid: !(node as any).config?.saveAs }\">\n      <label class=\"form-label\">保存为变量</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.saveAs\" placeholder=\"变量名\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFill.vue",
    "content": "<template>\n  <div>\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" />\n    <div class=\"form-section\">\n      <div class=\"form-group\" data-field=\"fill.value\">\n        <label class=\"form-label\">输入值</label>\n        <VarInput v-model=\"value\" :variables=\"variables\" placeholder=\"支持 {变量名} 格式\" />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport type { VariableOption } from '@/entrypoints/popup/components/builder/model/variables';\nimport SelectorEditor from './SelectorEditor.vue';\nimport VarInput from '@/entrypoints/popup/components/builder/widgets/VarInput.vue';\n\nconst props = defineProps<{ node: NodeBase; variables?: VariableOption[] }>();\nconst variables = computed<VariableOption[]>(() => (props.variables || []).slice());\nconst value = computed<string>({\n  get() {\n    return String((props.node as any)?.config?.value ?? '');\n  },\n  set(v: string) {\n    if (!props.node) return;\n    if (!(props.node as any).config) (props.node as any).config = {} as any;\n    (props.node as any).config.value = v;\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">列表变量</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.listVar\"\n        placeholder=\"workflow.list\"\n      />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">循环项变量名</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.itemVar\" placeholder=\"默认 item\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">子流 ID</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.subflowId\"\n        placeholder=\"选择或新建子流\"\n      />\n      <button class=\"btn-sm\" style=\"margin-top: 8px\" @click=\"onCreateSubflow\">新建子流</button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\nconst emit = defineEmits<{ (e: 'create-subflow', id: string): void }>();\n\nfunction onCreateSubflow() {\n  const id = prompt('请输入新子流ID');\n  if (!id) return;\n  emit('create-subflow', id);\n  const n = props.node as any;\n  if (n && n.config) n.config.subflowId = id;\n}\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFormRenderer.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"section-title\">配置</div>\n    <div v-for=\"field in schema\" :key=\"field.key\" class=\"form-group\" :data-field=\"field.key\">\n      <label class=\"form-label\">{{ field.label }}</label>\n      <component\n        :is=\"resolveField(field)\"\n        :field=\"field\"\n        v-model=\"model[field.key]\"\n        :variables=\"variables\"\n      />\n      <div v-if=\"field.help\" class=\"help\">{{ field.help }}</div>\n    </div>\n\n    <div v-if=\"errors.length\" class=\"error-box\">\n      <div class=\"error-title\">⚠️ 配置错误</div>\n      <div v-for=\"e in errors\" :key=\"e\" class=\"error-item\">{{ e }}</div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, reactive, watch, defineComponent, h, ref } from 'vue';\nimport type { FieldSpec, NodeSpec } from '@/entrypoints/popup/components/builder/model/node-spec';\nimport { getNodeSpec } from '@/entrypoints/popup/components/builder/model/node-spec-registry';\nimport {\n  getWidget,\n  registerDefaultWidgets,\n} from '@/entrypoints/popup/components/builder/model/form-widget-registry';\nimport VarInput from '@/entrypoints/popup/components/builder/widgets/VarInput.vue';\nimport type { VariableOption } from '@/entrypoints/popup/components/builder/model/variables';\n\nconst props = defineProps<{\n  node: any; // NodeBase\n  variables?: VariableOption[];\n}>();\n\n// Fetch spec by node.type\nconst spec = computed<NodeSpec | undefined>(() => getNodeSpec(props.node?.type));\nconst schema = computed<FieldSpec[]>(() => spec.value?.schema || []);\n\n// Config model references node.config; ensure defaults applied on mount\nconst model = reactive<any>({});\n\nfunction applyDefaults() {\n  if (!props.node) return;\n  if (!props.node.config) props.node.config = {};\n  const defaults = spec.value?.defaults || {};\n  for (const [k, v] of Object.entries(defaults))\n    if (props.node.config[k] === undefined) props.node.config[k] = v;\n  Object.assign(model, props.node.config);\n}\n\nonMounted(applyDefaults);\nregisterDefaultWidgets();\nwatch(\n  () => props.node?.id,\n  () => applyDefaults(),\n);\nwatch(\n  model,\n  () => {\n    if (!props.node) return;\n    props.node.config = { ...(props.node.config || {}), ...model };\n  },\n  { deep: true },\n);\n\nconst errors = computed(() => {\n  const cfg = props.node?.config || {};\n  const out: string[] = [];\n  for (const f of schema.value)\n    if (f.required && (cfg[f.key] === undefined || cfg[f.key] === '')) out.push(`${f.label} 必填`);\n  try {\n    const more = spec.value?.validate?.(cfg) || [];\n    out.push(...more);\n  } catch {}\n  return out;\n});\n\nfunction resolveField(field: FieldSpec) {\n  const w = getWidget((field as any).widget);\n  if (w) return w as any;\n  switch (field.type) {\n    case 'string':\n      return StringField;\n    case 'number':\n      return NumberField;\n    case 'boolean':\n      return BoolField;\n    case 'select':\n      return SelectField;\n    case 'object':\n      return ObjectField;\n    case 'array':\n      return ArrayField;\n    case 'json':\n      return JsonField;\n    default:\n      return StringField;\n  }\n}\n\n// Field components without runtime templates (render functions)\nconst StringField = defineComponent({\n  name: 'StringField',\n  props: ['field', 'modelValue', 'variables'],\n  emits: ['update:modelValue'],\n  setup(p: any, { emit }) {\n    return () =>\n      h(VarInput as any, {\n        modelValue: p.modelValue ?? '',\n        variables: (p.variables || []) as VariableOption[],\n        placeholder: p.field?.placeholder,\n        'onUpdate:modelValue': (v: string) => emit('update:modelValue', v),\n      });\n  },\n});\n\nconst NumberField = defineComponent({\n  name: 'NumberField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    return () =>\n      h('input', {\n        class: 'form-input',\n        type: 'number',\n        min: props.field?.min,\n        max: props.field?.max,\n        step: props.field?.step || 1,\n        value: props.modelValue ?? '',\n        onInput: (e: any) => emit('update:modelValue', e?.target?.valueAsNumber),\n      });\n  },\n});\n\nconst BoolField = defineComponent({\n  name: 'BoolField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    return () =>\n      h('label', { class: 'checkbox-label' }, [\n        h('input', {\n          type: 'checkbox',\n          checked: !!props.modelValue,\n          onChange: (e: any) => emit('update:modelValue', !!e?.target?.checked),\n        }),\n        h('span', null, props.field?.label ?? ''),\n      ]);\n  },\n});\n\nconst SelectField = defineComponent({\n  name: 'SelectField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    return () =>\n      h(\n        'select',\n        {\n          class: 'form-input',\n          value: props.modelValue,\n          onChange: (e: any) => emit('update:modelValue', e?.target?.value),\n        },\n        (props.field?.options || []).map((op: any) =>\n          h('option', { value: op.value, key: String(op.value) }, op.label),\n        ),\n      );\n  },\n});\n\nconst JsonField = defineComponent({\n  name: 'JsonField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    const text = ref<string>('');\n    const err = ref<string>('');\n    onMounted(() => {\n      try {\n        text.value = props.modelValue != null ? JSON.stringify(props.modelValue, null, 2) : '';\n      } catch {\n        text.value = '';\n      }\n    });\n    watch(text, () => {\n      try {\n        const v = text.value ? JSON.parse(text.value) : undefined;\n        err.value = '';\n        emit('update:modelValue', v);\n      } catch (e) {\n        err.value = 'JSON 格式错误';\n      }\n    });\n    return () =>\n      h('div', null, [\n        h('textarea', {\n          class: 'form-input',\n          rows: 6,\n          placeholder: '输入 JSON',\n          value: text.value,\n          onInput: (e: any) => (text.value = String(e?.target?.value ?? '')),\n        }),\n        err.value ? h('div', { class: 'error-item' }, err.value) : null,\n      ]);\n  },\n});\n\nconst ObjectField = defineComponent({\n  name: 'ObjectField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    const local = ref<Record<string, any>>({ ...(props.modelValue || {}) });\n    const compOf = (f: any) => {\n      const w = getWidget(f.widget);\n      if (w) return w as any;\n      if (f.type === 'string') return StringField;\n      if (f.type === 'number') return NumberField;\n      if (f.type === 'boolean') return BoolField;\n      if (f.type === 'select') return SelectField;\n      if (f.type === 'json') return JsonField;\n      if (f.type === 'object') return ObjectField;\n      if (f.type === 'array') return ArrayField;\n      return StringField;\n    };\n    watch(\n      () => local.value,\n      () => emit('update:modelValue', local.value),\n      { deep: true },\n    );\n    return () =>\n      h(\n        'div',\n        { class: 'nested' },\n        (props.field?.fields || []).map((f: any) =>\n          h('div', { class: 'form-group', 'data-field': f.key, key: f.key }, [\n            h('label', { class: 'form-label' }, f.label),\n            h(compOf(f), {\n              field: f,\n              modelValue: local.value[f.key],\n              'onUpdate:modelValue': (v: any) => (local.value = { ...local.value, [f.key]: v }),\n              variables: props.variables || [],\n            }),\n          ]),\n        ),\n      );\n  },\n});\n\nconst ArrayField = defineComponent({\n  name: 'ArrayField',\n  props: ['field', 'modelValue'],\n  emits: ['update:modelValue'],\n  setup(props: any, { emit }) {\n    const items = ref<any[]>(Array.isArray(props.modelValue) ? [...props.modelValue] : []);\n    const update = () => emit('update:modelValue', items.value);\n    const add = () => {\n      const it = props.field.item as any;\n      let v: any = null;\n      if (it.type === 'string') v = '';\n      else if (it.type === 'number') v = 0;\n      else if (it.type === 'boolean') v = false;\n      else if (it.type === 'select') v = it.options?.[0]?.value ?? '';\n      else if (it.type === 'object') v = {};\n      else if (it.type === 'json') v = {};\n      else if (it.type === 'array') v = [];\n      items.value.push(v);\n      update();\n    };\n    const remove = (i: number) => {\n      items.value.splice(i, 1);\n      update();\n    };\n    const compOf = (f: any) => {\n      const w = getWidget(f.widget);\n      if (w) return w as any;\n      if (f.type === 'string') return StringField;\n      if (f.type === 'number') return NumberField;\n      if (f.type === 'boolean') return BoolField;\n      if (f.type === 'select') return SelectField;\n      if (f.type === 'json') return JsonField;\n      if (f.type === 'object') return ObjectField;\n      if (f.type === 'array') return ArrayField;\n      return StringField;\n    };\n    return () =>\n      h('div', { class: 'array' }, [\n        ...items.value.map((_, i) =>\n          h('div', { class: 'array-item', key: i }, [\n            h(compOf(props.field.item), {\n              field: props.field.item,\n              modelValue: items.value[i],\n              'onUpdate:modelValue': (v: any) => {\n                items.value[i] = v;\n                update();\n              },\n              variables: props.variables || [],\n            }),\n            h('button', { class: 'btn-mini', type: 'button', onClick: () => remove(i) }, '删除'),\n          ]),\n        ),\n        h('button', { class: 'btn', type: 'button', onClick: add }, '新增'),\n      ]);\n  },\n});\n</script>\n\n<style scoped></style>\n<style scoped>\n.form-section {\n  padding: 8px 12px;\n}\n.section-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--rr-text);\n  margin-bottom: 6px;\n}\n.form-group {\n  margin-bottom: 10px;\n}\n.form-label {\n  display: block;\n  font-size: 12px;\n  color: var(--rr-dim);\n  margin-bottom: 4px;\n}\n.help {\n  font-size: 11px;\n  color: var(--rr-dim);\n  margin-top: 4px;\n}\n.checkbox-label {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 13px;\n  color: var(--rr-text);\n}\n.nested {\n  border-left: 2px solid var(--rr-border);\n  padding-left: 8px;\n}\n.array-item {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  margin-bottom: 6px;\n}\n.btn-mini {\n  font-size: 12px;\n  padding: 2px 6px;\n  border: 1px solid var(--rr-border);\n  border-radius: 6px;\n}\n.btn {\n  font-size: 12px;\n  padding: 4px 8px;\n  border: 1px solid var(--rr-border);\n  border-radius: 8px;\n}\n.form-input {\n  width: 100%;\n  border: 1px solid var(--rr-border);\n  border-radius: 8px;\n  padding: 6px 8px;\n  background: var(--rr-card-2);\n  color: var(--rr-text);\n}\n.error-box {\n  background: rgba(255, 102, 102, 0.06);\n  border: 1px solid rgba(255, 102, 102, 0.25);\n  color: #ff6666;\n  border-radius: 8px;\n  padding: 6px 8px;\n  margin-top: 8px;\n}\n.error-title {\n  font-size: 12px;\n  font-weight: 600;\n  margin-bottom: 4px;\n}\n.error-item {\n  font-size: 12px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue",
    "content": "<template>\n  <PropertyFormRenderer v-if=\"node && hasSpec\" :node=\"node\" :variables=\"variables\" />\n  <div v-else class=\"form-section\">\n    <div class=\"section-title\">未找到节点规范</div>\n    <div class=\"help\">该节点尚未提供 NodeSpec，已回退到默认属性面板。</div>\n  </div>\n  <!-- 将通用字段留给外层 PropertyPanel 渲染（timeoutMs/screenshotOnFail等） -->\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport PropertyFormRenderer from './PropertyFormRenderer.vue';\nimport { getNodeSpec } from '@/entrypoints/popup/components/builder/model/node-spec-registry';\n\nconst props = defineProps<{\n  node: any;\n  variables?: Array<{ key: string; origin?: string; nodeId?: string; nodeName?: string }>;\n}>();\nconst hasSpec = computed(() => !!getNodeSpec(props.node?.type));\n</script>\n\n<style scoped>\n.form-section {\n  padding: 8px 12px;\n}\n.section-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--rr-text);\n  margin-bottom: 6px;\n}\n.help {\n  font-size: 12px;\n  color: var(--rr-dim);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">文件名包含（可选）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.filenameContains\"\n        placeholder=\"子串匹配文件名或URL\"\n      />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">超时(ms)</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.timeoutMs\" placeholder=\"默认 60000\" />\n    </div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"(node as any).config.waitForComplete\" />\n        等待下载完成</label\n      >\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">保存到变量</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.saveAs\" placeholder=\"默认 download\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">请求方法</label>\n      <select class=\"form-select\" v-model=\"(node as any).config.method\">\n        <option>GET</option>\n        <option>POST</option>\n        <option>PUT</option>\n        <option>PATCH</option>\n        <option>DELETE</option>\n      </select>\n    </div>\n    <div class=\"form-group\" :class=\"{ invalid: !(node as any).config?.url }\" data-field=\"http.url\">\n      <label class=\"form-label\">URL 地址</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.url\"\n        placeholder=\"https://api.example.com/data\"\n      />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">Headers (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"headersJson\"\n        rows=\"3\"\n        placeholder='{\"Content-Type\": \"application/json\"}'\n      ></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">Body (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"bodyJson\"\n        rows=\"3\"\n        placeholder='{\"key\": \"value\"}'\n      ></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">FormData (JSON，可选，提供时覆盖 Body)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"formDataJson\"\n        rows=\"3\"\n        placeholder='{\"fields\":{\"k\":\"v\"},\"files\":[{\"name\":\"file\",\"fileUrl\":\"https://...\",\"filename\":\"a.png\"}]}'\n      ></textarea>\n      <div class=\"text-xs text-slate-500\" style=\"margin-top: 6px\"\n        >支持简洁数组形式：[[\"file\",\"url:https://...\",\"a.png\"],[\"metadata\",\"value\"]]</div\n      >\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nconst headersJson = computed({\n  get() {\n    try {\n      return JSON.stringify((props.node as any).config?.headers || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config.headers = JSON.parse(v || '{}');\n    } catch {}\n  },\n});\nconst bodyJson = computed({\n  get() {\n    try {\n      return JSON.stringify((props.node as any).config?.body ?? null, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config.body = v ? JSON.parse(v) : null;\n    } catch {}\n  },\n});\nconst formDataJson = computed({\n  get() {\n    try {\n      return (props.node as any).config?.formData\n        ? JSON.stringify((props.node as any).config.formData, null, 2)\n        : '';\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config.formData = v ? JSON.parse(v) : undefined;\n    } catch {}\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyIf.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"section-header\">\n      <span class=\"section-title\">If / else</span>\n      <button class=\"btn-sm\" @click=\"addIfCase\">+ Add</button>\n    </div>\n    <div class=\"text-xs text-slate-500\" style=\"padding: 0 20px\"\n      >使用表达式定义分支，支持变量与常见比较运算符。</div\n    >\n    <div class=\"if-case-list\" data-field=\"if.branches\">\n      <div class=\"if-case-item\" v-for=\"(c, i) in ifBranches\" :key=\"c.id\">\n        <div class=\"if-case-header\">\n          <input class=\"form-input-sm flex-1\" v-model=\"c.name\" placeholder=\"分支名称（可选）\" />\n          <button class=\"btn-icon-sm danger\" @click=\"removeIfCase(i)\" title=\"删除\">×</button>\n        </div>\n        <div class=\"if-case-expr\">\n          <VarInput\n            v-model=\"c.expr\"\n            :variables=\"variablesNormalized\"\n            format=\"workflowDot\"\n            :placeholder=\"'workflow.' + (variablesNormalized[0]?.key || 'var') + ' == 5'\"\n          />\n          <div class=\"if-toolbar\">\n            <select\n              class=\"form-select-sm\"\n              @change=\"(e: any) => insertVar(e.target.value, i)\"\n              :value=\"''\"\n            >\n              <option value=\"\" disabled>插入变量</option>\n              <option v-for=\"v in variables\" :key=\"v.key\" :value=\"v.key\">{{ v.key }}</option>\n            </select>\n            <select\n              class=\"form-select-sm\"\n              @change=\"(e: any) => insertOp(e.target.value, i)\"\n              :value=\"''\"\n            >\n              <option value=\"\" disabled>运算符</option>\n              <option v-for=\"op in ops\" :key=\"op\" :value=\"op\">{{ op }}</option>\n            </select>\n          </div>\n        </div>\n      </div>\n      <div class=\"if-case-else\" v-if=\"elseEnabled\">\n        <div class=\"text-xs text-slate-500\">Else 分支（无需表达式，将匹配以上条件都不成立时）</div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { newId } from '@/entrypoints/popup/components/builder/model/transforms';\n\nimport VarInput from '@/entrypoints/popup/components/builder/widgets/VarInput.vue';\nimport type { VariableOption } from '@/entrypoints/popup/components/builder/model/variables';\nconst props = defineProps<{ node: NodeBase; variables?: Array<{ key: string }> }>();\nconst variablesNormalized = computed<VariableOption[]>(() =>\n  (props.variables || []).map((v) => ({ key: v.key, origin: 'global' }) as VariableOption),\n);\n\nconst ops = ['==', '!=', '>', '>=', '<', '<=', '&&', '||'];\nconst ifBranches = computed<Array<{ id: string; name?: string; expr: string }>>({\n  get() {\n    try {\n      return Array.isArray((props.node as any)?.config?.branches)\n        ? ((props.node as any).config.branches as any[])\n        : [];\n    } catch {\n      return [] as any;\n    }\n  },\n  set(arr) {\n    try {\n      (props.node as any).config.branches = arr;\n    } catch {}\n  },\n});\nconst elseEnabled = computed<boolean>({\n  get() {\n    try {\n      return (props.node as any)?.config?.else !== false;\n    } catch {\n      return true;\n    }\n  },\n  set(v) {\n    try {\n      (props.node as any).config.else = !!v;\n    } catch {}\n  },\n});\n\nfunction addIfCase() {\n  const arr = ifBranches.value.slice();\n  arr.push({ id: newId('case'), name: '', expr: '' });\n  ifBranches.value = arr;\n}\nfunction removeIfCase(i: number) {\n  const arr = ifBranches.value.slice();\n  arr.splice(i, 1);\n  ifBranches.value = arr;\n}\nfunction insertVar(key: string, idx: number) {\n  if (!key) return;\n  const arr = ifBranches.value.slice();\n  const token = `workflow.${key}`;\n  arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + token;\n  ifBranches.value = arr;\n}\nfunction insertOp(op: string, idx: number) {\n  if (!op) return;\n  const arr = ifBranches.value.slice();\n  arr[idx].expr = String(arr[idx].expr || '') + (arr[idx].expr ? ' ' : '') + op;\n  ifBranches.value = arr;\n}\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyKey.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">按键序列</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.keys\"\n        placeholder=\"如 Backspace Enter 或 cmd+a\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">元素选择器</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.selector\" placeholder=\"CSS 选择器\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">列表变量名</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.saveAs\" placeholder=\"默认 elements\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">循环项变量名</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.itemVar\" placeholder=\"默认 item\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">子流 ID</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.subflowId\"\n        placeholder=\"选择或新建子流\"\n      />\n      <button class=\"btn-sm\" style=\"margin-top: 8px\" @click=\"onCreateSubflow\">新建子流</button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\nconst emit = defineEmits<{ (e: 'create-subflow', id: string): void }>();\n\nfunction onCreateSubflow() {\n  const id = prompt('请输入新子流ID');\n  if (!id) return;\n  emit('create-subflow', id);\n  const n = props.node as any;\n  if (n && n.config) n.config.subflowId = id;\n}\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\" data-field=\"navigate.url\">\n      <label class=\"form-label\">URL 地址</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.url\"\n        placeholder=\"https://example.com\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">URL 地址（可选）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.url\"\n        placeholder=\"https://example.com\"\n      />\n    </div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"(node as any).config.newWindow\" /> 新窗口</label\n      >\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">元素选择器（可选）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.selector\"\n        placeholder=\"为空则截取可视区或全页\"\n      />\n    </div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"(node as any).config.fullPage\" /> 全页截图</label\n      >\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">保存为变量</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.saveAs\"\n        placeholder=\"变量名，例如 shot\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScript.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">代码</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"(node as any).config.code\"\n        rows=\"6\"\n        placeholder=\"// your JS code\"\n      ></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">执行环境</label>\n      <select class=\"form-select\" v-model=\"(node as any).config.world\">\n        <option value=\"ISOLATED\">ISOLATED</option>\n        <option value=\"MAIN\">MAIN</option>\n      </select>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">执行时机</label>\n      <select class=\"form-select\" v-model=\"(node as any).config.when\">\n        <option value=\"before\">before</option>\n        <option value=\"after\">after</option>\n      </select>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">保存为变量（可选）</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.saveAs\" placeholder=\"变量名\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">结果字段映射</label>\n      <KeyValueEditor v-model=\"(node as any).config.assign\" />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport KeyValueEditor from '@/entrypoints/popup/components/builder/components/KeyValueEditor.vue';\n\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue",
    "content": "<template>\n  <div>\n    <div class=\"form-row\">\n      <label class=\"form-label\">模式</label>\n      <select v-model=\"cfg.mode\" class=\"form-select-sm\">\n        <option value=\"element\">滚动到元素</option>\n        <option value=\"offset\">窗口偏移</option>\n        <option value=\"container\">容器偏移</option>\n      </select>\n    </div>\n\n    <div v-if=\"cfg.mode === 'element'\" class=\"mt-2\">\n      <SelectorEditor :node=\"node\" :allowPick=\"true\" title=\"目标元素\" targetKey=\"target\" />\n    </div>\n\n    <div v-if=\"cfg.mode !== 'element'\" class=\"mt-2\">\n      <div class=\"form-row\">\n        <label class=\"form-label\">偏移 X</label>\n        <input type=\"number\" class=\"form-input-sm\" v-model.number=\"cfg.offset.x\" placeholder=\"0\" />\n      </div>\n      <div class=\"form-row\">\n        <label class=\"form-label\">偏移 Y</label>\n        <input\n          type=\"number\"\n          class=\"form-input-sm\"\n          v-model.number=\"cfg.offset.y\"\n          placeholder=\"300\"\n        />\n      </div>\n      <div v-if=\"cfg.mode === 'container'\" class=\"mt-2\">\n        <SelectorEditor :node=\"node\" :allowPick=\"true\" title=\"容器选择器\" targetKey=\"target\" />\n        <div class=\"hint\"><small>容器需支持 scrollTo(top,left)</small></div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport SelectorEditor from './SelectorEditor.vue';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nfunction ensure() {\n  const n: any = props.node;\n  n.config = n.config || {};\n  if (!n.config.mode) n.config.mode = 'offset';\n  if (!n.config.offset) n.config.offset = { x: 0, y: 300 };\n  if (!n.config.target) n.config.target = { candidates: [] };\n}\n\nconst cfg = {\n  get mode() {\n    ensure();\n    return (props.node as any).config.mode;\n  },\n  set mode(v: any) {\n    ensure();\n    (props.node as any).config.mode = v;\n  },\n  get offset() {\n    ensure();\n    return (props.node as any).config.offset;\n  },\n  set offset(v: any) {\n    ensure();\n    (props.node as any).config.offset = v;\n  },\n} as any;\n</script>\n\n<style scoped>\n.hint {\n  color: #64748b;\n  margin-top: 8px;\n}\n.mt-2 {\n  margin-top: 8px;\n}\n.form-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin: 6px 0;\n}\n.form-label {\n  width: 80px;\n  color: #334155;\n  font-size: 12px;\n}\n.form-input-sm,\n.form-select-sm {\n  flex: 1;\n  padding: 6px 8px;\n  border: 1px solid var(--rr-border);\n  border-radius: 6px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue",
    "content": "<template>\n  <div>\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" />\n    <div class=\"form-section\">\n      <div class=\"form-group\">\n        <label class=\"form-label\">属性名</label>\n        <input\n          class=\"form-input\"\n          v-model=\"(node as any).config.name\"\n          placeholder=\"如 value/src/disabled 等\"\n        />\n      </div>\n      <div class=\"form-group\">\n        <label class=\"form-label\">属性值（留空并勾选删除则移除）</label>\n        <input class=\"form-input\" v-model=\"(node as any).config.value\" placeholder=\"属性值\" />\n      </div>\n      <div class=\"form-group checkbox-group\">\n        <label class=\"checkbox-label\"\n          ><input type=\"checkbox\" v-model=\"(node as any).config.remove\" /> 删除属性</label\n        >\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport SelectorEditor from './SelectorEditor.vue';\n\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">按 URL 包含匹配（优先）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.frame.urlContains\"\n        placeholder=\"frame URL 包含的字符串\"\n      />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">按索引匹配（从 0 起，仅子 frame）</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.frame.index\" placeholder=\"索引数字\" />\n    </div>\n    <div class=\"text-xs text-slate-500\" style=\"padding: 0 20px\"\n      >同源/可注入 frame 可用；留空则回到顶级页面</div\n    >\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">Tab ID（可选）</label>\n      <input\n        class=\"form-input\"\n        type=\"number\"\n        v-model.number=\"(node as any).config.tabId\"\n        placeholder=\"数字\"\n      />\n    </div>\n    <div class=\"form-group\" :class=\"{ invalid: needOne && !hasAny }\">\n      <label class=\"form-label\">URL 包含（可选）</label>\n      <input class=\"form-input\" v-model=\"(node as any).config.urlContains\" placeholder=\"子串匹配\" />\n    </div>\n    <div class=\"form-group\" :class=\"{ invalid: needOne && !hasAny }\">\n      <label class=\"form-label\">标题包含（可选）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.titleContains\"\n        placeholder=\"子串匹配\"\n      />\n    </div>\n    <div\n      v-if=\"needOne && !hasAny\"\n      class=\"text-xs text-slate-500\"\n      style=\"padding: 0 20px; color: var(--rr-danger)\"\n      >需提供 tabId 或 URL/标题包含</div\n    >\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\nconst needOne = true;\nconst hasAny = computed(() => {\n  const c: any = (props.node as any).config || {};\n  return !!(c.tabId || c.urlContains || c.titleContains);\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.enabled\" /> 启用触发器</label\n      >\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">描述（可选）</label>\n      <input class=\"form-input\" v-model=\"cfg.description\" placeholder=\"说明此触发器的用途\" />\n    </div>\n  </div>\n\n  <div class=\"divider\"></div>\n\n  <div class=\"form-section\">\n    <div class=\"section-header\"><span class=\"section-title\">触发方式</span></div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.manual\" /> 手动</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.url\" /> 访问 URL</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.contextMenu\" /> 右键菜单</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.command\" /> 快捷键</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.dom\" /> DOM 变化</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.modes.schedule\" /> 定时</label\n      >\n    </div>\n  </div>\n\n  <div v-if=\"cfg.modes.url\" class=\"form-section\">\n    <div class=\"section-title\">访问 URL 匹配</div>\n    <div class=\"selector-list\">\n      <div v-for=\"(r, i) in urlRules\" :key=\"i\" class=\"selector-item\">\n        <select class=\"form-select-sm\" v-model=\"r.kind\">\n          <option value=\"url\">前缀 URL</option>\n          <option value=\"domain\">域名包含</option>\n          <option value=\"path\">路径前缀</option>\n        </select>\n        <input\n          class=\"form-input-sm flex-1\"\n          v-model=\"r.value\"\n          placeholder=\"例如 https://example.com/app\"\n        />\n        <button class=\"btn-icon-sm\" @click=\"move(urlRules, i, -1)\" :disabled=\"i === 0\">↑</button>\n        <button\n          class=\"btn-icon-sm\"\n          @click=\"move(urlRules, i, 1)\"\n          :disabled=\"i === urlRules.length - 1\"\n          >↓</button\n        >\n        <button class=\"btn-icon-sm danger\" @click=\"urlRules.splice(i, 1)\">×</button>\n      </div>\n    </div>\n    <button class=\"btn-sm\" @click=\"urlRules.push({ kind: 'url', value: '' })\">+ 添加匹配</button>\n  </div>\n\n  <div v-if=\"cfg.modes.contextMenu\" class=\"form-section\">\n    <div class=\"section-title\">右键菜单</div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">标题</label>\n      <input class=\"form-input\" v-model=\"cfg.contextMenu.title\" placeholder=\"菜单标题\" />\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">作用范围</label>\n      <div class=\"checkbox-group\">\n        <label class=\"checkbox-label\" v-for=\"c in menuContexts\" :key=\"c\">\n          <input type=\"checkbox\" :value=\"c\" v-model=\"cfg.contextMenu.contexts\" /> {{ c }}\n        </label>\n      </div>\n    </div>\n  </div>\n\n  <div v-if=\"cfg.modes.command\" class=\"form-section\">\n    <div class=\"section-title\">快捷键</div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">命令键（需预先在 manifest commands 中声明）</label>\n      <input\n        class=\"form-input\"\n        v-model=\"cfg.command.commandKey\"\n        placeholder=\"例如 run_quick_trigger_1\"\n      />\n    </div>\n    <div class=\"text-xs text-slate-500\" style=\"padding: 0 20px\"\n      >提示：Chrome 扩展快捷键需要在 manifest 里固定声明，无法运行时动态添加。</div\n    >\n  </div>\n\n  <div v-if=\"cfg.modes.dom\" class=\"form-section\">\n    <div class=\"section-title\">DOM 变化</div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">选择器</label>\n      <input class=\"form-input\" v-model=\"cfg.dom.selector\" placeholder=\"#app .item\" />\n    </div>\n    <div class=\"form-group checkbox-group\">\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.dom.appear\" /> 出现时触发</label\n      >\n      <label class=\"checkbox-label\"\n        ><input type=\"checkbox\" v-model=\"cfg.dom.once\" /> 仅触发一次</label\n      >\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">去抖(ms)</label>\n      <input class=\"form-input\" type=\"number\" min=\"0\" v-model.number=\"cfg.dom.debounceMs\" />\n    </div>\n  </div>\n\n  <div v-if=\"cfg.modes.schedule\" class=\"form-section\">\n    <div class=\"section-title\">定时</div>\n    <div class=\"selector-list\">\n      <div v-for=\"(s, i) in schedules\" :key=\"i\" class=\"selector-item\">\n        <select class=\"form-select-sm\" v-model=\"s.type\">\n          <option value=\"interval\">间隔(分钟)</option>\n          <option value=\"daily\">每天(HH:mm)</option>\n          <option value=\"once\">一次(ISO时间)</option>\n        </select>\n        <input\n          class=\"form-input-sm flex-1\"\n          v-model=\"s.when\"\n          placeholder=\"5 或 09:00 或 2025-01-01T10:00:00\"\n        />\n        <label class=\"checkbox-label\"><input type=\"checkbox\" v-model=\"s.enabled\" /> 启用</label>\n        <button class=\"btn-icon-sm\" @click=\"move(schedules, i, -1)\" :disabled=\"i === 0\">↑</button>\n        <button\n          class=\"btn-icon-sm\"\n          @click=\"move(schedules, i, 1)\"\n          :disabled=\"i === schedules.length - 1\"\n          >↓</button\n        >\n        <button class=\"btn-icon-sm danger\" @click=\"schedules.splice(i, 1)\">×</button>\n      </div>\n    </div>\n    <button class=\"btn-sm\" @click=\"schedules.push({ type: 'interval', when: '5', enabled: true })\"\n      >+ 添加定时</button\n    >\n  </div>\n\n  <div class=\"divider\"></div>\n  <div class=\"form-section\">\n    <div class=\"text-xs text-slate-500\" style=\"padding: 0 20px\"\n      >说明：\n      触发器会在保存工作流时同步到后台触发表（URL/右键/快捷键/DOM）和计划任务（间隔/每天/一次）。\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nfunction ensure() {\n  const n: any = props.node;\n  if (!n.config) n.config = {};\n  if (!n.config.modes)\n    n.config.modes = {\n      manual: true,\n      url: false,\n      contextMenu: false,\n      command: false,\n      dom: false,\n      schedule: false,\n    };\n  if (!n.config.url) n.config.url = { rules: [] };\n  if (!n.config.contextMenu)\n    n.config.contextMenu = { title: '运行工作流', contexts: ['all'], enabled: false };\n  if (!n.config.command) n.config.command = { commandKey: '', enabled: false };\n  if (!n.config.dom)\n    n.config.dom = { selector: '', appear: true, once: true, debounceMs: 800, enabled: false };\n  if (!Array.isArray(n.config.schedules)) n.config.schedules = [];\n}\n\nconst cfg = computed<any>({\n  get() {\n    ensure();\n    return (props.node as any).config;\n  },\n  set(v) {\n    (props.node as any).config = v;\n  },\n});\n\nconst urlRules = computed({\n  get() {\n    ensure();\n    return (props.node as any).config.url.rules as Array<any>;\n  },\n  set(v) {\n    (props.node as any).config.url.rules = v;\n  },\n});\n\nconst schedules = computed({\n  get() {\n    ensure();\n    return (props.node as any).config.schedules as Array<any>;\n  },\n  set(v) {\n    (props.node as any).config.schedules = v;\n  },\n});\n\nconst menuContexts = ['all', 'page', 'selection', 'image', 'link', 'video', 'audio'];\n\nfunction move(arr: any[], i: number, d: number) {\n  const j = i + d;\n  if (j < 0 || j >= arr.length) return;\n  const t = arr[i];\n  arr[i] = arr[j];\n  arr[j] = t;\n}\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue",
    "content": "<template>\n  <div>\n    <SelectorEditor :node=\"node\" :allowPick=\"true\" />\n    <div class=\"form-section\">\n      <div class=\"form-group\">\n        <label class=\"form-label\">事件类型</label>\n        <input\n          class=\"form-input\"\n          v-model=\"(node as any).config.event\"\n          placeholder=\"如 input/change/mouseover\"\n        />\n      </div>\n      <div class=\"form-group checkbox-group\">\n        <label class=\"checkbox-label\"\n          ><input type=\"checkbox\" v-model=\"(node as any).config.bubbles\" /> 冒泡</label\n        >\n        <label class=\"checkbox-label\"\n          ><input type=\"checkbox\" v-model=\"(node as any).config.cancelable\" /> 可取消</label\n        >\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport SelectorEditor from './SelectorEditor.vue';\n\ndefineProps<{ node: NodeBase }>();\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWait.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">等待条件 (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"waitJson\"\n        rows=\"4\"\n        placeholder='{\"text\":\"ok\",\"appear\":true}'\n      ></textarea>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\n\nconst waitJson = computed({\n  get() {\n    const n = props.node;\n    if (!n || n.type !== 'wait') return '';\n    try {\n      return JSON.stringify((n as any).config?.condition || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    const n = props.node;\n    if (!n || n.type !== 'wait') return;\n    try {\n      (n as any).config = { ...((n as any).config || {}), condition: JSON.parse(v || '{}') };\n    } catch {}\n  },\n});\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"form-group\">\n      <label class=\"form-label\">条件 (JSON)</label>\n      <textarea\n        class=\"form-textarea\"\n        v-model=\"whileJson\"\n        rows=\"3\"\n        placeholder='{\"expression\":\"workflow.count < 3\"}'\n      ></textarea>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">子流 ID</label>\n      <input\n        class=\"form-input\"\n        v-model=\"(node as any).config.subflowId\"\n        placeholder=\"选择或新建子流\"\n      />\n      <button class=\"btn-sm\" style=\"margin-top: 8px\" @click=\"onCreateSubflow\">新建子流</button>\n    </div>\n    <div class=\"form-group\">\n      <label class=\"form-label\">最大迭代次数（可选）</label>\n      <input\n        class=\"form-input\"\n        type=\"number\"\n        min=\"0\"\n        v-model.number=\"(node as any).config.maxIterations\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport { computed } from 'vue';\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{ node: NodeBase }>();\nconst emit = defineEmits<{ (e: 'create-subflow', id: string): void }>();\n\nconst whileJson = computed({\n  get() {\n    try {\n      return JSON.stringify((props.node as any).config?.condition || {}, null, 2);\n    } catch {\n      return '';\n    }\n  },\n  set(v: string) {\n    try {\n      (props.node as any).config = {\n        ...((props.node as any).config || {}),\n        condition: JSON.parse(v || '{}'),\n      };\n    } catch {}\n  },\n});\n\nfunction onCreateSubflow() {\n  const id = prompt('请输入新子流ID');\n  if (!id) return;\n  emit('create-subflow', id);\n  const n = props.node as any;\n  if (n && n.config) n.config.subflowId = id;\n}\n</script>\n\n<style scoped></style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/components/properties/SelectorEditor.vue",
    "content": "<template>\n  <div class=\"form-section\">\n    <div class=\"section-header\">\n      <span class=\"section-title\">{{ title || '选择器' }}</span>\n      <button v-if=\"allowPick\" class=\"btn-sm btn-primary\" @click=\"pickFromPage\">从页面选择</button>\n    </div>\n    <div class=\"selector-list\" data-field=\"target.candidates\">\n      <div class=\"selector-item\" v-for=\"(c, i) in list\" :key=\"i\">\n        <select class=\"form-select-sm\" v-model=\"c.type\">\n          <option value=\"css\">CSS</option>\n          <option value=\"attr\">Attr</option>\n          <option value=\"aria\">ARIA</option>\n          <option value=\"text\">Text</option>\n          <option value=\"xpath\">XPath</option>\n        </select>\n        <input class=\"form-input-sm flex-1\" v-model=\"c.value\" placeholder=\"选择器值\" />\n        <button class=\"btn-icon-sm\" @click=\"move(i, -1)\" :disabled=\"i === 0\">↑</button>\n        <button class=\"btn-icon-sm\" @click=\"move(i, 1)\" :disabled=\"i === list.length - 1\">↓</button>\n        <button class=\"btn-icon-sm danger\" @click=\"remove(i)\">×</button>\n      </div>\n      <button class=\"btn-sm\" @click=\"add\">+ 添加选择器</button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\n/* eslint-disable vue/no-mutating-props */\nimport type { NodeBase } from '@/entrypoints/background/record-replay/types';\n\nconst props = defineProps<{\n  node: NodeBase;\n  allowPick?: boolean;\n  targetKey?: string;\n  title?: string;\n}>();\nconst key = (props.targetKey || 'target') as string;\n\nfunction ensureTarget() {\n  const n: any = props.node;\n  if (!n.config) n.config = {};\n  if (!n.config[key]) n.config[key] = { candidates: [] };\n  if (!Array.isArray(n.config[key].candidates)) n.config[key].candidates = [];\n}\n\nconst list = {\n  get value() {\n    ensureTarget();\n    return ((props.node as any).config[key].candidates || []) as Array<{\n      type: string;\n      value: string;\n    }>;\n  },\n} as any as Array<{ type: string; value: string }>;\n\nfunction add() {\n  ensureTarget();\n  (props.node as any).config[key].candidates.push({ type: 'css', value: '' });\n}\nfunction remove(i: number) {\n  ensureTarget();\n  (props.node as any).config[key].candidates.splice(i, 1);\n}\nfunction move(i: number, d: number) {\n  ensureTarget();\n  const arr = (props.node as any).config[key].candidates as any[];\n  const j = i + d;\n  if (j < 0 || j >= arr.length) return;\n  const t = arr[i];\n  arr[i] = arr[j];\n  arr[j] = t;\n}\n\nasync function ensurePickerInjected(tabId: number) {\n  try {\n    const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);\n    if (pong && pong.status === 'pong') return;\n  } catch {}\n  try {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: ['inject-scripts/accessibility-tree-helper.js'],\n      world: 'ISOLATED',\n    } as any);\n  } catch (e) {\n    console.warn('inject picker helper failed:', e);\n  }\n}\n\nasync function pickFromPage() {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (typeof tabId !== 'number') return;\n    await ensurePickerInjected(tabId);\n    const resp: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);\n    if (!resp || !resp.success) return;\n    ensureTarget();\n    const n: any = props.node;\n    const arr = Array.isArray(resp.candidates) ? resp.candidates : [];\n    const seen = new Set<string>();\n    const merged: any[] = [];\n    for (const c of arr) {\n      if (!c || !c.type || !c.value) continue;\n      const key = `${c.type}|${c.value}`;\n      if (!seen.has(key)) {\n        seen.add(key);\n        merged.push({ type: String(c.type), value: String(c.value) });\n      }\n    }\n    n.config[key].candidates = merged;\n  } catch (e) {\n    console.warn('pickFromPage failed:', e);\n  }\n}\n</script>\n\n<style scoped>\n/* No local styles; inherit from parent panel via :deep selectors */\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/form-widget-registry.ts",
    "content": "// form-widget-registry.ts — global widget registry for PropertyFormRenderer\nimport FieldExpression from '@/entrypoints/popup/components/builder/widgets/FieldExpression.vue';\nimport FieldSelector from '@/entrypoints/popup/components/builder/widgets/FieldSelector.vue';\nimport FieldDuration from '@/entrypoints/popup/components/builder/widgets/FieldDuration.vue';\nimport FieldCode from '@/entrypoints/popup/components/builder/widgets/FieldCode.vue';\nimport FieldKeySequence from '@/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue';\nimport FieldTargetLocator from '@/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue';\nimport type { Component } from 'vue';\n\nconst REG = new Map<string, Component>();\n\nexport function registerDefaultWidgets() {\n  REG.set('expression', FieldExpression as unknown as Component);\n  REG.set('selector', FieldSelector as unknown as Component);\n  REG.set('duration', FieldDuration as unknown as Component);\n  REG.set('code', FieldCode as unknown as Component);\n  REG.set('keysequence', FieldKeySequence as unknown as Component);\n  // Structured TargetLocator based on a selector input\n  REG.set('targetlocator', FieldTargetLocator as unknown as Component);\n}\n\nexport function getWidget(name?: string): Component | null {\n  if (!name) return null;\n  return REG.get(name) || null;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/node-spec-registry.ts",
    "content": "export * from 'chrome-mcp-shared';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/node-spec.ts",
    "content": "export * from 'chrome-mcp-shared';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/node-specs-builtin.ts",
    "content": "export * from 'chrome-mcp-shared';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/toast.ts",
    "content": "// toast.ts - lightweight toast event bus for builder UI\n// Usage: import { toast } and call toast('message', 'warn'|'error'|'info')\n\nexport type ToastLevel = 'info' | 'warn' | 'error';\n\nexport function toast(message: string, level: ToastLevel = 'warn') {\n  try {\n    const ev = new CustomEvent('rr_toast', { detail: { message: String(message), level } });\n    window.dispatchEvent(ev);\n  } catch {\n    // as a last resort\n    console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log']('[toast]', message);\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/transforms.ts",
    "content": "import type {\n  Flow as FlowV2,\n  NodeBase,\n  Edge as EdgeV2,\n} from '@/entrypoints/background/record-replay/types';\nimport {\n  nodesToSteps as sharedNodesToSteps,\n  stepsToNodes as sharedStepsToNodes,\n  topoOrder as sharedTopoOrder,\n} from 'chrome-mcp-shared';\nimport { STEP_TYPES } from 'chrome-mcp-shared';\nimport { EDGE_LABELS } from 'chrome-mcp-shared';\n\nexport function newId(prefix: string) {\n  return `${prefix}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nexport type NodeType = NodeBase['type'];\n\nexport function defaultConfigFor(t: NodeType): any {\n  if ((t as any) === 'trigger') return { type: 'manual', description: '' };\n  if (t === STEP_TYPES.CLICK || t === STEP_TYPES.FILL)\n    return { target: { candidates: [] }, value: t === STEP_TYPES.FILL ? '' : undefined };\n  if (t === STEP_TYPES.IF)\n    return { branches: [{ id: newId('case'), name: '', expr: '' }], else: true };\n  if (t === STEP_TYPES.NAVIGATE) return { url: '' };\n  if (t === STEP_TYPES.WAIT) return { condition: { text: '', appear: true } };\n  if (t === STEP_TYPES.ASSERT) return { assert: { exists: '' } };\n  if (t === STEP_TYPES.KEY) return { keys: '' };\n  if (t === STEP_TYPES.DELAY) return { ms: 1000 };\n  if (t === STEP_TYPES.HTTP) return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' };\n  if (t === STEP_TYPES.EXTRACT) return { selector: '', attr: 'text', js: '', saveAs: '' };\n  if (t === STEP_TYPES.SCREENSHOT) return { selector: '', fullPage: false, saveAs: 'shot' };\n  if (t === STEP_TYPES.DRAG)\n    return { start: { candidates: [] }, end: { candidates: [] }, path: [] };\n  if (t === STEP_TYPES.SCROLL)\n    return { mode: 'offset', offset: { x: 0, y: 300 }, target: { candidates: [] } };\n  if (t === STEP_TYPES.TRIGGER_EVENT)\n    return { target: { candidates: [] }, event: 'input', bubbles: true, cancelable: false };\n  if (t === STEP_TYPES.SET_ATTRIBUTE) return { target: { candidates: [] }, name: '', value: '' };\n  if (t === STEP_TYPES.LOOP_ELEMENTS)\n    return { selector: '', saveAs: 'elements', itemVar: 'item', subflowId: '' };\n  if (t === STEP_TYPES.SWITCH_FRAME) return { frame: { index: 0, urlContains: '' } };\n  if (t === STEP_TYPES.HANDLE_DOWNLOAD)\n    return { filenameContains: '', waitForComplete: true, timeoutMs: 60000, saveAs: 'download' };\n  if (t === STEP_TYPES.EXECUTE_FLOW) return { flowId: '', inline: true, args: {} };\n  if (t === STEP_TYPES.OPEN_TAB) return { url: '', newWindow: false };\n  if (t === STEP_TYPES.SWITCH_TAB) return { tabId: null, urlContains: '', titleContains: '' };\n  if (t === STEP_TYPES.CLOSE_TAB) return { tabIds: [], url: '' };\n  if (t === STEP_TYPES.SCRIPT) return { world: 'ISOLATED', code: '', saveAs: '', assign: {} };\n  return {};\n}\n\nexport function stepsToNodes(steps: any[]): NodeBase[] {\n  const base = sharedStepsToNodes(steps) as unknown as NodeBase[];\n  // add simple UI positions\n  base.forEach((n, i) => {\n    (n as any).ui = (n as any).ui || { x: 200, y: 120 + i * 120 };\n  });\n  return base;\n}\n\nexport function topoOrder(nodes: NodeBase[], edges: EdgeV2[]): NodeBase[] {\n  const filtered = (edges || []).filter((e) => !e.label || e.label === EDGE_LABELS.DEFAULT);\n  return sharedTopoOrder(nodes as any, filtered as any) as any;\n}\n\nexport function nodesToSteps(nodes: NodeBase[], edges: EdgeV2[]): any[] {\n  // Exclude non-executable nodes like 'trigger' and cut edges from them\n  const execNodes = (nodes || []).filter((n) => n.type !== ('trigger' as any));\n  const filtered = (edges || []).filter(\n    (e) =>\n      (!e.label || e.label === EDGE_LABELS.DEFAULT) && !execNodes.every((n) => n.id !== e.from),\n  );\n  return sharedNodesToSteps(execNodes as any, filtered as any);\n}\n\nexport function autoChainEdges(nodes: NodeBase[]): EdgeV2[] {\n  const arr: EdgeV2[] = [];\n  for (let i = 0; i < nodes.length - 1; i++)\n    arr.push({\n      id: newId('e'),\n      from: nodes[i].id,\n      to: nodes[i + 1].id,\n      label: EDGE_LABELS.DEFAULT,\n    });\n  return arr;\n}\n\nexport function summarizeNode(n?: NodeBase | null): string {\n  if (!n) return '';\n  if (n.type === STEP_TYPES.CLICK || n.type === STEP_TYPES.FILL)\n    return n.config?.target?.candidates?.[0]?.value || '未配置选择器';\n  if (n.type === STEP_TYPES.NAVIGATE) return n.config?.url || '';\n  if (n.type === STEP_TYPES.KEY) return n.config?.keys || '';\n  if (n.type === STEP_TYPES.DELAY) return `${Number(n.config?.ms || 0)}ms`;\n  if (n.type === STEP_TYPES.HTTP) return `${n.config?.method || 'GET'} ${n.config?.url || ''}`;\n  if (n.type === STEP_TYPES.EXTRACT)\n    return `${n.config?.selector || ''} -> ${n.config?.saveAs || ''}`;\n  if (n.type === STEP_TYPES.SCREENSHOT)\n    return n.config?.selector\n      ? `el(${n.config.selector}) -> ${n.config?.saveAs || ''}`\n      : `fullPage -> ${n.config?.saveAs || ''}`;\n  if (n.type === STEP_TYPES.TRIGGER_EVENT)\n    return `${n.config?.event || ''} ${n.config?.target?.candidates?.[0]?.value || ''}`;\n  if (n.type === STEP_TYPES.SET_ATTRIBUTE)\n    return `${n.config?.name || ''}=${n.config?.value ?? ''}`;\n  if (n.type === STEP_TYPES.LOOP_ELEMENTS)\n    return `${n.config?.selector || ''} as ${n.config?.itemVar || 'item'} -> ${n.config?.subflowId || ''}`;\n  if (n.type === STEP_TYPES.SWITCH_FRAME)\n    return n.config?.frame?.urlContains\n      ? `url~${n.config.frame.urlContains}`\n      : `index=${Number(n.config?.frame?.index ?? 0)}`;\n  if (n.type === STEP_TYPES.OPEN_TAB) return `open ${n.config?.url || ''}`;\n  if (n.type === STEP_TYPES.SWITCH_TAB)\n    return `switch ${n.config?.tabId || n.config?.urlContains || n.config?.titleContains || ''}`;\n  if (n.type === STEP_TYPES.CLOSE_TAB) return `close ${n.config?.url || ''}`;\n  if (n.type === STEP_TYPES.HANDLE_DOWNLOAD) return `download ${n.config?.filenameContains || ''}`;\n  if (n.type === STEP_TYPES.WAIT) return JSON.stringify(n.config?.condition || {});\n  if (n.type === STEP_TYPES.ASSERT) return JSON.stringify(n.config?.assert || {});\n  if (n.type === STEP_TYPES.IF) {\n    const cnt = Array.isArray(n.config?.branches) ? n.config.branches.length : 0;\n    return `if/else 分支数 ${cnt}${n.config?.else === false ? '' : ' + else'}`;\n  }\n  if (n.type === STEP_TYPES.SCRIPT) return (n.config?.code || '').slice(0, 30);\n  if (n.type === STEP_TYPES.DRAG) {\n    const a = n.config?.start?.candidates?.[0]?.value || '';\n    const b = n.config?.end?.candidates?.[0]?.value || '';\n    return a || b ? `${a} -> ${b}` : '拖拽';\n  }\n  if (n.type === STEP_TYPES.SCROLL) {\n    const mode = n.config?.mode || 'offset';\n    if (mode === 'offset' || mode === 'container') {\n      const x = Number(n.config?.offset?.x ?? 0);\n      const y = Number(n.config?.offset?.y ?? 0);\n      return `${mode} (${x}, ${y})`;\n    }\n    const sel = n.config?.target?.candidates?.[0]?.value || '';\n    return sel ? `element ${sel}` : 'element';\n  }\n  if (n.type === STEP_TYPES.EXECUTE_FLOW) return `exec ${n.config?.flowId || ''}`;\n  return '';\n}\n\nexport function cloneFlow(flow: FlowV2): FlowV2 {\n  return JSON.parse(JSON.stringify(flow));\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/ui-nodes.ts",
    "content": "// ui-nodes.ts — UI registry for builder nodes (sidebar, canvas, properties)\n// Comments in English to explain intent.\n\nimport { markRaw, type Component } from 'vue';\nimport type { NodeBase, NodeType } from '@/entrypoints/background/record-replay/types';\nimport { NODE_TYPES } from '@/common/node-types';\nimport { defaultConfigFor as fallbackDefaultConfig } from '@/entrypoints/popup/components/builder/model/transforms';\nimport { validateNode as fallbackValidateNode } from '@/entrypoints/popup/components/builder/model/validation';\nimport {\n  listNodeSpecs,\n  getNodeSpec,\n} from '@/entrypoints/popup/components/builder/model/node-spec-registry';\nimport { STEP_TYPES } from 'chrome-mcp-shared';\n\n// Canvas renderer components\nimport NodeCard from '@/entrypoints/popup/components/builder/components/nodes/NodeCard.vue';\nimport NodeIf from '@/entrypoints/popup/components/builder/components/nodes/NodeIf.vue';\n\n// Property components (per-node or shared)\nimport PropClick from '@/entrypoints/popup/components/builder/components/properties/PropertyClick.vue';\nimport PropFill from '@/entrypoints/popup/components/builder/components/properties/PropertyFill.vue';\nimport PropTriggerEvent from '@/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue';\nimport PropSetAttribute from '@/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue';\nimport PropDrag from '@/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue';\nimport PropScroll from '@/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue';\nimport PropNavigate from '@/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue';\nimport PropertyFromSpec from '@/entrypoints/popup/components/builder/components/properties/PropertyFromSpec.vue';\nimport { registerBuiltinSpecs } from '@/entrypoints/popup/components/builder/model/node-specs-builtin';\n\n// Register builtin NodeSpecs at module init\nregisterBuiltinSpecs();\nimport PropWait from '@/entrypoints/popup/components/builder/components/properties/PropertyWait.vue';\nimport PropAssert from '@/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue';\nimport PropDelay from '@/entrypoints/popup/components/builder/components/properties/PropertyDelay.vue';\nimport PropHttp from '@/entrypoints/popup/components/builder/components/properties/PropertyHttp.vue';\nimport PropExtract from '@/entrypoints/popup/components/builder/components/properties/PropertyExtract.vue';\nimport PropScreenshot from '@/entrypoints/popup/components/builder/components/properties/PropertyScreenshot.vue';\nimport PropLoopElements from '@/entrypoints/popup/components/builder/components/properties/PropertyLoopElements.vue';\nimport PropSwitchFrame from '@/entrypoints/popup/components/builder/components/properties/PropertySwitchFrame.vue';\nimport PropHandleDownload from '@/entrypoints/popup/components/builder/components/properties/PropertyHandleDownload.vue';\nimport PropExecuteFlow from '@/entrypoints/popup/components/builder/components/properties/PropertyExecuteFlow.vue';\nimport PropOpenTab from '@/entrypoints/popup/components/builder/components/properties/PropertyOpenTab.vue';\nimport PropSwitchTab from '@/entrypoints/popup/components/builder/components/properties/PropertySwitchTab.vue';\nimport PropCloseTab from '@/entrypoints/popup/components/builder/components/properties/PropertyCloseTab.vue';\nimport PropKey from '@/entrypoints/popup/components/builder/components/properties/PropertyKey.vue';\nimport PropIf from '@/entrypoints/popup/components/builder/components/properties/PropertyIf.vue';\nimport PropForeach from '@/entrypoints/popup/components/builder/components/properties/PropertyForeach.vue';\nimport PropWhile from '@/entrypoints/popup/components/builder/components/properties/PropertyWhile.vue';\nimport PropScript from '@/entrypoints/popup/components/builder/components/properties/PropertyScript.vue';\nimport PropTrigger from '@/entrypoints/popup/components/builder/components/properties/PropertyTrigger.vue';\n\nexport type NodeCategory = 'Flow' | 'Actions' | 'Logic' | 'Tools' | 'Tabs' | 'Page';\n\nexport interface NodeUIConfig {\n  type: NodeType;\n  label: string;\n  category: NodeCategory;\n  iconClass: string; // reuse existing Sidebar.css color classes\n  canvas: Component; // canvas renderer\n  property: Component; // property renderer\n  docUrl?: string;\n  io?: { inputs?: number | 'any'; outputs?: number | 'any' };\n  defaultConfig?: () => any;\n  validate?: (node: NodeBase) => string[];\n}\n\n// Registry contents generated from NodeSpec; use existing color/icon CSS classes\nconst baseCard = NodeCard as Component;\n\nfunction specToUi(spec: any): NodeUIConfig {\n  const canvas = spec.type === (STEP_TYPES.IF as any) ? (NodeIf as Component) : baseCard;\n  const outputs = Array.isArray(spec.ports?.outputs) ? spec.ports.outputs.length : 'any';\n  return {\n    type: spec.type as any,\n    label: spec.display?.label || String(spec.type),\n    category: (spec.display?.category || 'Actions') as any,\n    iconClass: spec.display?.iconClass || 'icon-default',\n    // Mark component refs as raw to prevent them from being proxied/reactive by consumers\n    canvas: markRaw(canvas) as Component,\n    property: markRaw(PropertyFromSpec) as Component,\n    io: { inputs: spec.ports?.inputs ?? 1, outputs },\n    defaultConfig: () => ({ ...(spec.defaults || {}) }),\n    validate: (node: NodeBase) => {\n      try {\n        const cfg = (node as any)?.config || {};\n        return (getNodeSpec(node.type as any)?.validate?.(cfg) || []) as string[];\n      } catch {\n        return [];\n      }\n    },\n  } as any;\n}\n\nexport const NODE_UI_LIST: NodeUIConfig[] = listNodeSpecs().map(specToUi);\n\nconst REGISTRY_MAP: Record<string, NodeUIConfig> = Object.fromEntries(\n  NODE_UI_LIST.map((n) => [n.type, n]),\n);\nexport const NODE_UI_REGISTRY = REGISTRY_MAP as Record<NodeType, NodeUIConfig>;\n\nexport const NODE_CATEGORIES: NodeCategory[] = [\n  'Flow',\n  'Actions',\n  'Logic',\n  'Tools',\n  'Tabs',\n  'Page',\n];\n\nexport function listByCategory(): Record<NodeCategory, NodeUIConfig[]> {\n  const out: Record<NodeCategory, NodeUIConfig[]> = {\n    Flow: [],\n    Actions: [],\n    Logic: [],\n    Tools: [],\n    Tabs: [],\n    Page: [],\n  };\n  for (const n of NODE_UI_LIST) out[n.category].push(n);\n  return out;\n}\n\nexport function canvasTypeKey(t: NodeType): string {\n  // Map to VueFlow node-types key, unique per node type\n  return `rr-${t}`;\n}\n\n// Default config resolver with registry override\nexport function defaultConfigOf(t: NodeType): any {\n  // Prefer NodeSpec defaults\n  const spec = getNodeSpec(t as any);\n  if (spec?.defaults) return { ...spec.defaults };\n  const item = (NODE_UI_REGISTRY as any)[t] as NodeUIConfig | undefined;\n  if (item?.defaultConfig) return item.defaultConfig();\n  return fallbackDefaultConfig(t as any);\n}\n\n// Validation via registry where present\nexport function validateNodeWithRegistry(n: NodeBase): string[] {\n  // Prefer NodeSpec validate\n  try {\n    const spec = getNodeSpec(n.type as any);\n    if (spec?.validate) return spec.validate((n as any).config || {}) || [];\n  } catch {}\n  const item = (NODE_UI_REGISTRY as any)[n.type] as NodeUIConfig | undefined;\n  if (item?.validate) {\n    try {\n      return item.validate(n) || [];\n    } catch {}\n  }\n  return fallbackValidateNode(n);\n}\n\n// Allow external modules to register extra UI nodes\nexport function registerExtraUiNodes(list: NodeUIConfig[]) {\n  for (const n of list) {\n    (NODE_UI_LIST as any).push(n);\n    (REGISTRY_MAP as any)[n.type] = n;\n  }\n}\n\n// IO constraints helper with sensible defaults for our graph\nexport function getIoConstraint(t: NodeType): { inputs: number | 'any'; outputs: number | 'any' } {\n  const item = (NODE_UI_REGISTRY as any)[t] as NodeUIConfig | undefined;\n  const io = item?.io || {};\n  // Defaults: most nodes have single input; outputs unlimited unless otherwise defined\n  let inputs: number | 'any' = (io.inputs as any) ?? 1;\n  let outputs: number | 'any' = (io.outputs as any) ?? 'any';\n  if ((t as any) === 'trigger') inputs = 0;\n  if ((t as any) === 'if') outputs = 'any';\n  return { inputs, outputs };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/validation.ts",
    "content": "import type { NodeBase } from '@/entrypoints/background/record-replay/types';\nimport { STEP_TYPES } from 'chrome-mcp-shared';\n\nexport function validateNode(n: NodeBase): string[] {\n  const errs: string[] = [];\n  const c: any = n.config || {};\n\n  switch (n.type) {\n    case STEP_TYPES.CLICK:\n    case STEP_TYPES.DBLCLICK:\n    case 'fill': {\n      const hasCandidate = !!c?.target?.candidates?.length;\n      if (!hasCandidate) errs.push('缺少目标选择器候选');\n      if (n.type === 'fill' && (!('value' in c) || c.value === undefined)) errs.push('缺少输入值');\n      break;\n    }\n    case STEP_TYPES.WAIT: {\n      if (!c?.condition) errs.push('缺少等待条件');\n      break;\n    }\n    case STEP_TYPES.ASSERT: {\n      if (!c?.assert) errs.push('缺少断言条件');\n      break;\n    }\n    case STEP_TYPES.NAVIGATE: {\n      if (!c?.url) errs.push('缺少 URL');\n      break;\n    }\n    case STEP_TYPES.HTTP: {\n      if (!c?.url) errs.push('HTTP: 缺少 URL');\n      if (c?.assign && typeof c.assign === 'object') {\n        const pathRe = /^[A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+|\\[\\d+\\])*$/;\n        for (const v of Object.values(c.assign)) {\n          const s = String(v);\n          if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);\n        }\n      }\n      break;\n    }\n    case STEP_TYPES.HANDLE_DOWNLOAD: {\n      // filenameContains 可选\n      break;\n    }\n    case STEP_TYPES.EXTRACT: {\n      if (!c?.saveAs) errs.push('Extract: 需填写保存变量名');\n      if (!c?.selector && !c?.js) errs.push('Extract: 需提供 selector 或 js');\n      break;\n    }\n    case STEP_TYPES.SWITCH_TAB: {\n      if (!c?.tabId && !c?.urlContains && !c?.titleContains)\n        errs.push('SwitchTab: 需提供 tabId 或 URL/标题包含');\n      break;\n    }\n    case STEP_TYPES.SCREENSHOT: {\n      // selector 可空（全页/可视区），不强制\n      break;\n    }\n    case STEP_TYPES.TRIGGER_EVENT: {\n      const hasCandidate = !!c?.target?.candidates?.length;\n      if (!hasCandidate) errs.push('缺少目标选择器候选');\n      if (!String(c?.event || '').trim()) errs.push('需提供事件类型');\n      break;\n    }\n    case STEP_TYPES.IF: {\n      const arr = Array.isArray(c?.branches) ? c.branches : [];\n      if (arr.length === 0) errs.push('需添加至少一个条件分支');\n      for (let i = 0; i < arr.length; i++) {\n        if (!String(arr[i]?.expr || '').trim()) errs.push(`分支${i + 1}: 需填写条件表达式`);\n      }\n      break;\n    }\n    case STEP_TYPES.SET_ATTRIBUTE: {\n      const hasCandidate = !!c?.target?.candidates?.length;\n      if (!hasCandidate) errs.push('缺少目标选择器候选');\n      if (!String(c?.name || '').trim()) errs.push('需提供属性名');\n      break;\n    }\n    case STEP_TYPES.LOOP_ELEMENTS: {\n      if (!String(c?.selector || '').trim()) errs.push('需提供元素选择器');\n      if (!String(c?.subflowId || '').trim()) errs.push('需提供子流 ID');\n      break;\n    }\n    case STEP_TYPES.SWITCH_FRAME: {\n      // Both index/urlContains optional; empty means switch back to top frame\n      break;\n    }\n    case STEP_TYPES.EXECUTE_FLOW: {\n      if (!String(c?.flowId || '').trim()) errs.push('需选择要执行的工作流');\n      break;\n    }\n    case STEP_TYPES.CLOSE_TAB: {\n      // 允许空（关闭当前标签页），不强制\n      break;\n    }\n    case STEP_TYPES.SCRIPT: {\n      // 若配置了 saveAs/assign，应提供 code\n      const hasAssign = c?.assign && Object.keys(c.assign).length > 0;\n      if ((c?.saveAs || hasAssign) && !String(c?.code || '').trim())\n        errs.push('Script: 配置了保存/映射但缺少代码');\n      if (hasAssign) {\n        const pathRe = /^[A-Za-z0-9_]+(?:\\.[A-Za-z0-9_]+|\\[\\d+\\])*$/;\n        for (const v of Object.values(c.assign || {})) {\n          const s = String(v);\n          if (!pathRe.test(s)) errs.push(`Assign: 路径非法 ${s}`);\n        }\n      }\n      break;\n    }\n  }\n  return errs;\n}\n\nexport function validateFlow(nodes: NodeBase[]): {\n  totalErrors: number;\n  nodeErrors: Record<string, string[]>;\n} {\n  const nodeErrors: Record<string, string[]> = {};\n  let totalErrors = 0;\n  for (const n of nodes) {\n    const e = validateNode(n);\n    if (e.length) {\n      nodeErrors[n.id] = e;\n      totalErrors += e.length;\n    }\n  }\n  return { totalErrors, nodeErrors };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/model/variables.ts",
    "content": "// variables.ts — Shared variable suggestion types for builder UI\nexport type VariableOrigin = 'global' | 'node';\n\nexport interface VariableOption {\n  key: string;\n  origin: VariableOrigin;\n  nodeId?: string;\n  nodeName?: string;\n}\n\nexport const VAR_TOKEN_OPEN = '{';\nexport const VAR_TOKEN_CLOSE = '}';\nexport const VAR_PLACEHOLDER = '{}';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/store/useBuilderStore.ts",
    "content": "import { reactive, ref } from 'vue';\nimport type {\n  Flow as FlowV2,\n  NodeBase,\n  Edge as EdgeV2,\n} from '@/entrypoints/background/record-replay/types';\nimport {\n  autoChainEdges,\n  cloneFlow,\n  newId,\n  stepsToNodes,\n  summarizeNode,\n  topoOrder,\n} from '../model/transforms';\nimport { defaultConfigOf, getIoConstraint } from '../model/ui-nodes';\nimport { toast } from '../model/toast';\n\nexport function useBuilderStore(initial?: FlowV2 | null) {\n  const flowLocal = reactive<FlowV2>({ id: '', name: '', version: 1, steps: [], variables: [] });\n  const nodes = reactive<NodeBase[]>([]);\n  const edges = reactive<EdgeV2[]>([]);\n  const activeNodeId = ref<string | null>(null);\n  const activeEdgeId = ref<string | null>(null);\n  const pendingFrom = ref<string | null>(null);\n  const pendingLabel = ref<string>('default');\n  const paletteTypes = [\n    'trigger',\n    'click',\n    'drag',\n    'scroll',\n    'fill',\n    'if',\n    'foreach',\n    'while',\n    'key',\n    'wait',\n    'assert',\n    'navigate',\n    'script',\n    'delay',\n    'http',\n    'extract',\n    'screenshot',\n    'triggerEvent',\n    'setAttribute',\n    'loopElements',\n    'switchFrame',\n    'handleDownload',\n    'executeFlow',\n    'openTab',\n    'switchTab',\n    'closeTab',\n  ] as NodeBase['type'][];\n\n  // --- history (undo/redo) ---\n  type Snapshot = {\n    flow: Pick<FlowV2, 'name' | 'description'>;\n    nodes: NodeBase[];\n    edges: EdgeV2[];\n  };\n  const HISTORY_MAX = 50;\n  const past: Snapshot[] = [];\n  const future: Snapshot[] = [];\n  function takeSnapshot(): Snapshot {\n    return {\n      flow: { name: flowLocal.name, description: flowLocal.description } as any,\n      nodes: JSON.parse(JSON.stringify(nodes)),\n      edges: JSON.parse(JSON.stringify(edges)),\n    };\n  }\n  function applySnapshot(s: Snapshot) {\n    flowLocal.name = (s.flow as any).name || '';\n    (flowLocal as any).description = (s.flow as any).description || '';\n    nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(s.nodes)));\n    edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(s.edges)));\n  }\n  function recordChange() {\n    past.push(takeSnapshot());\n    // clear redo stack on new change\n    future.length = 0;\n    if (past.length > HISTORY_MAX) past.splice(0, past.length - HISTORY_MAX);\n  }\n  function undo() {\n    if (past.length === 0) return;\n    const current = takeSnapshot();\n    const prev = past.pop()!;\n    future.push(current);\n    applySnapshot(prev);\n  }\n  function redo() {\n    if (future.length === 0) return;\n    const current = takeSnapshot();\n    const next = future.pop()!;\n    past.push(current);\n    applySnapshot(next);\n  }\n\n  function layoutIfNeeded() {\n    const startX = 120,\n      startY = 80,\n      gapY = 120;\n    nodes.forEach((n, i) => {\n      if (!n.ui || isNaN(n.ui.x) || isNaN(n.ui.y)) n.ui = { x: startX, y: startY + i * gapY };\n    });\n  }\n\n  function initFromFlow(flow: FlowV2) {\n    const deep = cloneFlow(flow);\n    Object.assign(flowLocal, deep);\n    // DAG is required - flow-store guarantees nodes/edges via normalization\n    // steps fallback removed (deprecated field no longer returned)\n    nodes.splice(0, nodes.length, ...(Array.isArray(deep.nodes) ? deep.nodes : []));\n    edges.splice(\n      0,\n      edges.length,\n      ...(Array.isArray(deep.edges) && deep.edges.length ? deep.edges : autoChainEdges(nodes)),\n    );\n    layoutIfNeeded();\n    activeNodeId.value = nodes[0]?.id || null;\n    activeEdgeId.value = null;\n    // reset history\n    past.length = 0;\n    future.length = 0;\n    past.push(takeSnapshot());\n  }\n\n  function selectNode(id: string | null) {\n    // When click on empty canvas, id can be null => deselect\n    if (id && pendingFrom.value && pendingFrom.value !== id) {\n      onConnect(pendingFrom.value, id, pendingLabel.value);\n      pendingFrom.value = null;\n    }\n    activeNodeId.value = id || null;\n    // selecting a node should clear edge selection\n    if (id) activeEdgeId.value = null;\n  }\n\n  function selectEdge(id: string | null) {\n    activeEdgeId.value = id || null;\n    if (id) activeNodeId.value = null;\n  }\n\n  function addNode(t: NodeBase['type']) {\n    const id = newId(t);\n    const n: NodeBase = {\n      id,\n      type: t,\n      name: '',\n      config: defaultConfigOf(t),\n      ui: { x: 200 + nodes.length * 24, y: 120 + nodes.length * 96 },\n    };\n    nodes.push(n);\n    if (nodes.length > 1) {\n      const prev = nodes[nodes.length - 2];\n      edges.push({ id: newId('e'), from: prev.id, to: id, label: 'default' });\n    }\n    activeNodeId.value = id;\n    recordChange();\n  }\n\n  function addNodeAt(t: NodeBase['type'], x: number, y: number) {\n    const id = newId(t);\n    const n: NodeBase = {\n      id,\n      type: t,\n      name: '',\n      config: defaultConfigOf(t),\n      ui: { x: Math.round(x), y: Math.round(y) },\n    };\n    nodes.push(n);\n    activeNodeId.value = id;\n    recordChange();\n  }\n\n  function duplicateNode(id: string) {\n    const src = nodes.find((n) => n.id === id);\n    if (!src) return;\n    const cp: NodeBase = JSON.parse(JSON.stringify(src));\n    cp.id = newId(src.type);\n    cp.name = src.name ? `${src.name} Copy` : '';\n    const baseX = cp.ui && typeof cp.ui.x === 'number' ? cp.ui.x : 200;\n    const baseY = cp.ui && typeof cp.ui.y === 'number' ? cp.ui.y : 120;\n    cp.ui = { x: baseX + 40, y: baseY + 40 };\n    nodes.push(cp);\n    activeNodeId.value = cp.id;\n    recordChange();\n  }\n\n  function removeNode(id: string) {\n    const idx = nodes.findIndex((n) => n.id === id);\n    if (idx < 0) return;\n    nodes.splice(idx, 1);\n    for (let i = edges.length - 1; i >= 0; i--) {\n      const e = edges[i];\n      if (e.from === id || e.to === id) edges.splice(i, 1);\n    }\n    // After removal, do not auto-select another node to avoid accidental batch deletes\n    activeNodeId.value = null;\n    activeEdgeId.value = null;\n    recordChange();\n  }\n\n  function removeEdge(id: string) {\n    const idx = edges.findIndex((e) => e.id === id);\n    if (idx < 0) return;\n    edges.splice(idx, 1);\n    if (activeEdgeId.value === id) activeEdgeId.value = null;\n    recordChange();\n  }\n\n  function setNodePosition(id: string, x: number, y: number) {\n    const n = nodes.find((n) => n.id === id);\n    if (!n) return;\n    n.ui = { x: Math.round(x), y: Math.round(y) };\n    // 不计入历史栈，避免频繁记录；由用户触发操作（连接/新增/删除等）记录。\n  }\n\n  function connectFrom(id: string, label: string = 'default') {\n    pendingFrom.value = id;\n    pendingLabel.value = label;\n  }\n\n  function onConnect(sourceId: string, targetId: string, label: string = 'default') {\n    // prevent self-loop\n    if (sourceId === targetId) {\n      toast('不能连接到自身', 'warn');\n      return;\n    }\n    // IO constraints\n    try {\n      const src = nodes.find((n) => n.id === sourceId);\n      const dst = nodes.find((n) => n.id === targetId);\n      if (!src || !dst) return;\n      const srcIo = getIoConstraint(src.type as any);\n      const dstIo = getIoConstraint(dst.type as any);\n      // Inputs: respect numeric maximum; 'any' means unlimited\n      const incoming = edges.filter((e) => e.to === targetId).length;\n      if (dstIo.inputs !== 'any' && incoming >= (dstIo.inputs as number)) {\n        toast(`该节点最多允许 ${dstIo.inputs} 条入边`, 'warn');\n        return;\n      }\n      // Outputs: respect numeric maximum when defined\n      if (srcIo.outputs !== 'any') {\n        const outgoing = edges.filter((e) => e.from === sourceId).length;\n        if (outgoing >= (srcIo.outputs as number)) {\n          toast(`该节点最多允许 ${srcIo.outputs} 条出边`, 'warn');\n          return;\n        }\n      }\n    } catch {}\n    // 单一同标签出边：删除同源 + 同标签的已有边\n    for (let i = edges.length - 1; i >= 0; i--) {\n      const e = edges[i];\n      const lab = e.label || 'default';\n      if (e.from === sourceId && lab === label) edges.splice(i, 1);\n    }\n    // avoid duplicate for same pair+label\n    if (\n      edges.some(\n        (e) => e.from === sourceId && e.to === targetId && (e.label || 'default') === label,\n      )\n    )\n      return;\n    edges.push({ id: newId('e'), from: sourceId, to: targetId, label });\n    recordChange();\n    // auto select the newly created edge\n    try {\n      const last = edges[edges.length - 1];\n      activeEdgeId.value = last?.id || null;\n      activeNodeId.value = null;\n    } catch {}\n  }\n\n  /**\n   * Derive available variables for the property panel.\n   * - Includes declared flow variables (global)\n   * - Includes variables produced by preceding nodes (saveAs/assign/itemVar etc.)\n   * If currentId is provided, only nodes before it in topological order are considered.\n   */\n  function listAvailableVariables(currentId?: string): Array<{\n    key: string;\n    origin: 'global' | 'node';\n    nodeId?: string;\n    nodeName?: string;\n  }> {\n    const result: Array<{\n      key: string;\n      origin: 'global' | 'node';\n      nodeId?: string;\n      nodeName?: string;\n    }> = [];\n    const seen = new Set<string>();\n\n    // 1) Flow-declared variables\n    const declared = (flowLocal.variables || []) as Array<{ key: string }>;\n    for (const v of declared) {\n      const k = String(v?.key || '').trim();\n      if (!k || seen.has(k)) continue;\n      seen.add(k);\n      result.push({ key: k, origin: 'global' });\n    }\n\n    // 2) Variables derived from previous nodes\n    const ordered = topoOrder(nodes as any, edges as any);\n    let cutoffIndex =\n      typeof currentId === 'string' ? ordered.findIndex((n) => n.id === currentId) : -1;\n    if (cutoffIndex < 0) cutoffIndex = ordered.length; // include all if not found\n    const prevNodes = ordered.slice(0, cutoffIndex);\n    for (const n of prevNodes) {\n      const cfg: any = (n as any).config || {};\n      const nodeName = String((n as any).name || n.id || 'node');\n      const pushVar = (k: string) => {\n        const key = String(k || '').trim();\n        if (!key || seen.has(key)) return;\n        seen.add(key);\n        result.push({ key, origin: 'node', nodeId: n.id, nodeName });\n      };\n      // Generic saveAs\n      if (typeof cfg.saveAs === 'string') pushVar(cfg.saveAs);\n      // assign mapping (keys are variable names)\n      if (cfg.assign && typeof cfg.assign === 'object') {\n        for (const k of Object.keys(cfg.assign)) pushVar(k);\n      }\n      // loop elements: list var + item var\n      if ((n as any).type === 'loopElements') {\n        if (typeof cfg.saveAs === 'string') pushVar(cfg.saveAs);\n        if (typeof cfg.itemVar === 'string') pushVar(cfg.itemVar);\n      }\n    }\n\n    return result;\n  }\n\n  function importFromSteps() {\n    const arr = stepsToNodes(flowLocal.steps || []);\n    nodes.splice(0, nodes.length, ...arr);\n    edges.splice(0, edges.length, ...autoChainEdges(arr));\n    layoutIfNeeded();\n    recordChange();\n  }\n\n  // --- subflow management ---\n  const currentSubflowId = ref<string | null>(null);\n  function ensureSubflows() {\n    if (!flowLocal.subflows) (flowLocal as any).subflows = {} as any;\n  }\n  function listSubflowIds(): string[] {\n    ensureSubflows();\n    return Object.keys((flowLocal as any).subflows || {});\n  }\n  function addSubflow(id: string) {\n    ensureSubflows();\n    const sf = (flowLocal as any).subflows as any;\n    if (!id || sf[id]) return;\n    sf[id] = { nodes: [], edges: [] };\n    recordChange();\n  }\n  function removeSubflow(id: string) {\n    ensureSubflows();\n    const sf = (flowLocal as any).subflows as any;\n    if (!sf[id]) return;\n    delete sf[id];\n    if (currentSubflowId.value === id) switchToMain();\n    recordChange();\n  }\n  function flushCurrent() {\n    if (!currentSubflowId.value) {\n      // write back main\n      (flowLocal as any).nodes = JSON.parse(JSON.stringify(nodes));\n      (flowLocal as any).edges = JSON.parse(JSON.stringify(edges));\n      return;\n    }\n    ensureSubflows();\n    (flowLocal as any).subflows[currentSubflowId.value] = {\n      nodes: JSON.parse(JSON.stringify(nodes)),\n      edges: JSON.parse(JSON.stringify(edges)),\n    };\n  }\n  function switchToMain() {\n    flushCurrent();\n    currentSubflowId.value = null;\n    nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify((flowLocal.nodes || []) as any)));\n    edges.splice(0, edges.length, ...JSON.parse(JSON.stringify((flowLocal.edges || []) as any)));\n    layoutIfNeeded();\n  }\n  function switchToSubflow(id: string) {\n    flushCurrent();\n    currentSubflowId.value = id;\n    ensureSubflows();\n    const sf = (flowLocal as any).subflows[id] || { nodes: [], edges: [] };\n    nodes.splice(0, nodes.length, ...JSON.parse(JSON.stringify(sf.nodes || [])));\n    edges.splice(0, edges.length, ...JSON.parse(JSON.stringify(sf.edges || [])));\n    layoutIfNeeded();\n  }\n  const isEditingMain = () => currentSubflowId.value == null;\n\n  /**\n   * Export flow for saving. This properly handles subflow editing:\n   * 1. Flushes current canvas state back to flowLocal\n   * 2. Returns a deep copy to avoid reference issues\n   *\n   * IMPORTANT: Always use this method for saving instead of directly\n   * accessing store.nodes/edges, which may contain subflow data.\n   *\n   * NOTE: flow.steps is no longer written here. The storage layer (flow-store.ts)\n   * will strip steps on save. Only nodes/edges are the source of truth.\n   */\n  function exportFlowForSave(): FlowV2 {\n    // Step 1: Flush current canvas state to flowLocal\n    flushCurrent();\n\n    // Step 2: Return deep copy to prevent mutation\n    return JSON.parse(JSON.stringify(flowLocal));\n  }\n\n  function summarize(id?: string) {\n    const n = nodes.find((x) => x.id === id);\n    return summarizeNode(n || null);\n  }\n\n  // 备用布局：分层 + 重心排序（不依赖外部库）\n  function layoutFallback() {\n    const idMap = new Map<string, NodeBase>();\n    nodes.forEach((n) => idMap.set(n.id, n));\n\n    // Build graph using all edges (include branches like case:/else/onError)\n    const inEdges = new Map<string, EdgeV2[]>();\n    const outEdges = new Map<string, EdgeV2[]>();\n    for (const n of nodes) {\n      inEdges.set(n.id, []);\n      outEdges.set(n.id, []);\n    }\n    for (const e of edges) {\n      if (!idMap.has(e.from) || !idMap.has(e.to)) continue;\n      inEdges.get(e.to)!.push(e);\n      outEdges.get(e.from)!.push(e);\n    }\n\n    // Kahn topo with all edges; fall back to original order on cycles\n    const indeg = new Map<string, number>();\n    nodes.forEach((n) => indeg.set(n.id, inEdges.get(n.id)!.length));\n    const q: string[] = [];\n    // Prefer trigger and existing left-most nodes first for stability\n    const roots = nodes\n      .filter((n) => (indeg.get(n.id) || 0) === 0)\n      .sort(\n        (a, b) =>\n          (a.type === ('trigger' as any) ? -1 : 0) - (b.type === ('trigger' as any) ? -1 : 0),\n      );\n    roots.forEach((r) => q.push(r.id));\n    const topo: string[] = [];\n    const indegMut = new Map(indeg);\n    while (q.length) {\n      const v = q.shift()!;\n      topo.push(v);\n      for (const e of outEdges.get(v) || []) {\n        const d = (indegMut.get(e.to) || 0) - 1;\n        indegMut.set(e.to, d);\n        if (d === 0) q.push(e.to);\n      }\n    }\n    if (topo.length < nodes.length) {\n      // Graph may contain cycles; append remaining nodes in original order\n      for (const n of nodes) if (!topo.includes(n.id)) topo.push(n.id);\n    }\n\n    // Level assignment: level = max(parent.level + 1)\n    const level = new Map<string, number>();\n    for (const id of topo) {\n      const parents = inEdges.get(id) || [];\n      let lv = 0;\n      for (const e of parents) lv = Math.max(lv, (level.get(e.from) || 0) + 1);\n      // Ensure trigger stays at level 0\n      const node = idMap.get(id)!;\n      if ((node.type as any) === 'trigger') lv = 0;\n      level.set(id, lv);\n    }\n\n    // Group nodes by level\n    const maxLevel = Math.max(0, ...Array.from(level.values()));\n    const layers: string[][] = Array.from({ length: maxLevel + 1 }, () => []);\n    for (const id of topo) layers[level.get(id) || 0].push(id);\n\n    // Barycenter/median ordering per layer based on parent y-index\n    const yIndex = new Map<string, number>();\n    // initialize first layer stable order\n    layers[0].forEach((id, i) => yIndex.set(id, i));\n    for (let lv = 1; lv < layers.length; lv++) {\n      const arr = layers[lv];\n      const scored = arr.map((id) => {\n        const ps = inEdges.get(id) || [];\n        const parentIdx = ps\n          .map((e) => yIndex.get(e.from))\n          .filter((v): v is number => typeof v === 'number');\n        const score = parentIdx.length\n          ? parentIdx.reduce((a, b) => a + b, 0) / parentIdx.length\n          : 1e9;\n        return { id, score };\n      });\n      scored.sort((a, b) => a.score - b.score);\n      scored.forEach((s, i) => yIndex.set(s.id, i));\n      layers[lv] = scored.map((s) => s.id);\n    }\n\n    // Place nodes\n    const startX = 120;\n    const startY = 80;\n    const stepX = 280; // tighter than 300 to reduce wide gaps\n    const stepY = 110;\n    for (let lv = 0; lv < layers.length; lv++) {\n      const arr = layers[lv];\n      for (let i = 0; i < arr.length; i++) {\n        const id = arr[i];\n        const n = idMap.get(id)!;\n        n.ui = { x: startX + lv * stepX, y: startY + i * stepY } as any;\n      }\n    }\n    recordChange();\n  }\n\n  // 自动排版（ELK 优先）：\n  // - 动态引入 elkjs，避免常驻体积\n  // - 失败则回退到 layoutFallback()\n  async function layoutAuto() {\n    try {\n      // Dynamic import of bundled build to avoid 'web-worker' resolution issues\n      const mod: any = await import('elkjs/lib/elk.bundled.js');\n      const ELK = mod.default || mod.ELK || mod;\n      const elk = new ELK();\n\n      // Estimate node sizes (px). Keep close to actual NodeCard dimensions.\n      const estimateSize = (n: NodeBase) => {\n        const baseW = 280;\n        let baseH = 72;\n        if ((n.type as any) === 'if') baseH = 110;\n        return { width: baseW, height: baseH };\n      };\n\n      const children = nodes.map((n) => ({ id: n.id, ...estimateSize(n) }));\n      const elkEdges = edges\n        .filter((e) => nodes.some((n) => n.id === e.from) && nodes.some((n) => n.id === e.to))\n        .map((e) => ({ id: e.id, sources: [e.from], targets: [e.to] }));\n\n      const graph = {\n        id: 'root',\n        layoutOptions: {\n          'elk.algorithm': 'layered',\n          'elk.direction': 'RIGHT',\n          'elk.layered.spacing.nodeNodeBetweenLayers': '80',\n          'elk.spacing.nodeNode': '40',\n          'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',\n        },\n        children,\n        edges: elkEdges,\n      } as any;\n\n      const res = await elk.layout(graph);\n      const pos = new Map<string, { x: number; y: number }>();\n      for (const c of res.children || []) {\n        pos.set(String(c.id), { x: Math.round(c.x || 0), y: Math.round(c.y || 0) });\n      }\n      // anchor\n      const startX = 120;\n      const startY = 80;\n      for (const n of nodes) {\n        const p = pos.get(n.id);\n        if (p) n.ui = { x: startX + p.x, y: startY + p.y } as any;\n      }\n      recordChange();\n    } catch (e) {\n      // Fallback without dependency\n      try {\n        layoutFallback();\n        toast('ELK 自动布局不可用，已使用备用布局', 'warn');\n      } catch {}\n    }\n  }\n\n  if (initial) initFromFlow(initial);\n\n  return {\n    flowLocal,\n    nodes,\n    edges,\n    activeNodeId,\n    activeEdgeId,\n    pendingFrom,\n    pendingLabel,\n    currentSubflowId,\n    paletteTypes,\n    undo,\n    redo,\n    initFromFlow,\n    selectNode,\n    selectEdge,\n    addNode,\n    duplicateNode,\n    removeNode,\n    removeEdge,\n    setNodePosition,\n    addNodeAt,\n    connectFrom,\n    onConnect,\n    listAvailableVariables,\n    listSubflowIds,\n    addSubflow,\n    removeSubflow,\n    switchToMain,\n    switchToSubflow,\n    isEditingMain,\n    importFromSteps,\n    exportFlowForSave,\n    summarize,\n    layoutAuto,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldCode.vue",
    "content": "<template>\n  <div class=\"code\">\n    <textarea\n      class=\"form-input mono\"\n      rows=\"6\"\n      :placeholder=\"placeholder\"\n      :value=\"text\"\n      @input=\"onInput\"\n    ></textarea>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watchEffect } from 'vue';\nconst props = defineProps<{ modelValue?: string; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: string): void }>();\nconst text = ref<string>(props.modelValue ?? '');\nconst placeholder = props.field?.placeholder || '/* code */';\nfunction onInput(ev: any) {\n  const v = String(ev?.target?.value ?? '');\n  text.value = v;\n  emit('update:modelValue', v);\n}\nwatchEffect(() => (text.value = props.modelValue ?? ''));\n</script>\n\n<style scoped>\n.mono {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldDuration.vue",
    "content": "<template>\n  <div class=\"duration\">\n    <div class=\"row\">\n      <input class=\"form-input\" type=\"number\" :value=\"val\" @input=\"onNum\" min=\"0\" />\n      <select class=\"form-input unit\" :value=\"unit\" @change=\"onUnit\">\n        <option value=\"ms\">ms</option>\n        <option value=\"s\">s</option>\n      </select>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watchEffect } from 'vue';\nconst props = defineProps<{ modelValue?: number; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: number): void }>();\nconst unit = ref<'ms' | 's'>('ms');\nconst val = ref<number>(Number(props.modelValue || 0));\nwatchEffect(() => {\n  const ms = Number(props.modelValue || 0);\n  if (ms % 1000 === 0 && ms >= 1000) {\n    unit.value = 's';\n    val.value = ms / 1000;\n  } else {\n    unit.value = 'ms';\n    val.value = ms;\n  }\n});\nfunction onNum(ev: any) {\n  const n = Number(ev?.target?.value || 0);\n  val.value = n;\n  emit('update:modelValue', unit.value === 's' ? n * 1000 : n);\n}\nfunction onUnit(ev: any) {\n  unit.value = ev?.target?.value === 's' ? 's' : 'ms';\n  emit('update:modelValue', unit.value === 's' ? val.value * 1000 : val.value);\n}\n</script>\n\n<style scoped>\n.row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n.unit {\n  width: 84px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldExpression.vue",
    "content": "<template>\n  <div class=\"expr\">\n    <input class=\"form-input mono\" :placeholder=\"placeholder\" :value=\"text\" @input=\"onInput\" />\n    <div v-if=\"err\" class=\"error-item\">{{ err }}</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watchEffect } from 'vue';\nimport { evalExpression } from '@/entrypoints/background/record-replay/engine/utils/expression';\n\nconst props = defineProps<{ modelValue?: string; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: string): void }>();\nconst text = ref<string>(props.modelValue ?? '');\nconst err = ref<string>('');\nconst placeholder = props.field?.placeholder || 'e.g. vars.a > 0 && vars.flag';\n\nfunction onInput(ev: any) {\n  const v = String(ev?.target?.value ?? '');\n  text.value = v;\n  try {\n    // just validate; allow empty\n    if (v.trim()) {\n      evalExpression(v, { vars: {} as any });\n    }\n    err.value = '';\n  } catch (e: any) {\n    err.value = '表达式解析错误';\n  }\n  emit('update:modelValue', v);\n}\n\nwatchEffect(() => {\n  text.value = props.modelValue ?? '';\n});\n</script>\n\n<style scoped>\n.mono {\n  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldKeySequence.vue",
    "content": "<template>\n  <div class=\"keys\">\n    <input class=\"form-input\" :placeholder=\"placeholder\" :value=\"text\" @input=\"onInput\" />\n    <div class=\"help\">示例：Backspace Enter 或 cmd+a</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watchEffect } from 'vue';\nconst props = defineProps<{ modelValue?: string; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: string): void }>();\nconst text = ref<string>(props.modelValue ?? '');\nconst placeholder = props.field?.placeholder || 'Backspace Enter 或 cmd+a';\nfunction onInput(ev: any) {\n  const v = String(ev?.target?.value ?? '');\n  text.value = v;\n  emit('update:modelValue', v);\n}\nwatchEffect(() => (text.value = props.modelValue ?? ''));\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldSelector.vue",
    "content": "<template>\n  <div class=\"selector\">\n    <div class=\"row\">\n      <input class=\"form-input\" :placeholder=\"placeholder\" :value=\"text\" @input=\"onInput\" />\n      <button class=\"btn-mini\" type=\"button\" title=\"从页面拾取\" @click=\"onPick\">拾取</button>\n    </div>\n    <div class=\"help\">可输入 CSS 选择器，或点击“拾取”在页面中选择元素</div>\n    <div v-if=\"err\" class=\"error-item\">{{ err }}</div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watchEffect } from 'vue';\nconst props = defineProps<{ modelValue?: string; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: string): void }>();\nconst text = ref<string>(props.modelValue ?? '');\nconst placeholder = props.field?.placeholder || '.btn.primary';\nfunction onInput(ev: any) {\n  const v = String(ev?.target?.value ?? '');\n  text.value = v;\n  emit('update:modelValue', v);\n}\nwatchEffect(() => (text.value = props.modelValue ?? ''));\n\nconst err = ref<string>('');\nasync function ensurePickerInjected(tabId: number) {\n  try {\n    const pong = await chrome.tabs.sendMessage(tabId, { action: 'chrome_read_page_ping' } as any);\n    if (pong && pong.status === 'pong') return;\n  } catch {}\n  try {\n    await chrome.scripting.executeScript({\n      target: { tabId },\n      files: ['inject-scripts/accessibility-tree-helper.js'],\n      world: 'ISOLATED',\n    } as any);\n  } catch (e) {\n    console.warn('inject picker helper failed:', e);\n  }\n}\n\nasync function onPick() {\n  try {\n    err.value = '';\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    if (!tabId) throw new Error('未找到活动页签');\n    await ensurePickerInjected(tabId);\n    const res: any = await chrome.tabs.sendMessage(tabId, { action: 'rr_picker_start' } as any);\n    if (!res || !res.success) {\n      if (res?.cancelled) return;\n      throw new Error(res?.error || '拾取失败');\n    }\n    const candidates = Array.isArray(res.candidates) ? res.candidates : [];\n    const prefer = ['css', 'attr', 'aria', 'text'];\n    let sel = '';\n    for (const t of prefer) {\n      const c = candidates.find((x: any) => x.type === t && x.value);\n      if (c) {\n        sel = String(c.value);\n        break;\n      }\n    }\n    if (!sel && candidates[0]?.value) sel = String(candidates[0].value);\n    if (sel) {\n      text.value = sel;\n      emit('update:modelValue', sel);\n    } else {\n      err.value = '未生成有效选择器，请手动输入';\n    }\n  } catch (e: any) {\n    err.value = e?.message || String(e);\n  }\n}\n</script>\n\n<style scoped>\n.row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n.btn-mini {\n  font-size: 12px;\n  padding: 2px 6px;\n  border: 1px solid var(--rr-border);\n  border-radius: 6px;\n}\n.error-item {\n  font-size: 12px;\n  color: #ff6666;\n  margin-top: 6px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/FieldTargetLocator.vue",
    "content": "<template>\n  <div class=\"target-locator\">\n    <!-- Reuse FieldSelector UI for picking/typing a selector -->\n    <FieldSelector v-model=\"text\" :field=\"{ placeholder }\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watch, nextTick } from 'vue';\nimport FieldSelector from './FieldSelector.vue';\n\ntype Candidate = { type: 'css' | 'attr' | 'aria' | 'text' | 'xpath'; value: string };\ntype TargetLocator = { ref?: string; candidates?: Candidate[] };\n\nconst props = defineProps<{ modelValue?: TargetLocator | string; field?: any }>();\nconst emit = defineEmits<{ (e: 'update:modelValue', v?: TargetLocator): void }>();\n\nconst placeholder = props.field?.placeholder || '.btn.primary';\nconst text = ref<string>('');\n// guard to prevent emitting during initial/prop-driven sync\nconst updatingFromProps = ref<boolean>(false);\n\n// derive text from incoming modelValue (supports string or structured object)\nwatch(\n  () => props.modelValue,\n  (mv: any) => {\n    updatingFromProps.value = true;\n    if (!mv) {\n      text.value = '';\n      nextTick(() => (updatingFromProps.value = false));\n      return;\n    }\n    if (typeof mv === 'string') {\n      text.value = mv;\n      nextTick(() => (updatingFromProps.value = false));\n      return;\n    }\n    try {\n      const arr: Candidate[] = Array.isArray(mv.candidates) ? mv.candidates : [];\n      const prefer = ['css', 'attr', 'aria', 'text', 'xpath'];\n      let val = '';\n      for (const t of prefer) {\n        const c = arr.find((x) => x && x.type === t && x.value);\n        if (c) {\n          val = String(c.value || '');\n          break;\n        }\n      }\n      if (!val) val = arr[0]?.value ? String(arr[0].value) : '';\n      text.value = val;\n    } catch {\n      text.value = '';\n    }\n    nextTick(() => (updatingFromProps.value = false));\n  },\n  { immediate: true, deep: true },\n);\n\n// whenever text changes, emit structured TargetLocator (skip when syncing from props)\nwatch(\n  () => text.value,\n  (v) => {\n    if (updatingFromProps.value) return;\n    const s = String(v || '').trim();\n    if (!s) {\n      emit('update:modelValue', { candidates: [] });\n    } else {\n      emit('update:modelValue', {\n        ...(typeof props.modelValue === 'object' && props.modelValue\n          ? (props.modelValue as any)\n          : {}),\n        candidates: [{ type: 'css', value: s }],\n      });\n    }\n  },\n);\n</script>\n\n<style scoped>\n.target-locator {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/builder/widgets/VarInput.vue",
    "content": "<template>\n  <div class=\"var-input-wrap\">\n    <input\n      ref=\"inputEl\"\n      class=\"form-input\"\n      :placeholder=\"placeholder\"\n      :value=\"modelValue\"\n      @input=\"onInput\"\n      @keydown=\"onKeydown\"\n      @blur=\"onBlur\"\n      @focus=\"onFocus\"\n    />\n    <div\n      v-if=\"open && filtered.length\"\n      class=\"var-suggest\"\n      @mouseenter=\"hover = true\"\n      @mouseleave=\"\n        hover = false;\n        open = false;\n      \"\n    >\n      <div\n        v-for=\"(v, i) in filtered\"\n        :key=\"v.key + ':' + (v.nodeId || '')\"\n        class=\"var-item\"\n        :class=\"{ active: i === activeIdx }\"\n        @mousedown.prevent\n        @click=\"insertVar(v.key)\"\n        :title=\"\n          v.origin === 'node' ? `${v.key} · from ${v.nodeName || v.nodeId}` : `${v.key} · global`\n        \"\n      >\n        <span class=\"var-key\">{{ v.key }}</span>\n        <span class=\"var-origin\" :data-origin=\"v.origin\">{{\n          v.origin === 'node' ? v.nodeName || v.nodeId || 'node' : 'global'\n        }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref, watch } from 'vue';\nimport type { VariableOption } from '../model/variables';\nimport { VAR_PLACEHOLDER, VAR_TOKEN_CLOSE, VAR_TOKEN_OPEN } from '../model/variables';\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue: string;\n    variables?: VariableOption[];\n    placeholder?: string;\n    // insertion format: \"{key}\" (mustache) or \"workflow.key\" (workflowDot)\n    format?: 'mustache' | 'workflowDot';\n  }>(),\n  { modelValue: '', variables: () => [], format: 'mustache' },\n);\nconst emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();\n\nconst inputEl = ref<HTMLInputElement | null>(null);\nconst open = ref(false);\nconst hover = ref(false);\nconst activeIdx = ref(0);\n\nconst query = computed(() => {\n  const val = String(props.modelValue || '');\n  // Extract text after the last '{' up to caret when focused\n  const el = inputEl.value;\n  const pos = el?.selectionStart ?? val.length;\n  const before = val.slice(0, pos);\n  const lastOpen = before.lastIndexOf(VAR_TOKEN_OPEN);\n  const lastClose = before.lastIndexOf(VAR_TOKEN_CLOSE);\n  if (lastOpen >= 0 && lastClose < lastOpen) return before.slice(lastOpen + 1).trim();\n  // special case: contains '{}' placeholder\n  if (val.includes(VAR_PLACEHOLDER)) return '';\n  return '';\n});\n\nconst filtered = computed<VariableOption[]>(() => {\n  const all = props.variables || [];\n  const q = query.value.toLowerCase();\n  if (!q) return all;\n  return all.filter((v) => v.key.toLowerCase().startsWith(q));\n});\n\nfunction showSuggestIfNeeded(next: string) {\n  try {\n    const el = inputEl.value;\n    const pos = el?.selectionStart ?? next.length;\n    const before = next.slice(0, pos);\n    const shouldOpen = before.endsWith(VAR_TOKEN_OPEN) || next.includes(VAR_PLACEHOLDER);\n    open.value = shouldOpen;\n    if (shouldOpen) activeIdx.value = 0;\n  } catch {\n    open.value = false;\n  }\n}\n\nfunction onInput(e: Event) {\n  const target = e.target as HTMLInputElement;\n  const v = target?.value ?? '';\n  emit('update:modelValue', v);\n  showSuggestIfNeeded(v);\n}\n\nfunction onKeydown(e: KeyboardEvent) {\n  if (e.key === '{') {\n    // Defer until input updates\n    setTimeout(() => showSuggestIfNeeded(String(props.modelValue || '')), 0);\n  }\n  // Manual trigger: Ctrl/Cmd+Space opens suggestions\n  if ((e.ctrlKey || e.metaKey) && e.key === ' ') {\n    e.preventDefault();\n    open.value = (props.variables || []).length > 0;\n    activeIdx.value = 0;\n    return;\n  }\n  if (!open.value) return;\n  if (e.key === 'Escape') {\n    open.value = false;\n    return;\n  }\n  if (e.key === 'ArrowDown') {\n    e.preventDefault();\n    activeIdx.value = (activeIdx.value + 1) % Math.max(1, filtered.value.length);\n    return;\n  }\n  if (e.key === 'ArrowUp') {\n    e.preventDefault();\n    activeIdx.value =\n      (activeIdx.value - 1 + Math.max(1, filtered.value.length)) %\n      Math.max(1, filtered.value.length);\n    return;\n  }\n  if (e.key === 'Enter' || e.key === 'Tab') {\n    if (!filtered.value.length) return;\n    e.preventDefault();\n    insertVar(\n      filtered.value[Math.max(0, Math.min(activeIdx.value, filtered.value.length - 1))].key,\n    );\n  }\n}\n\nfunction onBlur() {\n  // Close after suggestions click handler\n  setTimeout(() => (!hover.value ? (open.value = false) : null), 50);\n}\nfunction onFocus() {\n  showSuggestIfNeeded(String(props.modelValue || ''));\n}\n\nfunction insertVar(key: string) {\n  const el = inputEl.value;\n  const val = String(props.modelValue || '');\n  const token =\n    props.format === 'workflowDot'\n      ? `workflow.${key}`\n      : `${VAR_TOKEN_OPEN}${key}${VAR_TOKEN_CLOSE}`;\n  if (!el) {\n    emit('update:modelValue', `${val}${token}`);\n    open.value = false;\n    return;\n  }\n  const start = el.selectionStart ?? val.length;\n  const end = el.selectionEnd ?? start;\n  const before = val.slice(0, start);\n  const after = val.slice(end);\n  const lastOpen = before.lastIndexOf(VAR_TOKEN_OPEN);\n  const lastClose = before.lastIndexOf(VAR_TOKEN_CLOSE);\n\n  let next: string;\n  if (val.includes(VAR_PLACEHOLDER)) {\n    const idx = val.indexOf(VAR_PLACEHOLDER);\n    next = val.slice(0, idx) + token + val.slice(idx + 2);\n  } else if (lastOpen >= 0 && lastClose < lastOpen) {\n    // replace incomplete token {xxx| with {key}\n    next = val.slice(0, lastOpen) + token + after;\n  } else {\n    next = before + token + after;\n  }\n  emit('update:modelValue', next);\n  // move caret after inserted token\n  requestAnimationFrame(() => {\n    try {\n      const pos =\n        props.format === 'workflowDot'\n          ? before.length + token.length\n          : next.indexOf(VAR_TOKEN_CLOSE, lastOpen >= 0 ? lastOpen : start) + 1 || next.length;\n      inputEl.value?.setSelectionRange(pos, pos);\n    } catch {}\n  });\n  open.value = false;\n}\n\nonMounted(() => {\n  // best effort: nothing special\n});\n\nwatch(\n  () => props.modelValue,\n  (v) => {\n    if (document.activeElement === inputEl.value) showSuggestIfNeeded(String(v || ''));\n  },\n);\n</script>\n\n<style scoped>\n.var-input-wrap {\n  position: relative;\n}\n.var-suggest {\n  position: absolute;\n  top: calc(100% + 4px);\n  left: 0;\n  right: 0;\n  max-height: 200px;\n  overflow: auto;\n  background: var(--rr-bg, #fff);\n  border: 1px solid rgba(0, 0, 0, 0.12);\n  border-radius: 8px;\n  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);\n  z-index: 1000;\n}\n.var-item {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  padding: 6px 8px;\n  cursor: pointer;\n  font-size: 12px;\n}\n.var-item.active,\n.var-item:hover {\n  background: var(--rr-hover, #f3f4f6);\n}\n.var-key {\n  color: var(--rr-text, #111);\n}\n.var-origin {\n  color: var(--rr-muted, #666);\n}\n.var-origin[data-origin='node'] {\n  color: #2563eb;\n}\n.var-origin[data-origin='global'] {\n  color: #059669;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/BoltIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"1.5\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/CheckIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 20 20\"\n    fill=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      fill-rule=\"evenodd\"\n      d=\"M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.052-.143Z\"\n      clip-rule=\"evenodd\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-small',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/DatabaseIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/DocumentIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/EditIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/MarkerIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z\"\n    />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 6h.008v.008H6V6z\" />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/RecordIcon.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" :class=\"className\">\n    <circle cx=\"12\" cy=\"12\" r=\"8\" :fill=\"recording ? '#ef4444' : 'currentColor'\" />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n  recording?: boolean;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n  recording: false,\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/RefreshIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/StopIcon.vue",
    "content": "<template>\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" :class=\"className\">\n    <rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"1\" fill=\"currentColor\" />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/TabIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-16.5 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Z\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/TrashIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"1.5\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/VectorIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"2\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M9 4.5a4.5 4.5 0 0 1 6 0M9 4.5V3a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 3v1.5M9 4.5a4.5 4.5 0 0 0-4.5 4.5v7.5A1.5 1.5 0 0 0 6 18h12a1.5 1.5 0 0 0 1.5-1.5V9a4.5 4.5 0 0 0-4.5-4.5M12 12l2.25 2.25M12 12l-2.25-2.25M12 12v6\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/WorkflowIcon.vue",
    "content": "<template>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"1.5\"\n    stroke=\"currentColor\"\n    :class=\"className\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z\"\n    />\n  </svg>\n</template>\n\n<script lang=\"ts\" setup>\ninterface Props {\n  className?: string;\n}\n\nwithDefaults(defineProps<Props>(), {\n  className: 'icon-default',\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/components/icons/index.ts",
    "content": "export { default as DocumentIcon } from './DocumentIcon.vue';\nexport { default as DatabaseIcon } from './DatabaseIcon.vue';\nexport { default as BoltIcon } from './BoltIcon.vue';\nexport { default as TrashIcon } from './TrashIcon.vue';\nexport { default as CheckIcon } from './CheckIcon.vue';\nexport { default as TabIcon } from './TabIcon.vue';\nexport { default as VectorIcon } from './VectorIcon.vue';\nexport { default as RecordIcon } from './RecordIcon.vue';\nexport { default as StopIcon } from './StopIcon.vue';\nexport { default as WorkflowIcon } from './WorkflowIcon.vue';\nexport { default as RefreshIcon } from './RefreshIcon.vue';\nexport { default as EditIcon } from './EditIcon.vue';\nexport { default as MarkerIcon } from './MarkerIcon.vue';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Default Popup Title</title>\n    <meta name=\"manifest.type\" content=\"browser_action\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/main.ts",
    "content": "import { createApp } from 'vue';\nimport { NativeMessageType } from 'chrome-mcp-shared';\nimport './style.css';\n// 引入AgentChat主题样式\nimport '../sidepanel/styles/agent-chat.css';\nimport { preloadAgentTheme } from '../sidepanel/composables/useAgentTheme';\nimport App from './App.vue';\n\n// 在Vue挂载前预加载主题，防止主题闪烁\npreloadAgentTheme().then(() => {\n  // Trigger ensure native connection (fire-and-forget, don't block UI mounting)\n  void chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => {\n    // Silent failure - background will handle reconnection\n  });\n  createApp(App).mount('#app');\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/popup/style.css",
    "content": "/* 现代化全局样式 */\n:root {\n  /* 字体系统 */\n  font-family:\n    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n  line-height: 1.6;\n  font-weight: 400;\n\n  /* 颜色系统 */\n  --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n  --primary-color: #667eea;\n  --primary-dark: #5a67d8;\n  --secondary-color: #764ba2;\n\n  --success-color: #48bb78;\n  --warning-color: #ed8936;\n  --error-color: #f56565;\n  --info-color: #4299e1;\n\n  --text-primary: #2d3748;\n  --text-secondary: #4a5568;\n  --text-muted: #718096;\n  --text-light: #a0aec0;\n\n  --bg-primary: #ffffff;\n  --bg-secondary: #f7fafc;\n  --bg-tertiary: #edf2f7;\n  --bg-overlay: rgba(255, 255, 255, 0.95);\n\n  --border-color: #e2e8f0;\n  --border-light: #f1f5f9;\n  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);\n  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);\n  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);\n  --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);\n\n  /* 间距系统 */\n  --spacing-xs: 4px;\n  --spacing-sm: 8px;\n  --spacing-md: 12px;\n  --spacing-lg: 16px;\n  --spacing-xl: 20px;\n  --spacing-2xl: 24px;\n  --spacing-3xl: 32px;\n\n  /* 圆角系统 */\n  --radius-sm: 4px;\n  --radius-md: 6px;\n  --radius-lg: 8px;\n  --radius-xl: 12px;\n  --radius-2xl: 16px;\n\n  /* 动画 */\n  --transition-fast: 0.15s ease;\n  --transition-normal: 0.3s ease;\n  --transition-slow: 0.5s ease;\n\n  /* 字体渲染优化 */\n  font-synthesis: none;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-text-size-adjust: 100%;\n}\n\n/* 重置样式 */\n* {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\nbody {\n  margin: 0;\n  padding: 0;\n  width: 400px;\n  min-height: 500px;\n  max-height: 600px;\n  overflow: hidden;\n  font-family: inherit;\n  background: var(--bg-secondary);\n  color: var(--text-primary);\n}\n\n#app {\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n}\n\n/* 链接样式 */\na {\n  color: var(--primary-color);\n  text-decoration: none;\n  transition: color var(--transition-fast);\n}\n\na:hover {\n  color: var(--primary-dark);\n}\n\n/* 按钮基础样式重置 */\nbutton {\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n  border: none;\n  background: none;\n  cursor: pointer;\n  transition: all var(--transition-normal);\n}\n\nbutton:disabled {\n  cursor: not-allowed;\n  opacity: 0.6;\n}\n\n/* 输入框基础样式 */\ninput,\ntextarea,\nselect {\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n  border: 1px solid var(--border-color);\n  border-radius: var(--radius-md);\n  padding: var(--spacing-sm) var(--spacing-md);\n  background: var(--bg-primary);\n  color: var(--text-primary);\n  transition: all var(--transition-fast);\n}\n\ninput:focus,\ntextarea:focus,\nselect:focus {\n  outline: none;\n  border-color: var(--primary-color);\n  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);\n}\n\n/* 滚动条样式 */\n::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: var(--bg-tertiary);\n  border-radius: var(--radius-sm);\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--border-color);\n  border-radius: var(--radius-sm);\n  transition: background var(--transition-fast);\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--text-muted);\n}\n\n/* 选择文本样式 */\n::selection {\n  background: rgba(102, 126, 234, 0.2);\n  color: var(--text-primary);\n}\n\n/* 焦点可见性 */\n:focus-visible {\n  outline: 2px solid var(--primary-color);\n  outline-offset: 2px;\n}\n\n/* 动画关键帧 */\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes slideDown {\n  from {\n    opacity: 0;\n    transform: translateY(-10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes scaleIn {\n  from {\n    opacity: 0;\n    transform: scale(0.95);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n/* 响应式断点 */\n@media (max-width: 420px) {\n  :root {\n    --spacing-xs: 3px;\n    --spacing-sm: 6px;\n    --spacing-md: 10px;\n    --spacing-lg: 14px;\n    --spacing-xl: 18px;\n    --spacing-2xl: 22px;\n    --spacing-3xl: 28px;\n  }\n}\n\n/* 高对比度模式支持 */\n@media (prefers-contrast: high) {\n  :root {\n    --border-color: #000000;\n    --text-muted: #000000;\n  }\n}\n\n/* 减少动画偏好 */\n@media (prefers-reduced-motion: reduce) {\n  * {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/quick-panel.content.ts",
    "content": "/**\n * Quick Panel Content Script\n *\n * This content script manages the Quick Panel AI Chat feature on web pages.\n * It responds to:\n * - Background messages (toggle_quick_panel from keyboard shortcut)\n * - Direct programmatic calls\n *\n * The Quick Panel provides a floating AI chat interface that:\n * - Uses Shadow DOM for style isolation\n * - Streams AI responses in real-time\n * - Supports keyboard shortcuts (Enter to send, Esc to close)\n * - Collects page context (URL, selection) automatically\n */\n\nimport { createQuickPanelController, type QuickPanelController } from '@/shared/quick-panel';\n\nexport default defineContentScript({\n  matches: ['<all_urls>'],\n  runAt: 'document_idle',\n\n  main() {\n    console.log('[QuickPanelContentScript] Content script loaded on:', window.location.href);\n    let controller: QuickPanelController | null = null;\n\n    /**\n     * Ensure controller is initialized (lazy initialization)\n     */\n    function ensureController(): QuickPanelController {\n      if (!controller) {\n        controller = createQuickPanelController({\n          title: 'Agent',\n          subtitle: 'Quick Panel',\n          placeholder: 'Ask about this page...',\n        });\n      }\n      return controller;\n    }\n\n    /**\n     * Handle messages from background script\n     */\n    function handleMessage(\n      message: unknown,\n      _sender: chrome.runtime.MessageSender,\n      sendResponse: (response?: unknown) => void,\n    ): boolean | void {\n      const msg = message as { action?: string } | undefined;\n\n      if (msg?.action === 'toggle_quick_panel') {\n        console.log('[QuickPanelContentScript] Received toggle_quick_panel message');\n        try {\n          const ctrl = ensureController();\n          ctrl.toggle();\n          const visible = ctrl.isVisible();\n          console.log('[QuickPanelContentScript] Toggle completed, visible:', visible);\n          sendResponse({ success: true, visible });\n        } catch (err) {\n          console.error('[QuickPanelContentScript] Toggle error:', err);\n          sendResponse({ success: false, error: String(err) });\n        }\n        return true; // Async response\n      }\n\n      if (msg?.action === 'show_quick_panel') {\n        try {\n          const ctrl = ensureController();\n          ctrl.show();\n          sendResponse({ success: true });\n        } catch (err) {\n          console.error('[QuickPanelContentScript] Show error:', err);\n          sendResponse({ success: false, error: String(err) });\n        }\n        return true;\n      }\n\n      if (msg?.action === 'hide_quick_panel') {\n        try {\n          if (controller) {\n            controller.hide();\n          }\n          sendResponse({ success: true });\n        } catch (err) {\n          console.error('[QuickPanelContentScript] Hide error:', err);\n          sendResponse({ success: false, error: String(err) });\n        }\n        return true;\n      }\n\n      if (msg?.action === 'get_quick_panel_status') {\n        sendResponse({\n          success: true,\n          visible: controller?.isVisible() ?? false,\n          initialized: controller !== null,\n        });\n        return true;\n      }\n\n      // Not handled\n      return false;\n    }\n\n    // Register message listener\n    chrome.runtime.onMessage.addListener(handleMessage);\n\n    // Cleanup on page unload\n    window.addEventListener('unload', () => {\n      chrome.runtime.onMessage.removeListener(handleMessage);\n      if (controller) {\n        controller.dispose();\n        controller = null;\n      }\n    });\n  },\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/shared/composables/index.ts",
    "content": "/**\n * @fileoverview Shared UI Composables\n * @description Composables shared between multiple UI entrypoints (Sidepanel, Builder, Popup, etc.)\n *\n * Note: These composables are for UI-only use. Do not import them in background scripts\n * as they depend on Vue and will bloat the service worker bundle.\n */\n\n// RR V3 RPC Client\nexport { useRRV3Rpc } from './useRRV3Rpc';\nexport type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/shared/composables/useRRV3Rpc.ts",
    "content": "/**\n * @fileoverview RR V3 Port-RPC Client Composable (Shared)\n * @description RPC client for UI components to connect with Background Service Worker\n *\n * This composable is shared between Sidepanel, Builder, and other UI entrypoints.\n *\n * Responsibilities:\n * - Connect to background via chrome.runtime.Port\n * - Provide request/response RPC calls (with timeout and cancellation)\n * - Support event stream subscription\n * - Auto-reconnect with exponential backoff\n *\n * Design considerations:\n * - MV3 service worker may be terminated due to idle, causing Port disconnect\n * - Implement idempotent reconnection and subscription recovery\n */\n\nimport { computed, onUnmounted, ref, shallowRef, type ComputedRef, type Ref } from 'vue';\n\nimport type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';\nimport type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport {\n  RR_V3_PORT_NAME,\n  createRpcRequest,\n  isRpcEvent,\n  isRpcResponse,\n  type RpcMethod,\n} from '@/entrypoints/background/record-replay-v3/engine/transport/rpc';\n\n// ==================== Types ====================\n\n/** RPC request options */\nexport interface RpcRequestOptions {\n  /** Timeout in milliseconds, 0 means no timeout */\n  timeoutMs?: number;\n  /** Abort signal for cancellation */\n  signal?: AbortSignal;\n}\n\n/** Composable configuration */\nexport interface UseRRV3RpcOptions {\n  /** Default request timeout (ms) */\n  requestTimeoutMs?: number;\n  /** Maximum reconnect attempts */\n  maxReconnectAttempts?: number;\n  /** Base delay for reconnection (ms) */\n  baseReconnectDelayMs?: number;\n  /** Auto-connect on initialization */\n  autoConnect?: boolean;\n  /** Connection state change callback */\n  onConnectionChange?: (connected: boolean) => void;\n  /** Error callback */\n  onError?: (error: string) => void;\n}\n\n/** Event listener function */\ntype EventListener = (event: RunEvent) => void;\n\n/** Pending request entry */\ninterface PendingRequest {\n  method: RpcMethod;\n  resolve: (value: JsonValue) => void;\n  reject: (error: Error) => void;\n  timeoutId: ReturnType<typeof setTimeout> | null;\n  /** AbortSignal reference for cleanup */\n  signal?: AbortSignal;\n  /** Abort handler for cleanup */\n  abortHandler?: () => void;\n}\n\n/** Composable return type */\nexport interface UseRRV3Rpc {\n  // Connection state\n  connected: Ref<boolean>;\n  connecting: Ref<boolean>;\n  reconnecting: Ref<boolean>;\n  reconnectAttempts: Ref<number>;\n  lastError: Ref<string | null>;\n  isReady: ComputedRef<boolean>;\n\n  // Diagnostics\n  pendingCount: Ref<number>;\n  subscribedRunIds: Ref<Array<RunId | null>>;\n\n  // Connection lifecycle\n  connect: () => Promise<boolean>;\n  disconnect: (reason?: string) => void;\n  ensureConnected: () => Promise<boolean>;\n\n  // RPC calls\n  request: <T extends JsonValue = JsonValue>(\n    method: RpcMethod,\n    params?: JsonObject,\n    options?: RpcRequestOptions,\n  ) => Promise<T>;\n\n  // Event subscription\n  subscribe: (runId?: RunId | null) => Promise<boolean>;\n  unsubscribe: (runId?: RunId | null) => Promise<boolean>;\n  onEvent: (listener: EventListener) => () => void;\n}\n\n// ==================== Helpers ====================\n\nfunction toErrorMessage(error: unknown): string {\n  return error instanceof Error ? error.message : String(error);\n}\n\nfunction isRunEvent(value: unknown): value is RunEvent {\n  if (typeof value !== 'object' || value === null) return false;\n  const obj = value as Record<string, unknown>;\n  return (\n    typeof obj.runId === 'string' &&\n    typeof obj.type === 'string' &&\n    typeof obj.seq === 'number' &&\n    typeof obj.ts === 'number'\n  );\n}\n\n// ==================== Composable ====================\n\n/**\n * RR V3 Port-RPC client\n */\nexport function useRRV3Rpc(options: UseRRV3RpcOptions = {}): UseRRV3Rpc {\n  // Configuration\n  const DEFAULT_TIMEOUT_MS = options.requestTimeoutMs ?? 12_000;\n  const MAX_RECONNECT_ATTEMPTS = options.maxReconnectAttempts ?? 8;\n  const BASE_RECONNECT_DELAY_MS = options.baseReconnectDelayMs ?? 500;\n\n  // Reactive state\n  const connected = ref(false);\n  const connecting = ref(false);\n  const reconnecting = ref(false);\n  const reconnectAttempts = ref(0);\n  const lastError = ref<string | null>(null);\n  const pendingCount = ref(0);\n  const subscribedRunIds = ref<Array<RunId | null>>([]);\n\n  // Internal state (non-reactive)\n  const port = shallowRef<chrome.runtime.Port | null>(null);\n  const pendingRequests = new Map<string, PendingRequest>();\n  const eventListeners = new Set<EventListener>();\n  const desiredSubscriptions = new Set<RunId | null>();\n  let connectPromise: Promise<boolean> | null = null;\n  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n  let manualDisconnect = false;\n\n  // Computed\n  const isReady = computed(() => connected.value && port.value !== null);\n\n  // ==================== Internal Methods ====================\n\n  function setError(message: string | null): void {\n    lastError.value = message;\n    if (message) options.onError?.(message);\n  }\n\n  function setConnected(next: boolean): void {\n    if (connected.value === next) return;\n    connected.value = next;\n    options.onConnectionChange?.(next);\n  }\n\n  function syncSubscriptionsSnapshot(): void {\n    const arr = Array.from(desiredSubscriptions.values());\n    arr.sort((a, b) => {\n      // Both null - equal\n      if (a === null && b === null) return 0;\n      // null comes first\n      if (a === null) return -1;\n      if (b === null) return 1;\n      return String(a).localeCompare(String(b));\n    });\n    subscribedRunIds.value = arr;\n  }\n\n  /**\n   * Clean up a pending request entry (timeout, abort listener)\n   */\n  function cleanupPendingRequest(entry: PendingRequest): void {\n    if (entry.timeoutId) {\n      clearTimeout(entry.timeoutId);\n      entry.timeoutId = null;\n    }\n    if (entry.signal && entry.abortHandler) {\n      try {\n        entry.signal.removeEventListener('abort', entry.abortHandler);\n      } catch {\n        // Ignore - signal may be invalid\n      }\n    }\n  }\n\n  function rejectAllPending(reason: string): void {\n    const error = new Error(reason);\n    for (const [requestId, entry] of pendingRequests) {\n      cleanupPendingRequest(entry);\n      entry.reject(error);\n      pendingRequests.delete(requestId);\n    }\n    pendingCount.value = 0;\n  }\n\n  async function rehydrateSubscriptions(): Promise<void> {\n    if (!isReady.value || desiredSubscriptions.size === 0) return;\n\n    for (const runId of desiredSubscriptions) {\n      try {\n        const params: JsonObject = runId === null ? {} : { runId };\n        await request('rr_v3.subscribe', params).catch(() => {\n          // Best-effort, ignore errors\n        });\n      } catch {\n        // Ignore\n      }\n    }\n  }\n\n  function scheduleReconnect(): void {\n    if (manualDisconnect || reconnectTimer) return;\n\n    if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {\n      reconnecting.value = false;\n      setError('RR V3 RPC: max reconnect attempts reached');\n      return;\n    }\n\n    reconnecting.value = true;\n    const delay = BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts.value);\n\n    reconnectTimer = setTimeout(() => {\n      reconnectTimer = null;\n      reconnectAttempts.value += 1;\n      void connect().then((ok) => {\n        if (!ok) scheduleReconnect();\n      });\n    }, delay);\n  }\n\n  // ==================== Port Handlers ====================\n\n  function handlePortDisconnect(): void {\n    // Capture disconnect reason for debugging\n    const disconnectReason = chrome.runtime.lastError?.message;\n    const reason = disconnectReason\n      ? `RR V3 RPC disconnected: ${disconnectReason}`\n      : 'RR V3 RPC disconnected';\n\n    port.value = null;\n    setConnected(false);\n    connecting.value = false;\n    rejectAllPending(reason);\n\n    // Update lastError for UI visibility (only on unexpected disconnect)\n    if (!manualDisconnect) {\n      setError(reason);\n      scheduleReconnect();\n    }\n  }\n\n  function handlePortMessage(msg: unknown): void {\n    // Handle RPC response\n    if (isRpcResponse(msg)) {\n      const entry = pendingRequests.get(msg.requestId);\n      if (!entry) return;\n\n      pendingRequests.delete(msg.requestId);\n      pendingCount.value = pendingRequests.size;\n\n      // Clean up timeout and abort listener\n      cleanupPendingRequest(entry);\n\n      if (msg.ok) {\n        entry.resolve(msg.result as JsonValue);\n      } else {\n        entry.reject(new Error(msg.error || `RPC error: ${entry.method}`));\n      }\n      return;\n    }\n\n    // Handle event push\n    if (isRpcEvent(msg)) {\n      const event = msg.event;\n      if (!isRunEvent(event)) return;\n\n      for (const listener of eventListeners) {\n        try {\n          listener(event);\n        } catch (e) {\n          console.error('[useRRV3Rpc] Event listener error:', e);\n        }\n      }\n    }\n  }\n\n  // ==================== Public Methods ====================\n\n  async function connect(): Promise<boolean> {\n    if (isReady.value) return true;\n    if (connectPromise) return connectPromise;\n\n    connectPromise = (async () => {\n      manualDisconnect = false;\n      connecting.value = true;\n      setError(null);\n\n      try {\n        if (typeof chrome === 'undefined' || !chrome.runtime?.connect) {\n          setError('chrome.runtime.connect not available');\n          return false;\n        }\n\n        const p = chrome.runtime.connect({ name: RR_V3_PORT_NAME });\n        port.value = p;\n\n        // Reset reconnect state\n        reconnectAttempts.value = 0;\n        reconnecting.value = false;\n        if (reconnectTimer) {\n          clearTimeout(reconnectTimer);\n          reconnectTimer = null;\n        }\n\n        p.onMessage.addListener(handlePortMessage);\n        p.onDisconnect.addListener(handlePortDisconnect);\n\n        setConnected(true);\n\n        // Restore subscriptions\n        void rehydrateSubscriptions();\n\n        return true;\n      } catch (error) {\n        setError(`Connection failed: ${toErrorMessage(error)}`);\n        return false;\n      } finally {\n        connecting.value = false;\n        connectPromise = null;\n      }\n    })();\n\n    return connectPromise;\n  }\n\n  function disconnect(reason?: string): void {\n    manualDisconnect = true;\n\n    if (reconnectTimer) {\n      clearTimeout(reconnectTimer);\n      reconnectTimer = null;\n    }\n    reconnecting.value = false;\n\n    const p = port.value;\n    port.value = null;\n    setConnected(false);\n    connecting.value = false;\n\n    rejectAllPending(reason || 'RR V3 RPC: client disconnected');\n\n    if (p) {\n      try {\n        p.onMessage.removeListener(handlePortMessage);\n        p.onDisconnect.removeListener(handlePortDisconnect);\n        p.disconnect();\n      } catch {\n        // Ignore\n      }\n    }\n  }\n\n  async function ensureConnected(): Promise<boolean> {\n    if (isReady.value) return true;\n    return connect();\n  }\n\n  async function request<T extends JsonValue = JsonValue>(\n    method: RpcMethod,\n    params?: JsonObject,\n    reqOptions: RpcRequestOptions = {},\n  ): Promise<T> {\n    const ready = await ensureConnected();\n    const p = port.value;\n\n    if (!ready || !p) {\n      throw new Error('RR V3 RPC: not connected');\n    }\n\n    const timeoutMs = reqOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n    const { signal } = reqOptions;\n\n    if (signal?.aborted) {\n      throw new Error('RPC request already aborted');\n    }\n\n    const req = createRpcRequest(method, params);\n\n    return new Promise<T>((resolve, reject) => {\n      const entry: PendingRequest = {\n        method,\n        resolve: resolve as (value: JsonValue) => void,\n        reject,\n        timeoutId: null,\n        signal,\n      };\n\n      // Helper to complete request with cleanup\n      const complete = (fn: () => void) => {\n        pendingRequests.delete(req.requestId);\n        pendingCount.value = pendingRequests.size;\n        cleanupPendingRequest(entry);\n        fn();\n      };\n\n      // Timeout handling\n      if (timeoutMs > 0) {\n        entry.timeoutId = setTimeout(() => {\n          complete(() => reject(new Error(`RPC timeout (${timeoutMs}ms): ${method}`)));\n        }, timeoutMs);\n      }\n\n      // Abort handling\n      if (signal) {\n        const onAbort = () => {\n          complete(() => reject(new Error('RPC request aborted')));\n        };\n        entry.abortHandler = onAbort;\n        signal.addEventListener('abort', onAbort, { once: true });\n      }\n\n      pendingRequests.set(req.requestId, entry);\n      pendingCount.value = pendingRequests.size;\n\n      try {\n        p.postMessage(req);\n      } catch (e) {\n        complete(() => reject(new Error(`Failed to send RPC request: ${toErrorMessage(e)}`)));\n      }\n    });\n  }\n\n  async function subscribe(runId: RunId | null = null): Promise<boolean> {\n    desiredSubscriptions.add(runId);\n    syncSubscriptionsSnapshot();\n\n    try {\n      const params: JsonObject = runId === null ? {} : { runId };\n      await request('rr_v3.subscribe', params);\n      return true;\n    } catch (error) {\n      setError(toErrorMessage(error));\n      return false;\n    }\n  }\n\n  async function unsubscribe(runId: RunId | null = null): Promise<boolean> {\n    desiredSubscriptions.delete(runId);\n    syncSubscriptionsSnapshot();\n\n    try {\n      const params: JsonObject = runId === null ? {} : { runId };\n      await request('rr_v3.unsubscribe', params);\n      return true;\n    } catch (error) {\n      setError(toErrorMessage(error));\n      return false;\n    }\n  }\n\n  function onEvent(listener: EventListener): () => void {\n    eventListeners.add(listener);\n    return () => eventListeners.delete(listener);\n  }\n\n  // ==================== Lifecycle ====================\n\n  onUnmounted(() => {\n    disconnect('Component unmounted');\n  });\n\n  if (options.autoConnect) {\n    void ensureConnected();\n  }\n\n  return {\n    connected,\n    connecting,\n    reconnecting,\n    reconnectAttempts,\n    lastError,\n    isReady,\n    pendingCount,\n    subscribedRunIds,\n    connect,\n    disconnect,\n    ensureConnected,\n    request,\n    subscribe,\n    unsubscribe,\n    onEvent,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/shared/utils/index.ts",
    "content": "/**\n * @fileoverview Shared Utilities Index\n * @description Utility functions shared between UI entrypoints\n */\n\n// Flow conversion utilities\nexport {\n  flowV2ToV3ForRpc,\n  flowV3ToV2ForBuilder,\n  isFlowV3,\n  isFlowV2,\n  extractFlowCandidates,\n  type FlowConversionResult,\n} from './rr-flow-convert';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/shared/utils/rr-flow-convert.ts",
    "content": "/**\n * @fileoverview V2/V3 Flow 双向转换工具\n * @description 桥接 Builder V2 Flow 类型与 V3 RPC FlowV3 类型\n *\n * 设计说明:\n * - Builder store 目前仍使用 V2 类型 (type, version, steps)\n * - RPC 层使用 V3 类型 (kind, schemaVersion, entryNodeId)\n * - 本模块提供 UI 层的类型转换，封装底层转换器\n */\n\nimport type { Flow as FlowV2 } from '@/entrypoints/background/record-replay/types';\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport {\n  convertFlowV2ToV3,\n  convertFlowV3ToV2,\n} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';\n\n// ==================== Types ====================\n\nexport interface FlowConversionResult<T> {\n  flow: T;\n  warnings: string[];\n}\n\n// ==================== V2 -> V3 (for RPC calls) ====================\n\n/**\n * 将 V2 Flow 转换为 V3 格式，用于 RPC 保存\n * @param flowV2 Builder store 中的 V2 Flow\n * @returns V3 Flow 和警告信息\n * @throws 转换失败时抛出错误\n */\nexport function flowV2ToV3ForRpc(flowV2: FlowV2): FlowConversionResult<FlowV3> {\n  const result = convertFlowV2ToV3(flowV2 as unknown as Parameters<typeof convertFlowV2ToV3>[0]);\n\n  if (!result.success || !result.data) {\n    const errorMsg =\n      result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';\n    throw new Error(`V2→V3 conversion failed: ${errorMsg}`);\n  }\n\n  return {\n    flow: result.data,\n    warnings: result.warnings,\n  };\n}\n\n// ==================== V3 -> V2 (for Builder display) ====================\n\n/**\n * 将 V3 Flow 转换为 V2 格式，用于 Builder 显示和编辑\n * @param flowV3 从 RPC 获取的 V3 Flow\n * @returns V2 Flow 和警告信息\n * @throws 转换失败时抛出错误\n */\nexport function flowV3ToV2ForBuilder(flowV3: FlowV3): FlowConversionResult<FlowV2> {\n  const result = convertFlowV3ToV2(flowV3);\n\n  if (!result.success || !result.data) {\n    const errorMsg =\n      result.errors.length > 0 ? result.errors.join('; ') : 'Unknown conversion error';\n    throw new Error(`V3→V2 conversion failed: ${errorMsg}`);\n  }\n\n  return {\n    flow: result.data as unknown as FlowV2,\n    warnings: result.warnings,\n  };\n}\n\n// ==================== Type Guards ====================\n\n/**\n * 判断是否为 V3 Flow\n * @description 用于导入时判断 JSON 格式\n */\nexport function isFlowV3(value: unknown): value is FlowV3 {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return false;\n  }\n\n  const obj = value as Record<string, unknown>;\n  return (\n    obj.schemaVersion === 3 &&\n    typeof obj.id === 'string' &&\n    typeof obj.name === 'string' &&\n    typeof obj.entryNodeId === 'string' &&\n    Array.isArray(obj.nodes)\n  );\n}\n\n/**\n * 判断是否为 V2 Flow\n * @description 用于导入时判断 JSON 格式\n */\nexport function isFlowV2(value: unknown): value is FlowV2 {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return false;\n  }\n\n  const obj = value as Record<string, unknown>;\n  return (\n    typeof obj.id === 'string' &&\n    typeof obj.name === 'string' &&\n    // V2 有 version 字段（数字），且没有 schemaVersion\n    typeof obj.version === 'number' &&\n    obj.schemaVersion === undefined &&\n    // V2 可能有 steps 或 nodes\n    (Array.isArray(obj.steps) || Array.isArray(obj.nodes))\n  );\n}\n\n// ==================== Import Helpers ====================\n\n/**\n * 从导入的 JSON 中提取 Flow 候选列表\n * @description 支持单个 Flow、Flow 数组、或 { flows: Flow[] } 格式\n */\nexport function extractFlowCandidates(parsed: unknown): unknown[] {\n  // 数组格式\n  if (Array.isArray(parsed)) {\n    return parsed;\n  }\n\n  // 对象格式\n  if (parsed && typeof parsed === 'object') {\n    const obj = parsed as Record<string, unknown>;\n\n    // { flows: [...] } 格式\n    if (Array.isArray(obj.flows)) {\n      return obj.flows;\n    }\n\n    // 单个 Flow 对象\n    if (obj.id && (Array.isArray(obj.steps) || Array.isArray(obj.nodes))) {\n      return [obj];\n    }\n  }\n\n  return [];\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/App.vue",
    "content": "<template>\n  <div class=\"h-full w-full bg-slate-50 relative agent-theme\" :data-agent-theme=\"currentTheme\">\n    <!-- Sidepanel Navigator - only show on workflows/element-markers pages -->\n    <SidepanelNavigator\n      v-if=\"activeTab !== 'agent-chat'\"\n      :activeTab=\"activeTab\"\n      @change=\"handleTabChange\"\n    />\n\n    <!-- Workflows Tab -->\n    <div v-show=\"activeTab === 'workflows'\" class=\"h-full\">\n      <WorkflowsView\n        :flows=\"filtered\"\n        :runs=\"runs\"\n        :triggers=\"triggers\"\n        :only-bound=\"onlyBound\"\n        :open-run-id=\"openRunId\"\n        @refresh=\"handleWorkflowRefresh\"\n        @create=\"createFlow\"\n        @run=\"run\"\n        @edit=\"edit\"\n        @delete=\"remove\"\n        @export=\"exportFlow\"\n        @update:only-bound=\"onlyBound = $event\"\n        @toggle-run=\"toggleRun\"\n        @create-trigger=\"createTrigger\"\n        @edit-trigger=\"editTrigger\"\n        @remove-trigger=\"removeTrigger\"\n      />\n    </div>\n\n    <!-- Agent Chat Tab -->\n    <div v-show=\"activeTab === 'agent-chat'\" class=\"h-full\">\n      <AgentChat />\n    </div>\n\n    <!-- Element Markers Tab -->\n    <div v-show=\"activeTab === 'element-markers'\" class=\"element-markers-content\">\n      <div class=\"px-4 py-4\">\n        <!-- Toolbar: Search + Add Button -->\n        <div class=\"em-toolbar\">\n          <div class=\"em-search-wrapper\">\n            <svg class=\"em-search-icon\" viewBox=\"0 0 20 20\" width=\"16\" height=\"16\">\n              <path\n                fill=\"currentColor\"\n                d=\"M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z\"\n              />\n            </svg>\n            <input\n              v-model=\"markerSearch\"\n              class=\"em-search-input\"\n              placeholder=\"搜索标注名称、选择器...\"\n              type=\"text\"\n            />\n            <button\n              v-if=\"markerSearch\"\n              class=\"em-search-clear\"\n              type=\"button\"\n              @click=\"markerSearch = ''\"\n            >\n              <svg viewBox=\"0 0 20 20\" width=\"14\" height=\"14\">\n                <path\n                  fill=\"currentColor\"\n                  d=\"M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z\"\n                />\n              </svg>\n            </button>\n          </div>\n          <button class=\"em-add-btn\" @click=\"openMarkerEditor()\" title=\"新增标注\">\n            <svg viewBox=\"0 0 20 20\" width=\"18\" height=\"18\">\n              <path\n                fill=\"currentColor\"\n                d=\"M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z\"\n              />\n            </svg>\n          </button>\n        </div>\n\n        <!-- Modal: Add/Edit Marker -->\n        <div v-if=\"markerEditorOpen\" class=\"em-modal-overlay\" @click.self=\"closeMarkerEditor\">\n          <div class=\"em-modal\">\n            <div class=\"em-modal-header\">\n              <h3 class=\"em-modal-title\">{{ editingMarkerId ? '编辑标注' : '新增标注' }}</h3>\n              <button class=\"em-modal-close\" @click=\"closeMarkerEditor\">\n                <svg viewBox=\"0 0 20 20\" width=\"18\" height=\"18\">\n                  <path\n                    fill=\"currentColor\"\n                    d=\"M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z\"\n                  />\n                </svg>\n              </button>\n            </div>\n            <form @submit.prevent=\"saveMarker\" class=\"em-form\">\n              <div class=\"em-form-row\">\n                <div class=\"em-field\">\n                  <label class=\"em-field-label\">名称</label>\n                  <input\n                    v-model=\"markerForm.name\"\n                    class=\"em-input\"\n                    placeholder=\"例如: 登录按钮\"\n                    required\n                  />\n                </div>\n              </div>\n\n              <div class=\"em-form-row em-form-row-multi\">\n                <div class=\"em-field\">\n                  <label class=\"em-field-label\">选择器类型</label>\n                  <div class=\"em-select-wrapper\">\n                    <select v-model=\"markerForm.selectorType\" class=\"em-select\">\n                      <option value=\"css\">CSS Selector</option>\n                      <option value=\"xpath\">XPath</option>\n                    </select>\n                  </div>\n                </div>\n                <div class=\"em-field\">\n                  <label class=\"em-field-label\">匹配类型</label>\n                  <div class=\"em-select-wrapper\">\n                    <select v-model=\"markerForm.matchType\" class=\"em-select\">\n                      <option value=\"prefix\">路径前缀</option>\n                      <option value=\"exact\">精确匹配</option>\n                      <option value=\"host\">域名</option>\n                    </select>\n                  </div>\n                </div>\n              </div>\n\n              <div class=\"em-form-row\">\n                <div class=\"em-field\">\n                  <label class=\"em-field-label\">选择器</label>\n                  <textarea\n                    v-model=\"markerForm.selector\"\n                    class=\"em-textarea\"\n                    placeholder=\"CSS 选择器或 XPath\"\n                    rows=\"3\"\n                    required\n                  ></textarea>\n                </div>\n              </div>\n\n              <div class=\"em-modal-actions\">\n                <button type=\"button\" class=\"em-btn em-btn-ghost\" @click=\"closeMarkerEditor\">\n                  取消\n                </button>\n                <button type=\"submit\" class=\"em-btn em-btn-primary\">\n                  {{ editingMarkerId ? '更新' : '保存' }}\n                </button>\n              </div>\n            </form>\n          </div>\n        </div>\n\n        <!-- Markers List -->\n        <div v-if=\"filteredMarkers.length > 0\" class=\"em-list\">\n          <!-- Statistics (compact) -->\n          <div class=\"em-stats-bar\">\n            <span class=\"em-stats-text\">\n              <template v-if=\"markerSearch\">\n                筛选出 <strong>{{ filteredMarkers.length }}</strong> 个标注 （共\n                {{ markers.length }} 个，{{ groupedMarkers.length }} 个域名）\n              </template>\n              <template v-else>\n                共 <strong>{{ markers.length }}</strong> 个标注，\n                <strong>{{ groupedMarkers.length }}</strong> 个域名\n              </template>\n            </span>\n          </div>\n\n          <!-- Grouped Markers by Domain -->\n          <div\n            v-for=\"domainGroup in groupedMarkers\"\n            :key=\"domainGroup.domain\"\n            class=\"em-domain-group\"\n          >\n            <!-- Domain Header -->\n            <div class=\"em-domain-header\" @click=\"toggleDomain(domainGroup.domain)\">\n              <div class=\"em-domain-info\">\n                <svg\n                  class=\"em-domain-icon\"\n                  :class=\"{ 'em-domain-icon-expanded': expandedDomains.has(domainGroup.domain) }\"\n                  viewBox=\"0 0 20 20\"\n                  width=\"16\"\n                  height=\"16\"\n                >\n                  <path fill=\"currentColor\" d=\"M6 8l4 4 4-4\" />\n                </svg>\n                <h3 class=\"em-domain-name\">{{ domainGroup.domain }}</h3>\n                <span class=\"em-domain-count\">{{ domainGroup.count }} 个标注</span>\n              </div>\n            </div>\n\n            <!-- URLs and Markers -->\n            <div v-if=\"expandedDomains.has(domainGroup.domain)\" class=\"em-domain-content\">\n              <div class=\"em-content-wrapper\">\n                <div v-for=\"urlGroup in domainGroup.urls\" :key=\"urlGroup.url\" class=\"em-url-group\">\n                  <div class=\"em-url-header\">\n                    <svg class=\"em-url-icon\" viewBox=\"0 0 16 16\" width=\"12\" height=\"12\">\n                      <path\n                        fill=\"currentColor\"\n                        d=\"M4 4a1 1 0 011-1h6a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1V4zm2 1v1h4V5H6zm0 3v1h4V8H6z\"\n                      />\n                    </svg>\n                    <span class=\"em-url-path\">{{ urlGroup.url }}</span>\n                  </div>\n\n                  <div class=\"em-markers-list\">\n                    <div v-for=\"marker in urlGroup.markers\" :key=\"marker.id\" class=\"em-marker-item\">\n                      <div class=\"em-marker-row-top\">\n                        <span class=\"em-marker-name\">{{ marker.name }}</span>\n                        <div class=\"em-marker-actions\">\n                          <button\n                            class=\"em-action-btn em-action-verify\"\n                            @click=\"validateMarker(marker)\"\n                            title=\"验证\"\n                          >\n                            <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\">\n                              <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\"\n                              />\n                            </svg>\n                          </button>\n                          <button\n                            class=\"em-action-btn em-action-edit\"\n                            @click=\"editMarker(marker)\"\n                            title=\"编辑\"\n                          >\n                            <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\">\n                              <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n                              />\n                            </svg>\n                          </button>\n                          <button\n                            class=\"em-action-btn em-action-delete\"\n                            @click=\"deleteMarker(marker)\"\n                            title=\"删除\"\n                          >\n                            <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\">\n                              <path\n                                stroke-linecap=\"round\"\n                                stroke-linejoin=\"round\"\n                                d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n                              />\n                            </svg>\n                          </button>\n                        </div>\n                      </div>\n                      <div class=\"em-marker-row-bottom\">\n                        <code class=\"em-marker-selector\" :title=\"marker.selector\">{{\n                          marker.selector\n                        }}</code>\n                        <div class=\"em-marker-tags\">\n                          <span class=\"em-tag\">{{ marker.selectorType || 'css' }}</span>\n                          <span class=\"em-tag\">{{ marker.matchType }}</span>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- No search results -->\n        <div v-else-if=\"markers.length > 0 && filteredMarkers.length === 0\" class=\"em-empty\">\n          <p>未找到匹配的标注</p>\n          <button class=\"em-btn em-btn-ghost em-empty-btn\" @click=\"markerSearch = ''\">\n            清除搜索\n          </button>\n        </div>\n\n        <!-- Empty state -->\n        <div v-else class=\"em-empty\">\n          <p>暂无标注元素</p>\n          <button class=\"em-btn em-btn-primary em-empty-btn\" @click=\"openMarkerEditor()\">\n            新增标注\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onMounted, ref, onUnmounted, watch } from 'vue';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';\nimport AgentChat from './components/AgentChat.vue';\nimport SidepanelNavigator from './components/SidepanelNavigator.vue';\nimport { WorkflowsView } from './components/workflows';\nimport { useAgentTheme } from './composables/useAgentTheme';\nimport { useWorkflowsV3, type FlowLite } from './composables/useWorkflowsV3';\n\n// Agent theme for consistent styling\nconst { theme: currentTheme, initTheme } = useAgentTheme();\n\n// Tab state - default to AgentChat\nconst activeTab = ref<'workflows' | 'element-markers' | 'agent-chat'>('agent-chat');\n\n// Handle tab change and update URL for deep linking\nfunction handleTabChange(tab: 'workflows' | 'element-markers' | 'agent-chat') {\n  activeTab.value = tab;\n  // Update URL params for deep link\n  const url = new URL(window.location.href);\n  url.searchParams.set('tab', tab);\n  history.replaceState(null, '', url.toString());\n  // Note: loadMarkers is already called by the watch on activeTab, no need to call here\n}\n\n// Workflows state - using V3 data layer\nconst workflowsV3 = useWorkflowsV3({ autoConnect: true });\nconst { flows, runs, triggers } = workflowsV3;\nconst onlyBound = ref(false);\nconst search = ref('');\nconst currentUrl = ref('');\nconst openRunId = ref<string | null>(null);\n\n// Element markers state\nconst currentPageUrl = ref('');\nconst markers = ref<ElementMarker[]>([]);\nconst editingMarkerId = ref<string | null>(null);\nconst markerForm = ref<UpsertMarkerRequest>({\n  url: '',\n  name: '',\n  selector: '',\n  selectorType: 'css',\n  matchType: 'prefix',\n});\nconst expandedDomains = ref<Set<string>>(new Set());\nconst markerSearch = ref('');\nconst markerEditorOpen = ref(false);\n\n// Filter markers based on search term\nconst filteredMarkers = computed(() => {\n  const query = markerSearch.value.trim().toLowerCase();\n  if (!query) return markers.value;\n  return markers.value.filter((m) => {\n    const name = (m.name || '').toLowerCase();\n    const selector = (m.selector || '').toLowerCase();\n    const url = (m.url || '').toLowerCase();\n    return name.includes(query) || selector.includes(query) || url.includes(query);\n  });\n});\n\n// Group markers by domain and URL\nconst groupedMarkers = computed(() => {\n  const groups = new Map<string, Map<string, ElementMarker[]>>();\n\n  for (const marker of filteredMarkers.value) {\n    // Use pre-normalized fields from storage instead of reparsing URLs\n    const domain = marker.host || '(本地文件)';\n    const fullUrl = marker.url || '(未知URL)';\n\n    if (!groups.has(domain)) {\n      groups.set(domain, new Map());\n    }\n\n    const domainGroup = groups.get(domain)!;\n    if (!domainGroup.has(fullUrl)) {\n      domainGroup.set(fullUrl, []);\n    }\n\n    domainGroup.get(fullUrl)!.push(marker);\n  }\n\n  // Convert to array and sort\n  return Array.from(groups.entries())\n    .map(([domain, urlMap]) => ({\n      domain,\n      count: Array.from(urlMap.values()).reduce((sum, arr) => sum + arr.length, 0),\n      urls: Array.from(urlMap.entries())\n        .map(([url, markers]) => ({ url, markers }))\n        .sort((a, b) => a.url.localeCompare(b.url)),\n    }))\n    .sort((a, b) => a.domain.localeCompare(b.domain));\n});\n\nconst totalMarkersCount = computed(() => filteredMarkers.value.length);\n\nconst filtered = computed(() => {\n  const list = onlyBound.value ? flows.value.filter(isBoundToCurrent) : flows.value;\n  const q = search.value.trim().toLowerCase();\n  if (!q) return list;\n  return list.filter((f) => {\n    const name = String(f.name || '').toLowerCase();\n    const domain = String(f?.meta?.domain || '').toLowerCase();\n    const tags = ((f?.meta?.tags || []) as any[]).join(',').toLowerCase();\n    return name.includes(q) || domain.includes(q) || tags.includes(q);\n  });\n});\n\nfunction isBoundToCurrent(f: FlowLite) {\n  try {\n    const bindings = f?.meta?.bindings || [];\n    if (!bindings.length) return false;\n    if (!currentUrl.value) return true;\n    const u = new URL(currentUrl.value);\n    return bindings.some((b: any) => {\n      // Support both V3 'kind' and V2 'type' field names\n      const bindingType = b.kind || b.type;\n      if (bindingType === 'domain') return u.hostname.includes(b.value);\n      if (bindingType === 'path') return u.pathname.startsWith(b.value);\n      if (bindingType === 'url') return (u.href || '').startsWith(b.value);\n      return false;\n    });\n  } catch {\n    return false;\n  }\n}\n\n// V3 Workflows methods - delegating to composable\nasync function handleWorkflowRefresh() {\n  await workflowsV3.refresh();\n}\n\nasync function exportFlow(id: string) {\n  try {\n    const flowData = await workflowsV3.exportFlow(id);\n    if (flowData) {\n      const blob = new Blob([JSON.stringify(flowData, null, 2)], { type: 'application/json' });\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = `workflow-${id}.json`;\n      a.click();\n      URL.revokeObjectURL(url);\n    }\n  } catch (e) {\n    console.warn('Export failed:', e);\n  }\n}\n\nfunction createTrigger() {\n  // V3 Trigger management not yet implemented\n  alert('V3 Trigger 管理尚未实现，暂时无法创建触发器');\n}\n\nfunction editTrigger(_id: string) {\n  // V3 Trigger management not yet implemented\n  alert('V3 Trigger 管理尚未实现，暂时无法编辑触发器');\n}\n\nasync function removeTrigger(id: string) {\n  await workflowsV3.deleteTrigger(id);\n}\n\nfunction toggleRun(id: string) {\n  openRunId.value = openRunId.value === id ? null : id;\n}\n\nasync function run(id: string) {\n  try {\n    const result = await workflowsV3.runFlow(id);\n    if (!result) console.warn('回放失败');\n  } catch {}\n}\n\nfunction edit(id: string) {\n  // V3 Builder not yet implemented - show message\n  alert('V3 Builder 尚未实现，暂时无法编辑工作流');\n  // TODO: openBuilder({ flowId: id });\n}\n\nfunction createFlow() {\n  // V3 Builder not yet implemented - show message\n  alert('V3 Builder 尚未实现，暂时无法创建工作流');\n  // TODO: openBuilder({ newFlow: true });\n}\n\nasync function remove(id: string) {\n  try {\n    const ok = confirm('确认删除该工作流？此操作不可恢复');\n    if (!ok) return;\n    await workflowsV3.deleteFlow(id);\n  } catch {}\n}\n\nfunction openBuilder(opts: { flowId?: string; newFlow?: boolean }) {\n  // Open dedicated builder window for better UX\n  const url = new URL(chrome.runtime.getURL('builder.html'));\n  if (opts.flowId) url.searchParams.set('flowId', opts.flowId);\n  if (opts.newFlow) url.searchParams.set('new', '1');\n  chrome.windows.create({ url: url.toString(), type: 'popup', width: 1280, height: 800 });\n}\n\n// Element markers functions\nfunction openMarkerEditor(marker?: ElementMarker) {\n  if (marker) {\n    editingMarkerId.value = marker.id;\n    markerForm.value = {\n      url: marker.url,\n      name: marker.name,\n      selector: marker.selector,\n      selectorType: marker.selectorType || 'css',\n      listMode: marker.listMode,\n      matchType: marker.matchType || 'prefix',\n      action: marker.action,\n    };\n  } else {\n    resetForm();\n  }\n  markerEditorOpen.value = true;\n}\n\nfunction closeMarkerEditor() {\n  markerEditorOpen.value = false;\n  resetForm();\n}\n\nfunction resetForm() {\n  markerForm.value = {\n    url: currentPageUrl.value,\n    name: '',\n    selector: '',\n    selectorType: 'css',\n    matchType: 'prefix',\n  };\n  editingMarkerId.value = null;\n}\n\nasync function loadMarkers() {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tab = tabs[0];\n    currentPageUrl.value = String(tab?.url || '');\n\n    // Only update form URL when not editing - prevents polluting edited marker's URL\n    if (!editingMarkerId.value) {\n      markerForm.value.url = currentPageUrl.value;\n    }\n\n    // Load all markers from all pages\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL,\n    });\n\n    if (res?.success) {\n      markers.value = res.markers || [];\n    }\n  } catch (e) {\n    console.error('Failed to load markers:', e);\n  }\n}\n\nasync function saveMarker() {\n  try {\n    if (!markerForm.value.selector) return;\n\n    const isEditing = !!editingMarkerId.value;\n\n    // Only set URL for new markers, not when editing existing ones\n    if (!isEditing) {\n      markerForm.value.url = currentPageUrl.value;\n    }\n\n    let res: any;\n\n    if (isEditing) {\n      // Use UPDATE for editing to preserve createdAt\n      const existingMarker = markers.value.find((m) => m.id === editingMarkerId.value);\n      if (existingMarker) {\n        const updatedMarker: ElementMarker = {\n          ...existingMarker,\n          ...markerForm.value,\n          id: editingMarkerId.value!,\n        };\n        res = await chrome.runtime.sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE,\n          marker: updatedMarker,\n        });\n      } else {\n        // Fallback to SAVE if existing marker not found in local state\n        console.warn('Editing marker not found in local state, falling back to SAVE');\n        res = await chrome.runtime.sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,\n          marker: { ...markerForm.value, id: editingMarkerId.value },\n        });\n      }\n    } else {\n      // Use SAVE for new markers\n      res = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE,\n        marker: { ...markerForm.value },\n      });\n    }\n\n    if (res?.success) {\n      closeMarkerEditor();\n      await loadMarkers();\n    }\n  } catch (e) {\n    console.error('Failed to save marker:', e);\n  }\n}\n\nfunction editMarker(marker: ElementMarker) {\n  openMarkerEditor(marker);\n}\n\nfunction cancelEdit() {\n  closeMarkerEditor();\n}\n\nasync function deleteMarker(marker: ElementMarker) {\n  try {\n    const confirmed = confirm(`确定要删除标注 \"${marker.name}\" 吗?`);\n    if (!confirmed) return;\n\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE,\n      id: marker.id,\n    });\n\n    if (res?.success) {\n      await loadMarkers();\n    }\n  } catch (e) {\n    console.error('Failed to delete marker:', e);\n  }\n}\n\nasync function validateMarker(marker: ElementMarker) {\n  try {\n    const res: any = await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE,\n      selector: marker.selector,\n      selectorType: marker.selectorType || 'css',\n      action: 'hover',\n      listMode: !!marker.listMode,\n    } as any);\n\n    // Trigger highlight in the page\n    if (res?.tool?.ok !== false) {\n      await highlightInTab(marker);\n    }\n  } catch (e) {\n    console.error('Failed to validate marker:', e);\n  }\n}\n\n/**\n * Check if element-marker.js is already injected in the tab\n * Uses a short timeout to avoid hanging on unresponsive tabs\n */\nasync function isMarkerInjected(tabId: number): Promise<boolean> {\n  try {\n    const response = await Promise.race([\n      chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),\n      new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),\n    ]);\n    return response?.status === 'pong';\n  } catch {\n    return false;\n  }\n}\n\nasync function highlightInTab(marker: ElementMarker) {\n  try {\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs[0]?.id;\n    if (!tabId) return;\n\n    // Check if already injected via ping to avoid duplicate injection\n    const alreadyInjected = await isMarkerInjected(tabId);\n\n    if (!alreadyInjected) {\n      try {\n        await chrome.scripting.executeScript({\n          target: { tabId, allFrames: true },\n          files: ['inject-scripts/element-marker.js'],\n          world: 'ISOLATED',\n        });\n      } catch {\n        // Script injection may fail on some pages\n      }\n    }\n\n    // Send highlight message to content script\n    await chrome.tabs.sendMessage(tabId, {\n      action: 'element_marker_highlight',\n      selector: marker.selector,\n      selectorType: marker.selectorType || 'css',\n      listMode: !!marker.listMode,\n    });\n  } catch (e) {\n    // Ignore errors (tab might not support content scripts)\n    console.error('Failed to highlight in tab:', e);\n  }\n}\n\nfunction toggleDomain(domain: string) {\n  if (expandedDomains.value.has(domain)) {\n    expandedDomains.value.delete(domain);\n  } else {\n    expandedDomains.value.add(domain);\n  }\n  // Trigger reactivity\n  expandedDomains.value = new Set(expandedDomains.value);\n}\n\n// Watch tab changes to load data\nwatch(activeTab, async (newTab, oldTab) => {\n  // Only load if tab actually changed (avoid double-loading on mount)\n  if (newTab === 'element-markers' && oldTab !== undefined) {\n    await loadMarkers();\n  }\n});\n\n// Auto-expand domains when search matches\nwatch(markerSearch, (query) => {\n  if (!query.trim()) return;\n  // Expand all domains that have matching markers\n  const domainsToExpand = new Set<string>();\n  for (const group of groupedMarkers.value) {\n    domainsToExpand.add(group.domain);\n  }\n  expandedDomains.value = domainsToExpand;\n});\n\nonMounted(async () => {\n  // Initialize theme\n  await initTheme();\n\n  try {\n    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n    currentUrl.value = String(tab?.url || '');\n  } catch {}\n\n  // Check URL params for initial tab\n  const params = new URLSearchParams(window.location.search);\n  const tabParam = params.get('tab');\n  if (tabParam === 'element-markers') {\n    activeTab.value = 'element-markers';\n    await loadMarkers();\n  } else if (tabParam === 'agent-chat') {\n    activeTab.value = 'agent-chat';\n  } else if (tabParam === 'workflows') {\n    activeTab.value = 'workflows';\n  }\n\n  // V3 workflows data is auto-refreshed by useWorkflowsV3 composable\n  // No need to manually call refresh here\n\n  // V2 push-based refresh is no longer needed - V3 uses event subscription\n  // Keeping commented for reference:\n  // const onMessage = (message: { type?: string }) => {\n  //   if (message?.type === BACKGROUND_MESSAGE_TYPES.RR_FLOWS_CHANGED) refresh();\n  // };\n  // chrome.runtime.onMessage.addListener(onMessage);\n});\n\nonUnmounted(() => {\n  // V3 workflows cleanup is handled by useWorkflowsV3 composable\n  // No additional cleanup needed\n});\n</script>\n\n<style scoped>\n/* reuse popup styles; only tune list item spacing for sidepanel width */\n.rr-item {\n  margin-bottom: 8px;\n}\n.rr-actions button {\n  margin-left: 6px;\n}\n\n/* Element Markers Styles - Using agent-theme tokens */\n.element-markers-content {\n  padding-bottom: 24px;\n  color: var(--ac-text, #262626);\n}\n\n.em-form {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.em-form-row {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.em-form-row-multi {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 12px;\n}\n\n.em-field {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.em-field-label {\n  font-size: 12px;\n  font-weight: 500;\n  color: var(--ac-text-subtle, #737373);\n}\n\n.em-input {\n  width: 100%;\n  height: 44px;\n  padding: 0 16px;\n  background: var(--ac-surface-muted, #f5f5f5);\n  border: none;\n  border-radius: var(--ac-radius-inner, 10px);\n  font-size: 14px;\n  color: var(--ac-text, #262626);\n  font-family: inherit;\n  outline: none;\n  transition: background var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-input:focus {\n  background: var(--ac-hover-bg, #e5e5e5);\n}\n\n.em-textarea {\n  width: 100%;\n  min-height: 80px;\n  padding: 12px 16px;\n  background: var(--ac-surface-muted, #f5f5f5);\n  border: none;\n  border-radius: var(--ac-radius-inner, 10px);\n  font-size: 14px;\n  color: var(--ac-text, #262626);\n  font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);\n  outline: none;\n  transition: background var(--ac-motion-fast, 150ms) ease;\n  resize: vertical;\n}\n\n.em-textarea:focus {\n  background: var(--ac-hover-bg, #e5e5e5);\n}\n\n.em-select-wrapper {\n  position: relative;\n}\n\n.em-select {\n  width: 100%;\n  height: 44px;\n  padding: 0 40px 0 16px;\n  background: var(--ac-surface-muted, #f5f5f5);\n  border: none;\n  border-radius: var(--ac-radius-inner, 10px);\n  font-size: 14px;\n  color: var(--ac-text, #262626);\n  font-family: inherit;\n  outline: none;\n  cursor: pointer;\n  appearance: none;\n}\n\n.em-select-wrapper::after {\n  content: '';\n  position: absolute;\n  right: 16px;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 0;\n  height: 0;\n  border-left: 5px solid transparent;\n  border-right: 5px solid transparent;\n  border-top: 6px solid var(--ac-text-subtle, #737373);\n  pointer-events: none;\n}\n\n.em-actions {\n  display: flex;\n  gap: 8px;\n  margin-top: 4px;\n}\n\n.em-btn {\n  flex: 1;\n  height: 44px;\n  border: none;\n  border-radius: var(--ac-radius-button, 10px);\n  font-size: 14px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-btn-primary {\n  background: var(--ac-accent, #d97757);\n  color: var(--ac-accent-contrast, #ffffff);\n}\n\n.em-btn-primary:hover {\n  background: var(--ac-accent-hover, #c4664a);\n  transform: translateY(-1px);\n  box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.15));\n}\n\n.em-btn-ghost {\n  background: var(--ac-surface-muted, #f5f5f5);\n  color: var(--ac-text, #404040);\n}\n\n.em-btn-ghost:hover {\n  background: var(--ac-hover-bg, #e5e5e5);\n}\n\n.em-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.em-empty {\n  text-align: center;\n  padding: 48px 20px;\n  color: var(--ac-text-subtle, #a3a3a3);\n  font-size: 14px;\n}\n\n/* Toolbar */\n.em-toolbar {\n  display: flex;\n  gap: 8px;\n  margin-bottom: 16px;\n  align-items: center;\n}\n\n.em-search-wrapper {\n  flex: 1;\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n\n.em-search-icon {\n  position: absolute;\n  left: 12px;\n  color: var(--ac-text-muted, #737373);\n  pointer-events: none;\n}\n\n.em-search-input {\n  width: 100%;\n  height: 40px;\n  padding: 0 36px;\n  background: var(--ac-surface-muted, #f5f5f5);\n  border: none;\n  border-radius: var(--ac-radius-inner, 10px);\n  font-size: 14px;\n  color: var(--ac-text, #262626);\n  outline: none;\n  transition: background var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-search-input:focus {\n  background: var(--ac-hover-bg, #e5e5e5);\n}\n\n.em-search-input::placeholder {\n  color: var(--ac-text-muted, #a3a3a3);\n}\n\n.em-search-clear {\n  position: absolute;\n  right: 8px;\n  width: 24px;\n  height: 24px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  border-radius: 50%;\n  color: var(--ac-text-muted, #737373);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-search-clear:hover {\n  background: var(--ac-hover-bg, #e5e5e5);\n  color: var(--ac-text, #262626);\n}\n\n.em-add-btn {\n  width: 40px;\n  height: 40px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--ac-accent, #d97757);\n  border: none;\n  border-radius: var(--ac-radius-button, 10px);\n  color: var(--ac-accent-contrast, #ffffff);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n  flex-shrink: 0;\n}\n\n.em-add-btn:hover {\n  background: var(--ac-accent-hover, #c4664a);\n  transform: translateY(-1px);\n  box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.15));\n}\n\n/* Modal */\n.em-modal-overlay {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.5);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 1000;\n  animation: fadeIn 150ms ease-out;\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n.em-modal {\n  width: calc(100% - 32px);\n  max-width: 480px;\n  max-height: calc(100vh - 64px);\n  background: var(--ac-surface, #ffffff);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-float, 0 8px 32px rgba(0, 0, 0, 0.2));\n  overflow: hidden;\n  animation: slideUp 200ms ease-out;\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(20px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.em-modal-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--ac-border, #e5e5e5);\n}\n\n.em-modal-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--ac-text, #262626);\n  margin: 0;\n}\n\n.em-modal-close {\n  width: 32px;\n  height: 32px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #737373);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-modal-close:hover {\n  background: var(--ac-hover-bg, #f5f5f5);\n  color: var(--ac-text, #262626);\n}\n\n.em-modal .em-form {\n  padding: 20px;\n}\n\n.em-modal-actions {\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n  margin-top: 16px;\n}\n\n.em-modal-actions .em-btn {\n  flex: none;\n  min-width: 80px;\n}\n\n/* Statistics Bar (compact) */\n.em-stats-bar {\n  padding: 10px 16px;\n  background: var(--ac-surface-muted, #f5f5f5);\n  border-radius: var(--ac-radius-inner, 8px);\n}\n\n.em-stats-text {\n  font-size: 13px;\n  color: var(--ac-text-muted, #737373);\n}\n\n.em-stats-text strong {\n  color: var(--ac-text, #262626);\n  font-weight: 600;\n}\n\n.em-domain-header {\n  background: var(--ac-surface, #ffffff);\n  border: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n  border-radius: var(--ac-radius-card, 12px);\n  padding: 6px 12px;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n  user-select: none;\n}\n\n.em-domain-header:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n  box-shadow: var(--ac-shadow-float, 0 4px 12px rgba(0, 0, 0, 0.1));\n}\n\n.em-domain-info {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.em-domain-icon {\n  flex-shrink: 0;\n  color: var(--ac-text-muted, #525252);\n  transition: transform var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-domain-icon-expanded {\n  transform: rotate(0deg);\n}\n\n.em-domain-icon:not(.em-domain-icon-expanded) {\n  transform: rotate(-90deg);\n}\n\n.em-domain-name {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--ac-text, #262626);\n  margin: 0;\n  flex: 1;\n}\n\n.em-domain-count {\n  font-size: 13px;\n  color: var(--ac-text-muted, #737373);\n  background: var(--ac-surface-muted, rgba(255, 255, 255, 0.6));\n  padding: 4px 12px;\n  border-radius: var(--ac-radius-button, 12px);\n  font-weight: 500;\n}\n\n/* Domain Content */\n.em-domain-content {\n  animation: slideDown 200ms ease-out;\n}\n\n@keyframes slideDown {\n  from {\n    opacity: 0;\n    transform: translateY(-10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Content wrapper with left border for visual hierarchy */\n.em-content-wrapper {\n  margin-left: 8px;\n  margin-top: 8px;\n  padding-left: 12px;\n  border-left: 2px solid var(--ac-border, #e5e5e5);\n}\n\n/* URL Group */\n.em-url-group {\n  margin-bottom: 12px;\n}\n\n.em-url-group:last-child {\n  margin-bottom: 0;\n}\n\n.em-url-header {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 0;\n}\n\n.em-url-icon {\n  color: var(--ac-text-muted, #a3a3a3);\n  flex-shrink: 0;\n}\n\n.em-url-path {\n  font-size: 12px;\n  color: var(--ac-text-muted, #737373);\n  font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);\n  word-break: break-all;\n  line-height: 1.4;\n}\n\n/* Markers List */\n.em-markers-list {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n/* Marker Item - Two row layout */\n.em-marker-item {\n  padding: 8px 10px;\n  border-radius: var(--ac-radius-inner, 6px);\n  background: var(--ac-hover-bg, rgba(0, 0, 0, 0.03));\n  margin-bottom: 4px;\n}\n\n.em-marker-item:last-child {\n  margin-bottom: 0;\n}\n\n.em-marker-item:hover {\n  background: var(--ac-hover-bg, rgba(0, 0, 0, 0.05));\n}\n\n/* Top row: name + actions */\n.em-marker-row-top {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  margin-bottom: 4px;\n}\n\n.em-marker-name {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--ac-text, #262626);\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.em-marker-actions {\n  display: flex;\n  gap: 4px;\n  flex-shrink: 0;\n}\n\n.em-action-btn {\n  width: 26px;\n  height: 26px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  border-radius: var(--ac-radius-button, 6px);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 150ms) ease;\n}\n\n.em-action-btn svg {\n  fill: none;\n  stroke: currentColor;\n  stroke-width: 2;\n}\n\n.em-action-btn.em-action-verify {\n  background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.1));\n  color: var(--ac-accent, #d97757);\n}\n\n.em-action-btn.em-action-verify:hover {\n  background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.18));\n}\n\n.em-action-btn.em-action-edit {\n  background: var(--ac-surface-muted, #f5f5f5);\n  color: var(--ac-text-muted, #737373);\n}\n\n.em-action-btn.em-action-edit:hover {\n  background: var(--ac-hover-bg, #e5e5e5);\n  color: var(--ac-text, #262626);\n}\n\n.em-action-btn.em-action-delete {\n  background: var(--ac-danger-subtle, rgba(239, 68, 68, 0.08));\n  color: var(--ac-danger, #ef4444);\n}\n\n.em-action-btn.em-action-delete:hover {\n  background: var(--ac-danger-subtle, rgba(239, 68, 68, 0.15));\n}\n\n/* Bottom row: selector + tags */\n.em-marker-row-bottom {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.em-marker-selector {\n  font-size: 11px;\n  font-family: var(--ac-font-mono, 'Monaco', 'Menlo', 'Ubuntu Mono', monospace);\n  color: var(--ac-text-muted, #737373);\n  background: var(--ac-surface-muted, #f5f5f5);\n  padding: 2px 6px;\n  border-radius: 4px;\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  cursor: help;\n}\n\n.em-marker-tags {\n  display: flex;\n  gap: 4px;\n  flex-shrink: 0;\n}\n\n.em-tag {\n  font-size: 9px;\n  padding: 2px 5px;\n  background: transparent;\n  color: var(--ac-text-muted, #a3a3a3);\n  border: 1px solid var(--ac-border, #e5e5e5);\n  border-radius: 3px;\n  font-weight: 500;\n  text-transform: uppercase;\n  letter-spacing: 0.3px;\n}\n\n/* Empty state button */\n.em-empty-btn {\n  margin-top: 16px;\n  width: auto;\n  padding: 0 24px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/AgentChat.vue",
    "content": "<template>\n  <div class=\"agent-theme relative h-full\" :data-agent-theme=\"themeState.theme.value\">\n    <!-- Sessions List View -->\n    <template v-if=\"viewRoute.isSessionsView.value\">\n      <AgentSessionsView\n        :sessions=\"sessions.allSessions.value\"\n        :selected-session-id=\"sessions.selectedSessionId.value\"\n        :is-loading=\"sessions.isLoadingAllSessions.value\"\n        :is-creating=\"sessions.isCreatingSession.value\"\n        :error=\"sessions.sessionError.value\"\n        :running-session-ids=\"runningSessionIds\"\n        :projects-map=\"projectsMap\"\n        @session:select=\"handleSessionSelectAndNavigate\"\n        @session:new=\"handleNewSessionAndNavigate\"\n        @session:delete=\"handleDeleteSession\"\n        @session:rename=\"handleRenameSession\"\n        @session:open-project=\"handleSessionOpenProject\"\n      />\n    </template>\n\n    <!-- Chat Conversation View -->\n    <template v-else>\n      <AgentChatShell\n        :error-message=\"chat.errorMessage.value\"\n        :usage=\"chat.lastUsage.value\"\n        :footer-label=\"`${engineDisplayName} Preview`\"\n        @error:dismiss=\"chat.errorMessage.value = null\"\n      >\n        <!-- Header -->\n        <template #header>\n          <AgentTopBar\n            :project-label=\"projectLabel\"\n            :session-label=\"sessionLabel\"\n            :connection-state=\"connectionState\"\n            :show-back-button=\"true\"\n            :brand-label=\"engineDisplayName\"\n            @toggle:project-menu=\"toggleProjectMenu\"\n            @toggle:session-menu=\"toggleSessionMenu\"\n            @toggle:settings-menu=\"toggleSettingsMenu\"\n            @toggle:open-project-menu=\"toggleOpenProjectMenu\"\n            @back=\"handleBackToSessions\"\n          />\n        </template>\n\n        <!-- Content -->\n        <template #content>\n          <AgentConversation :threads=\"threadState.threads.value\" />\n        </template>\n\n        <!-- Composer -->\n        <template #composer>\n          <!-- Web Editor Changes Chips -->\n          <WebEditorChanges />\n\n          <AgentComposer\n            :model-value=\"chat.input.value\"\n            :attachments=\"attachments.attachments.value\"\n            :attachment-error=\"attachments.error.value\"\n            :is-drag-over=\"attachments.isDragOver.value\"\n            :is-streaming=\"chat.isStreaming.value\"\n            :request-state=\"chat.requestState.value\"\n            :sending=\"chat.sending.value\"\n            :cancelling=\"chat.cancelling.value\"\n            :can-cancel=\"!!chat.currentRequestId.value\"\n            :can-send=\"chat.canSend.value\"\n            placeholder=\"Ask Claude to write code...\"\n            :engine-name=\"currentEngineName\"\n            :selected-model=\"currentSessionModel\"\n            :available-models=\"currentAvailableModels\"\n            :reasoning-effort=\"currentReasoningEffort\"\n            :available-reasoning-efforts=\"currentAvailableReasoningEfforts\"\n            :enable-fake-caret=\"inputPreferences.fakeCaretEnabled.value\"\n            @update:model-value=\"chat.input.value = $event\"\n            @submit=\"handleSend\"\n            @cancel=\"chat.cancelCurrentRequest()\"\n            @attachment:add=\"handleAttachmentAdd\"\n            @attachment:remove=\"attachments.removeAttachment\"\n            @attachment:drop=\"attachments.handleDrop\"\n            @attachment:paste=\"attachments.handlePaste\"\n            @attachment:dragover=\"attachments.handleDragOver\"\n            @attachment:dragleave=\"attachments.handleDragLeave\"\n            @model:change=\"handleComposerModelChange\"\n            @reasoning-effort:change=\"handleComposerReasoningEffortChange\"\n            @session:settings=\"handleComposerOpenSettings\"\n            @session:reset=\"handleComposerReset\"\n          />\n        </template>\n      </AgentChatShell>\n    </template>\n\n    <!-- Click-outside handler for menus (z-40) -->\n    <div\n      v-if=\"projectMenuOpen || sessionMenuOpen || settingsMenuOpen || openProjectMenuOpen\"\n      class=\"fixed inset-0 z-40\"\n      @click=\"closeMenus\"\n    />\n\n    <!-- Dropdown menus (z-50, outside stacking context) -->\n    <AgentProjectMenu\n      :open=\"projectMenuOpen\"\n      :projects=\"projects.projects.value\"\n      :selected-project-id=\"projects.selectedProjectId.value\"\n      :selected-cli=\"selectedCli\"\n      :model=\"model\"\n      :reasoning-effort=\"reasoningEffort\"\n      :use-ccr=\"useCcr\"\n      :enable-chrome-mcp=\"enableChromeMcp\"\n      :engines=\"server.engines.value\"\n      :is-picking=\"isPickingDirectory\"\n      :is-saving=\"isSavingPreference\"\n      :error=\"projects.projectError.value\"\n      @project:select=\"handleProjectSelect\"\n      @project:new=\"handleNewProject\"\n      @cli:update=\"selectedCli = $event\"\n      @model:update=\"model = $event\"\n      @reasoning-effort:update=\"reasoningEffort = $event\"\n      @ccr:update=\"useCcr = $event\"\n      @chrome-mcp:update=\"enableChromeMcp = $event\"\n      @save=\"handleSaveSettings\"\n    />\n\n    <AgentSessionMenu\n      :open=\"sessionMenuOpen\"\n      :sessions=\"sessions.sessions.value\"\n      :selected-session-id=\"sessions.selectedSessionId.value\"\n      :is-loading=\"sessions.isLoadingSessions.value\"\n      :is-creating=\"sessions.isCreatingSession.value\"\n      :error=\"sessions.sessionError.value\"\n      @session:select=\"handleSessionSelect\"\n      @session:new=\"handleNewSession\"\n      @session:delete=\"handleDeleteSession\"\n      @session:rename=\"handleRenameSession\"\n    />\n\n    <AgentSettingsMenu\n      :open=\"settingsMenuOpen\"\n      :theme=\"themeState.theme.value\"\n      :fake-caret-enabled=\"inputPreferences.fakeCaretEnabled.value\"\n      @theme:set=\"handleThemeChange\"\n      @reconnect=\"handleReconnect\"\n      @attachments:open=\"handleOpenAttachmentCache\"\n      @fake-caret:toggle=\"handleFakeCaretToggle\"\n    />\n\n    <AgentOpenProjectMenu\n      :open=\"openProjectMenuOpen\"\n      :default-target=\"openProjectPreference.defaultTarget.value\"\n      @select=\"handleOpenProjectSelect\"\n      @close=\"closeOpenProjectMenu\"\n    />\n\n    <!-- Session Settings Panel -->\n    <AgentSessionSettingsPanel\n      :open=\"sessionSettingsOpen\"\n      :session=\"sessions.selectedSession.value\"\n      :management-info=\"currentManagementInfo\"\n      :is-loading=\"sessionSettingsLoading\"\n      :is-saving=\"sessionSettingsSaving\"\n      @close=\"handleCloseSessionSettings\"\n      @save=\"handleSaveSessionSettings\"\n    />\n\n    <!-- Attachment Cache Panel -->\n    <AttachmentCachePanel :open=\"attachmentCacheOpen\" @close=\"handleCloseAttachmentCache\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, onMounted, onUnmounted, watch, provide } from 'vue';\nimport type { AgentStoredMessage, AgentMessage, CodexReasoningEffort } from 'chrome-mcp-shared';\n\n// Composables\nimport {\n  useAgentServer,\n  useAgentChat,\n  useAgentProjects,\n  useAgentSessions,\n  useAttachments,\n  useAgentTheme,\n  useAgentThreads,\n  useWebEditorTxState,\n  useAgentChatViewRoute,\n  useOpenProjectPreference,\n  useAgentInputPreferences,\n  WEB_EDITOR_TX_STATE_INJECTION_KEY,\n  AGENT_SERVER_PORT_KEY,\n  type AgentThemeId,\n} from '../composables';\nimport type { OpenProjectTarget } from 'chrome-mcp-shared';\n\n// New UI Components\nimport {\n  AgentChatShell,\n  AgentTopBar,\n  AgentComposer,\n  WebEditorChanges,\n  AgentConversation,\n  AgentProjectMenu,\n  AgentSessionMenu,\n  AgentSettingsMenu,\n  AgentSessionSettingsPanel,\n  AgentSessionsView,\n  AgentOpenProjectMenu,\n} from './agent-chat';\nimport type { SessionSettings } from './agent-chat/AgentSessionSettingsPanel.vue';\nimport AttachmentCachePanel from './agent-chat/AttachmentCachePanel.vue';\n\n// Model utilities\nimport {\n  getModelsForCli,\n  getCodexReasoningEfforts,\n  getDefaultModelForCli,\n} from '@/common/agent-models';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\n\n// Local UI state\nconst selectedCli = ref('');\nconst model = ref('');\nconst reasoningEffort = ref<CodexReasoningEffort>('medium');\nconst useCcr = ref(false);\nconst enableChromeMcp = ref(true);\nconst isSavingPreference = ref(false);\n\n/**\n * Get normalized model value that is valid for the current CLI.\n * Returns empty string if:\n * - No CLI selected (use server default)\n * - Model is invalid for selected CLI\n */\nfunction getNormalizedModel(): string {\n  const trimmedModel = model.value.trim();\n  if (!trimmedModel) return '';\n  // No CLI selected = don't override model, let server use default\n  if (!selectedCli.value) return '';\n  const models = getModelsForCli(selectedCli.value);\n  if (models.length === 0) return ''; // Unknown CLI\n  const isValid = models.some((m) => m.id === trimmedModel);\n  return isValid ? trimmedModel : '';\n}\n\n/**\n * Get normalized reasoning effort that is valid for the current model.\n * Used when creating/updating codex sessions.\n */\nfunction getNormalizedReasoningEffort(): CodexReasoningEffort {\n  if (selectedCli.value !== 'codex') return 'medium';\n  const effectiveModel = getNormalizedModel() || getDefaultModelForCli('codex');\n  const supported = getCodexReasoningEfforts(effectiveModel);\n  return supported.includes(reasoningEffort.value)\n    ? reasoningEffort.value\n    : (supported[supported.length - 1] as CodexReasoningEffort);\n}\n\nconst isPickingDirectory = ref(false);\nconst projectMenuOpen = ref(false);\nconst sessionMenuOpen = ref(false);\nconst settingsMenuOpen = ref(false);\nconst openProjectMenuOpen = ref(false);\n\n// Open project context: which session/project to open when menu selects\nconst openProjectContext = ref<{ type: 'session' | 'project'; id: string } | null>(null);\n\n// Session settings panel state\nconst sessionSettingsOpen = ref(false);\nconst sessionSettingsLoading = ref(false);\nconst sessionSettingsSaving = ref(false);\nconst currentManagementInfo = ref<import('chrome-mcp-shared').AgentManagementInfo | null>(null);\n\n// Attachment cache panel state\nconst attachmentCacheOpen = ref(false);\n\n// Initialize composables - sessions must be declared first for sessionId access\nconst sessions = useAgentSessions({\n  getServerPort: () => server.serverPort.value,\n  ensureServer: () => server.ensureNativeServer(),\n  onSessionChanged: (sessionId: string) => {\n    // Guard against stale callbacks from concurrent session switches\n    // This prevents race conditions where an older switch completes after a newer one\n    if (sessionId !== sessions.selectedSessionId.value) {\n      return;\n    }\n\n    // Always clear request state when session changes, regardless of view\n    // This prevents stale cancel targets and running badges from carrying over\n    chat.currentRequestId.value = null;\n    chat.isStreaming.value = false;\n    chat.requestState.value = 'idle';\n\n    // Always sync URL when session changes (for all paths: delete, project switch, etc.)\n    // This ensures URL stays consistent for refresh/deep-link scenarios\n    viewRoute.setSessionId(sessionId);\n\n    // Only reconnect SSE and reload history if we're in chat view\n    // This prevents duplicate connections when switching sessions from the list\n    // The list->chat navigation handlers will open SSE themselves\n    if (viewRoute.isChatView.value && projects.selectedProjectId.value) {\n      server.openEventSource();\n      void loadSessionHistory(sessionId);\n    }\n  },\n});\n\nconst server = useAgentServer({\n  getSessionId: () => sessions.selectedSessionId.value,\n  onMessage: (event) => chat.handleRealtimeEvent(event),\n  onError: (error) => {\n    chat.errorMessage.value = error;\n  },\n});\n\nconst chat = useAgentChat({\n  getServerPort: () => server.serverPort.value,\n  getSessionId: () => sessions.selectedSessionId.value,\n  ensureServer: () => server.ensureNativeServer(),\n  openEventSource: () => server.openEventSource(),\n});\n\nconst projects = useAgentProjects({\n  getServerPort: () => server.serverPort.value,\n  ensureServer: () => server.ensureNativeServer(),\n  onHistoryLoaded: (messages: AgentStoredMessage[]) => {\n    const converted = convertStoredMessages(messages);\n    chat.setMessages(converted);\n  },\n});\n\nconst attachments = useAttachments();\nconst themeState = useAgentTheme();\nconst openProjectPreference = useOpenProjectPreference({\n  getServerPort: () => server.serverPort.value,\n});\nconst inputPreferences = useAgentInputPreferences();\n\n// Initialize Web Editor TX state at root level and provide to children\n// This prevents duplicate listener registration in child components\nconst webEditorTxState = useWebEditorTxState();\nprovide(WEB_EDITOR_TX_STATE_INJECTION_KEY, webEditorTxState);\n\n// Provide server port for child components to build attachment URLs\nprovide(AGENT_SERVER_PORT_KEY, server.serverPort);\n\n// View routing (sessions list vs chat conversation)\nconst viewRoute = useAgentChatViewRoute();\n\n// Track running sessions for badge display\nconst runningSessionIds = computed(() => {\n  // For now, only track current session's running state\n  // Could be extended to track multiple sessions via background broadcast\n  const currentId = sessions.selectedSessionId.value;\n  // Use isRequestActive instead of isStreaming to correctly show running badge\n  // even during tool execution when isStreaming might be false\n  if (currentId && chat.isRequestActive.value) {\n    return new Set([currentId]);\n  }\n  return new Set<string>();\n});\n\n// Map of projectId -> AgentProject for looking up project info in sessions list\nconst projectsMap = computed(() => {\n  return new Map(projects.projects.value.map((p) => [p.id, p] as const));\n});\n\n// Thread state for grouping messages\nconst threadState = useAgentThreads({\n  messages: chat.messages,\n  requestState: chat.requestState,\n  currentRequestId: chat.currentRequestId,\n});\n\n// Computed values\nconst projectLabel = computed(() => {\n  const project = projects.selectedProject.value;\n  return project?.name ?? 'No project';\n});\n\nconst sessionLabel = computed(() => {\n  const session = sessions.selectedSession.value;\n  // Priority: preview (first user message) > name > 'New Session'\n  return session?.preview || session?.name || 'New Session';\n});\n\nconst connectionState = computed(() => {\n  if (server.isServerReady.value) return 'ready';\n  if (server.nativeConnected.value) return 'connecting';\n  return 'disconnected';\n});\n\n// Computed values for AgentComposer\nconst currentEngineName = computed(() => sessions.selectedSession.value?.engineName ?? '');\n\n// Engine display name for brand/footer\nconst engineDisplayName = computed(() => {\n  const name = currentEngineName.value;\n  switch (name) {\n    case 'claude':\n      return 'Claude Code';\n    case 'codex':\n      return 'Codex';\n    case 'cursor':\n      return 'Cursor';\n    case 'qwen':\n      return 'Qwen';\n    case 'glm':\n      return 'GLM';\n    default:\n      return 'Agent';\n  }\n});\n\nconst currentSessionModel = computed(() => {\n  const session = sessions.selectedSession.value;\n  if (!session) return '';\n  // Use session model if set, otherwise use default for the engine\n  return session.model || getDefaultModelForCli(session.engineName);\n});\n\nconst currentAvailableModels = computed(() => {\n  const session = sessions.selectedSession.value;\n  if (!session) return [];\n  return getModelsForCli(session.engineName);\n});\n\nconst currentReasoningEffort = computed(() => {\n  const session = sessions.selectedSession.value;\n  if (!session || session.engineName !== 'codex') return 'medium' as CodexReasoningEffort;\n  return session.optionsConfig?.codexConfig?.reasoningEffort ?? 'medium';\n});\n\nconst currentAvailableReasoningEfforts = computed(() => {\n  const session = sessions.selectedSession.value;\n  if (!session || session.engineName !== 'codex') return [] as readonly CodexReasoningEffort[];\n  const effectiveModel = currentSessionModel.value || getDefaultModelForCli('codex');\n  return getCodexReasoningEfforts(effectiveModel);\n});\n\n// Track pending history load with nonce to prevent A→B→A race conditions\nlet historyLoadNonce = 0;\n\n/**\n * Load chat history for a specific session with race-condition protection.\n * Uses a nonce to handle A→B→A scenarios where older requests for the same\n * session could return after newer ones.\n */\nasync function loadSessionHistory(sessionId: string): Promise<void> {\n  const serverPort = server.serverPort.value;\n  if (!serverPort || !sessionId) return;\n\n  // Increment nonce for this load - any subsequent load will invalidate this one\n  const myNonce = ++historyLoadNonce;\n\n  /**\n   * Check if this load is still valid.\n   * Validates both the nonce (handles A→B→A) and current selection (handles switches).\n   */\n  const isStillValid = (): boolean => {\n    return myNonce === historyLoadNonce && sessions.selectedSessionId.value === sessionId;\n  };\n\n  try {\n    const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/history`;\n    const response = await fetch(url);\n\n    if (!isStillValid()) return;\n\n    if (response.ok) {\n      const data = await response.json();\n\n      // Re-check after json parsing (parsing can be slow for large histories)\n      if (!isStillValid()) return;\n\n      const messages = data.messages || [];\n      const converted = convertStoredMessages(messages);\n      chat.setMessages(converted);\n    } else {\n      if (!isStillValid()) return;\n      chat.setMessages([]);\n    }\n  } catch (error) {\n    if (isStillValid()) {\n      console.error('Failed to load session history:', error);\n      chat.setMessages([]);\n    }\n  }\n}\n\n// Convert stored messages to AgentMessage format\nfunction convertStoredMessages(stored: AgentStoredMessage[]): AgentMessage[] {\n  return stored.map((m) => ({\n    id: m.id,\n    sessionId: m.sessionId,\n    role: m.role,\n    content: m.content,\n    messageType: m.messageType,\n    cliSource: m.cliSource ?? undefined,\n    requestId: m.requestId,\n    createdAt: m.createdAt ?? new Date().toISOString(),\n    metadata: m.metadata,\n  }));\n}\n\n/**\n * Clear streaming/request state when switching sessions.\n * Prevents stale cancel targets and running badges from carrying over.\n */\nfunction clearRequestState(): void {\n  chat.currentRequestId.value = null;\n  chat.isStreaming.value = false;\n  chat.requestState.value = 'idle';\n}\n\n// Menu handlers\nfunction toggleProjectMenu(): void {\n  projectMenuOpen.value = !projectMenuOpen.value;\n  if (projectMenuOpen.value) {\n    sessionMenuOpen.value = false;\n    settingsMenuOpen.value = false;\n    openProjectMenuOpen.value = false;\n  }\n}\n\nfunction toggleSessionMenu(): void {\n  sessionMenuOpen.value = !sessionMenuOpen.value;\n  if (sessionMenuOpen.value) {\n    projectMenuOpen.value = false;\n    settingsMenuOpen.value = false;\n    openProjectMenuOpen.value = false;\n  }\n}\n\nfunction toggleSettingsMenu(): void {\n  settingsMenuOpen.value = !settingsMenuOpen.value;\n  if (settingsMenuOpen.value) {\n    projectMenuOpen.value = false;\n    sessionMenuOpen.value = false;\n    openProjectMenuOpen.value = false;\n  }\n}\n\nfunction toggleOpenProjectMenu(): void {\n  openProjectMenuOpen.value = !openProjectMenuOpen.value;\n  if (openProjectMenuOpen.value) {\n    projectMenuOpen.value = false;\n    sessionMenuOpen.value = false;\n    settingsMenuOpen.value = false;\n    // Set context to current session from chat view\n    const sessionId = sessions.selectedSessionId.value;\n    if (sessionId) {\n      openProjectContext.value = { type: 'session', id: sessionId };\n    }\n  } else {\n    openProjectContext.value = null;\n  }\n}\n\nfunction closeOpenProjectMenu(): void {\n  openProjectMenuOpen.value = false;\n  openProjectContext.value = null;\n}\n\n/**\n * Handle session list item's open-project button click.\n * If user has a default preference, open directly; otherwise show menu.\n */\nasync function handleSessionOpenProject(sessionId: string): Promise<void> {\n  const defaultTarget = openProjectPreference.defaultTarget.value;\n  if (defaultTarget) {\n    // User has default preference, open directly\n    const result = await openProjectPreference.openBySession(sessionId, defaultTarget);\n    if (!result.success) {\n      alert(`Failed to open project: ${result.error}`);\n    }\n  } else {\n    // No default, show menu\n    openProjectContext.value = { type: 'session', id: sessionId };\n    openProjectMenuOpen.value = true;\n    projectMenuOpen.value = false;\n    sessionMenuOpen.value = false;\n    settingsMenuOpen.value = false;\n  }\n}\n\n/**\n * Handle open project menu selection.\n * Saves preference and opens the project.\n */\nasync function handleOpenProjectSelect(target: OpenProjectTarget): Promise<void> {\n  // Snapshot context before any await to prevent race condition\n  // (close event may clear context while we're awaiting)\n  const ctx = openProjectContext.value;\n\n  // Close menu immediately for better UX\n  closeOpenProjectMenu();\n\n  if (!ctx) return;\n\n  // Save as default preference (non-blocking for UX)\n  void openProjectPreference.saveDefaultTarget(target);\n\n  // Execute open action based on context\n  let result;\n  if (ctx.type === 'session') {\n    result = await openProjectPreference.openBySession(ctx.id, target);\n  } else {\n    result = await openProjectPreference.openByProject(ctx.id, target);\n  }\n\n  if (!result.success) {\n    alert(`Failed to open project: ${result.error}`);\n  }\n}\n\nfunction closeMenus(): void {\n  projectMenuOpen.value = false;\n  sessionMenuOpen.value = false;\n  settingsMenuOpen.value = false;\n  openProjectMenuOpen.value = false;\n  openProjectContext.value = null;\n}\n\n// Theme handler\nasync function handleThemeChange(theme: AgentThemeId): Promise<void> {\n  await themeState.setTheme(theme);\n  closeMenus();\n}\n\n// Fake caret toggle handler\nasync function handleFakeCaretToggle(enabled: boolean): Promise<void> {\n  await inputPreferences.setFakeCaretEnabled(enabled);\n}\n\n// Server reconnect\nasync function handleReconnect(): Promise<void> {\n  closeMenus();\n  await server.reconnect();\n}\n\n// Attachment cache handlers\nfunction handleOpenAttachmentCache(): void {\n  attachmentCacheOpen.value = true;\n  sessionSettingsOpen.value = false;\n  closeMenus();\n}\n\nfunction handleCloseAttachmentCache(): void {\n  attachmentCacheOpen.value = false;\n}\n\n// Session handlers\nasync function handleSessionSelect(sessionId: string): Promise<void> {\n  await sessions.selectSession(sessionId);\n  // Note: URL sync is handled by onSessionChanged callback\n  closeMenus();\n}\n\nasync function handleNewSession(): Promise<void> {\n  const projectId = projects.selectedProjectId.value;\n  if (!projectId) return;\n\n  // Clear previous request state (in chat view, creating new session should reset state)\n  clearRequestState();\n\n  const engineName =\n    (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude';\n\n  // Include codex config if using codex engine\n  const optionsConfig =\n    engineName === 'codex'\n      ? {\n          codexConfig: {\n            reasoningEffort: getNormalizedReasoningEffort(),\n          },\n        }\n      : undefined;\n\n  const session = await sessions.createSession(projectId, {\n    engineName,\n    name: `Session ${sessions.sessions.value.length + 1}`,\n    optionsConfig,\n  });\n\n  // Guard: only clear messages if the new session is still selected\n  // This prevents clearing messages if user switched during createSession await\n  if (session && sessions.selectedSessionId.value === session.id) {\n    chat.setMessages([]);\n    // Note: URL sync is handled by onSessionChanged callback (triggered by createSession)\n  }\n  closeMenus();\n}\n\nasync function handleDeleteSession(sessionId: string): Promise<void> {\n  const wasCurrentSession = sessions.selectedSessionId.value === sessionId;\n  const wasInChatView = viewRoute.isChatView.value;\n\n  await sessions.deleteSession(sessionId);\n\n  // Handle post-delete navigation and URL sync\n  if (wasCurrentSession) {\n    if (sessions.sessions.value.length === 0) {\n      // No sessions left - go back to sessions list (will show empty state)\n      // Also clear URL sessionId since there's no valid session\n      viewRoute.setSessionId(null);\n      if (wasInChatView) {\n        viewRoute.goToSessions();\n      }\n    }\n    // Note: If there are remaining sessions, useAgentSessions.deleteSession\n    // already calls onSessionChanged which syncs URL via setSessionId\n  }\n}\n\nasync function handleRenameSession(sessionId: string, name: string): Promise<void> {\n  await sessions.renameSession(sessionId, name);\n}\n\nasync function handleOpenSessionSettings(sessionId: string): Promise<void> {\n  closeMenus();\n  sessionSettingsOpen.value = true;\n  sessionSettingsLoading.value = true;\n  currentManagementInfo.value = null;\n\n  try {\n    // Fetch Claude SDK management info if this is a Claude session\n    const session = sessions.sessions.value.find((s) => s.id === sessionId);\n    if (session?.engineName === 'claude') {\n      const info = await sessions.fetchClaudeInfo(sessionId);\n      if (info) {\n        currentManagementInfo.value = info.managementInfo;\n      }\n    }\n  } finally {\n    sessionSettingsLoading.value = false;\n  }\n}\n\nasync function handleResetSession(sessionId: string): Promise<void> {\n  closeMenus();\n  const result = await sessions.resetConversation(sessionId);\n  // Guard: only clear messages if the reset session is still selected\n  // This prevents clearing messages if user switched during reset await\n  if (result && sessions.selectedSessionId.value === sessionId) {\n    chat.setMessages([]);\n  }\n}\n\n// Composer direct model/reasoning effort change handlers\nasync function handleComposerModelChange(modelId: string): Promise<void> {\n  const sessionId = sessions.selectedSessionId.value;\n  if (!sessionId) return;\n\n  await sessions.updateSession(sessionId, { model: modelId || null });\n}\n\nasync function handleComposerReasoningEffortChange(effort: CodexReasoningEffort): Promise<void> {\n  const sessionId = sessions.selectedSessionId.value;\n  const session = sessions.selectedSession.value;\n  if (!sessionId || !session) return;\n\n  const existingOptions = session.optionsConfig ?? {};\n  const existingCodexConfig = existingOptions.codexConfig ?? {};\n  await sessions.updateSession(sessionId, {\n    optionsConfig: {\n      ...existingOptions,\n      codexConfig: {\n        ...existingCodexConfig,\n        reasoningEffort: effort,\n      },\n    },\n  });\n}\n\n// Composer session settings/reset handlers (without sessionId parameter)\nfunction handleComposerOpenSettings(): void {\n  const sessionId = sessions.selectedSessionId.value;\n  if (sessionId) {\n    handleOpenSessionSettings(sessionId);\n  }\n}\n\nasync function handleComposerReset(): Promise<void> {\n  const sessionId = sessions.selectedSessionId.value;\n  if (sessionId) {\n    await handleResetSession(sessionId);\n  }\n}\n\nfunction handleCloseSessionSettings(): void {\n  sessionSettingsOpen.value = false;\n  currentManagementInfo.value = null;\n}\n\nasync function handleSaveSessionSettings(settings: SessionSettings): Promise<void> {\n  const sessionId = sessions.selectedSessionId.value;\n  if (!sessionId) return;\n\n  sessionSettingsSaving.value = true;\n  try {\n    await sessions.updateSession(sessionId, {\n      model: settings.model || null,\n      permissionMode: settings.permissionMode || null,\n      systemPromptConfig: settings.systemPromptConfig,\n      optionsConfig: settings.optionsConfig,\n    });\n    sessionSettingsOpen.value = false;\n    currentManagementInfo.value = null;\n  } finally {\n    sessionSettingsSaving.value = false;\n  }\n}\n\n// Project handlers\nasync function handleProjectSelect(projectId: string): Promise<void> {\n  // Clear request state and sessions before switching project\n  // This prevents stale session data from mixing with the new project\n  clearRequestState();\n  sessions.clearSessions();\n\n  projects.selectedProjectId.value = projectId;\n  await projects.handleProjectChanged();\n\n  // Guard: abort if user switched to a different project during await\n  if (projects.selectedProjectId.value !== projectId) {\n    closeMenus();\n    return;\n  }\n\n  const project = projects.selectedProject.value;\n  if (project) {\n    selectedCli.value = project.preferredCli ?? '';\n    model.value = project.selectedModel ?? '';\n    useCcr.value = project.useCcr ?? false;\n    enableChromeMcp.value = project.enableChromeMcp !== false;\n  }\n  // Load sessions for the new project\n  await sessions.ensureDefaultSession(\n    projectId,\n    (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude',\n  );\n\n  // Guard again after ensureDefaultSession\n  if (projects.selectedProjectId.value !== projectId) {\n    closeMenus();\n    return;\n  }\n\n  // Ensure URL is synced after project switch (fallback for edge cases)\n  // This handles rare cases where ensureDefaultSession doesn't trigger onSessionChanged\n  viewRoute.setSessionId(sessions.selectedSessionId.value || null);\n\n  closeMenus();\n}\n\nasync function handleNewProject(): Promise<void> {\n  isPickingDirectory.value = true;\n  try {\n    const path = await projects.pickDirectory();\n    if (path) {\n      // Extract directory name from path, handling trailing slashes\n      const segments = path.split(/[/\\\\]/).filter((s) => s.length > 0);\n      const dirName = segments.pop() || 'New Project';\n      const project = await projects.createProjectFromPath(path, dirName);\n      if (project) {\n        selectedCli.value = project.preferredCli ?? '';\n        model.value = project.selectedModel ?? '';\n        useCcr.value = project.useCcr ?? false;\n        enableChromeMcp.value = project.enableChromeMcp !== false;\n\n        // Ensure a default session exists for the new project\n        const engineName =\n          (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude';\n        await sessions.ensureDefaultSession(project.id, engineName);\n\n        // Reconnect SSE and load session history\n        if (sessions.selectedSessionId.value) {\n          server.openEventSource();\n          await loadSessionHistory(sessions.selectedSessionId.value);\n        }\n      }\n    }\n  } finally {\n    isPickingDirectory.value = false;\n    closeMenus();\n  }\n}\n\nasync function handleSaveSettings(): Promise<void> {\n  const project = projects.selectedProject.value;\n  if (!project) return;\n\n  // Capture previous CLI to detect changes\n  const previousCli = project.preferredCli ?? '';\n\n  isSavingPreference.value = true;\n  try {\n    // Use normalized model to ensure valid value is saved\n    const normalizedModel = getNormalizedModel();\n    // Only save CCR if Claude CLI is selected\n    const normalizedCcr = selectedCli.value === 'claude' ? useCcr.value : false;\n    await projects.saveProjectPreference(\n      selectedCli.value,\n      normalizedModel,\n      normalizedCcr,\n      enableChromeMcp.value,\n    );\n    // Sync local state with normalized values\n    model.value = normalizedModel;\n    useCcr.value = normalizedCcr;\n\n    // If CLI changed, create a new empty session with the new CLI\n    const cliChanged = previousCli !== selectedCli.value;\n    if (cliChanged && selectedCli.value) {\n      const engineName = selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';\n\n      // Include codex config if using codex engine\n      const optionsConfig =\n        engineName === 'codex'\n          ? {\n              codexConfig: {\n                reasoningEffort: getNormalizedReasoningEffort(),\n              },\n            }\n          : undefined;\n\n      const session = await sessions.createSession(project.id, {\n        engineName,\n        name: `Session ${sessions.sessions.value.length + 1}`,\n        optionsConfig,\n      });\n\n      // Guard: only clear messages if the new session is still selected\n      // This prevents clearing messages if user switched during createSession await\n      if (session && sessions.selectedSessionId.value === session.id) {\n        chat.setMessages([]);\n      }\n    }\n  } finally {\n    isSavingPreference.value = false;\n    closeMenus();\n  }\n}\n\n// =============================================================================\n// View Navigation\n// =============================================================================\n\n/**\n * Handle session selection from sessions list and navigate to chat view.\n * Supports cross-project selection: if the selected session belongs to a different\n * project, the project context will be switched automatically.\n */\nasync function handleSessionSelectAndNavigate(sessionId: string): Promise<void> {\n  // Only clear request state when switching to a DIFFERENT session\n  // If re-entering the same session, preserve the running state\n  // (e.g., user exits to list and comes back while request is still running)\n  const isSameSession = sessions.selectedSessionId.value === sessionId;\n  if (!isSameSession) {\n    clearRequestState();\n  }\n\n  // Find the session's projectId from allSessions, fallback to API if not found\n  const targetProjectId =\n    sessions.allSessions.value.find((s) => s.id === sessionId)?.projectId ??\n    (await sessions.getSession(sessionId))?.projectId;\n\n  if (!targetProjectId) {\n    console.warn('[AgentChat] Unable to resolve projectId for session:', sessionId);\n    return;\n  }\n\n  // If the session belongs to a different project, switch project context first\n  if (projects.selectedProjectId.value !== targetProjectId) {\n    // Clear sessions before switching to prevent stale data mixing\n    sessions.clearSessions();\n    projects.selectedProjectId.value = targetProjectId;\n    await projects.handleProjectChanged();\n\n    // Guard: abort if user switched to a different project during await\n    if (projects.selectedProjectId.value !== targetProjectId) {\n      return;\n    }\n\n    // Sync local UI state with the new project's preferences\n    const project = projects.selectedProject.value;\n    if (project) {\n      selectedCli.value = project.preferredCli ?? '';\n      model.value = project.selectedModel ?? '';\n      useCcr.value = project.useCcr ?? false;\n      enableChromeMcp.value = project.enableChromeMcp !== false;\n    }\n\n    // Fetch sessions for the new project\n    await sessions.fetchSessions(targetProjectId);\n\n    // Guard again after fetchSessions\n    if (projects.selectedProjectId.value !== targetProjectId) {\n      return;\n    }\n  }\n\n  await sessions.selectSession(sessionId);\n\n  // Guard against stale navigation if user switched to a different session during await\n  if (sessions.selectedSessionId.value !== sessionId) {\n    return;\n  }\n\n  viewRoute.goToChat(sessionId);\n\n  // Open SSE and load history when entering chat view\n  server.openEventSource();\n  await loadSessionHistory(sessionId);\n}\n\n/**\n * Create a new session and navigate to chat view.\n */\nasync function handleNewSessionAndNavigate(): Promise<void> {\n  if (!projects.selectedProjectId.value) return;\n\n  // Clear previous state before creating new session\n  clearRequestState();\n\n  const engineName =\n    (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude';\n  const optionsConfig =\n    engineName === 'codex'\n      ? {\n          codexConfig: {\n            reasoningEffort: getNormalizedReasoningEffort(),\n          },\n        }\n      : undefined;\n\n  const session = await sessions.createSession(projects.selectedProjectId.value, {\n    engineName,\n    name: `Session ${sessions.sessions.value.length + 1}`,\n    optionsConfig,\n  });\n\n  // Guard against stale navigation if user switched during createSession await\n  if (session && sessions.selectedSessionId.value === session.id) {\n    chat.setMessages([]);\n    viewRoute.goToChat(session.id);\n\n    // Open SSE for new session\n    server.openEventSource();\n  }\n}\n\n/**\n * Navigate back to sessions list.\n */\nfunction handleBackToSessions(): void {\n  viewRoute.goToSessions();\n}\n\n// =============================================================================\n// Web Editor Selection Context\n// =============================================================================\n\n/**\n * Build instruction with web editor selection context prepended.\n * This provides AI with element context when user asks to modify selected element.\n *\n * Format:\n * ```\n * [WebEditorSelectionContext]\n * pageUrl: <pageUrl>\n * tagName: <tagName>\n * label: <label>\n * selectors: [<up to 3>]\n * fingerprint: <fingerprint>\n *\n * [UserRequest]\n * <user original input>\n * ```\n *\n * @param userInput - The user's original input text\n * @returns Instruction with context prepended, or original input if no selection\n */\nfunction buildInstructionWithSelectionContext(userInput: string): string {\n  const selection = webEditorTxState.selectedElement.value;\n  const txState = webEditorTxState.txState.value;\n  const selectionPageUrl = webEditorTxState.selectionPageUrl.value;\n\n  // No selection = return original input\n  if (!selection) {\n    return userInput;\n  }\n\n  // Build context lines\n  const contextLines: string[] = ['[WebEditorSelectionContext]'];\n\n  // Page URL - prefer selection's pageUrl (more recent), fall back to txState\n  const pageUrl = selectionPageUrl || txState?.pageUrl;\n  if (pageUrl) {\n    contextLines.push(`pageUrl: ${pageUrl}`);\n  }\n\n  // Element key for stable identification\n  if (selection.elementKey) {\n    contextLines.push(`elementKey: ${selection.elementKey}`);\n  }\n\n  // Element info\n  contextLines.push(`tagName: ${selection.tagName || 'unknown'}`);\n  contextLines.push(`label: ${selection.label || selection.fullLabel || 'unknown'}`);\n\n  // Selectors (up to 3)\n  const selectors = selection.locator?.selectors ?? [];\n  const topSelectors = selectors.slice(0, 3);\n  if (topSelectors.length > 0) {\n    contextLines.push(`selectors: [${topSelectors.map((s) => `\"${s}\"`).join(', ')}]`);\n  }\n\n  // Fingerprint for similarity matching\n  if (selection.locator?.fingerprint) {\n    contextLines.push(`fingerprint: ${selection.locator.fingerprint}`);\n  }\n\n  // Combine context with user request\n  return `${contextLines.join('\\n')}\\n\\n[UserRequest]\\n${userInput}`;\n}\n\n// Attachment handlers\nfunction handleAttachmentAdd(): void {\n  // Create and click a hidden file input\n  const input = document.createElement('input');\n  input.type = 'file';\n  input.accept = 'image/*';\n  input.multiple = true;\n  input.onchange = (e) => attachments.handleFileSelect(e);\n  input.click();\n}\n\n// Send handler\nasync function handleSend(): Promise<void> {\n  const dbSessionId = sessions.selectedSessionId.value;\n  if (!dbSessionId) {\n    chat.errorMessage.value = 'No session selected.';\n    return;\n  }\n\n  // Capture input before clearing for preview update\n  const messageText = chat.input.value.trim();\n  if (!messageText) return;\n\n  // Check if user has selected an element in web editor\n  const selection = webEditorTxState.selectedElement.value;\n  const txState = webEditorTxState.txState.value;\n  const selectionPageUrl = webEditorTxState.selectionPageUrl.value;\n\n  // Capture selection info before sending (for clear after success)\n  const selectionTabId = webEditorTxState.tabId.value;\n  const selectionElementKey = selection?.elementKey ?? null;\n\n  // When a web editor element is selected, store structured metadata on the user message\n  // so the thread header can render as a chip (same style as \"Web editor apply\")\n  const selectionClientMeta = selection\n    ? {\n        kind: 'web_editor_apply_single' as const,\n        pageUrl: selectionPageUrl || txState?.pageUrl || 'unknown',\n        elementCount: 1,\n        elementLabels: [\n          selection.label || selection.fullLabel || selection.tagName || 'selected element',\n        ],\n      }\n    : undefined;\n\n  // Build instruction with web editor selection context (if any)\n  // The UI will show the original messageText, but the actual instruction\n  // sent to the server will include element context for AI to understand\n  const instructionWithContext = buildInstructionWithSelectionContext(messageText);\n\n  // Use getAttachments() to strip previewUrl and avoid payload bloat\n  chat.attachments.value = attachments.getAttachments() ?? [];\n\n  // Session-level config is now used by backend; no need to pass cliPreference/model\n  // For selection context messages, use the user's input as displayText\n  // so the chip shows meaningful content instead of a generic label\n  await chat.send({\n    projectId: projects.selectedProjectId.value || undefined,\n    dbSessionId,\n    // Pass the context-enriched instruction to be sent to server\n    instruction: instructionWithContext,\n    // Attach metadata only when selection context exists\n    // Use user's original message as displayText for better UX\n    displayText: selection ? messageText : undefined,\n    clientMeta: selectionClientMeta,\n  });\n\n  // Clear web editor selection after successful send\n  // This \"consumes\" the selection context so it won't be re-injected in next message\n  if (selectionElementKey && selectionTabId) {\n    // Check if user has selected a DIFFERENT element during the loading period\n    // Compare both elementKey AND tabId to handle cross-tab scenarios\n    // (elementKey like \"div#app\" is not unique across tabs/pages)\n    const currentElementKey = webEditorTxState.selectedElement.value?.elementKey ?? null;\n    const currentTabId = webEditorTxState.tabId.value;\n\n    const isSameSelection =\n      currentElementKey === selectionElementKey && currentTabId === selectionTabId;\n\n    if (!isSameSelection && currentElementKey !== null) {\n      // User selected a new element (or switched tab) during send - preserve it, don't clear\n    } else {\n      // Same element or already deselected - proceed with clear\n      // Try to clear via message (web-editor may be open)\n      chrome.runtime\n        .sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CLEAR_SELECTION,\n          payload: { tabId: selectionTabId },\n        })\n        .then((response: { success: boolean } | undefined) => {\n          // If web-editor didn't respond (closed/not active), clear local state\n          // Use captured selectionTabId/selectionElementKey to avoid clearing new selection\n          if (!response?.success) {\n            clearLocalSelectionState(selectionTabId, selectionElementKey);\n          }\n          // If success, web-editor will broadcast null selection which will clear our state\n        })\n        .catch(() => {\n          // Message failed - clear sidepanel local state directly\n          clearLocalSelectionState(selectionTabId, selectionElementKey);\n        });\n    }\n  }\n\n  // Update session preview with first user message (if not already set)\n  // Note: Use original messageText, not the context-enriched version\n  // Include previewMeta for special chip rendering in session list\n  sessions.updateSessionPreview(\n    dbSessionId,\n    messageText,\n    selectionClientMeta\n      ? {\n          displayText: messageText,\n          clientMeta: selectionClientMeta,\n          fullContent: instructionWithContext,\n        }\n      : undefined,\n  );\n\n  attachments.clearAttachments();\n}\n\n/**\n * Clear sidepanel local selection state.\n * Used when web-editor is closed or unreachable.\n *\n * @param expectedTabId - The tab ID that was selected at send time\n * @param expectedElementKey - The element key that was selected at send time\n */\nfunction clearLocalSelectionState(expectedTabId: number, expectedElementKey: string): void {\n  // Double-check we're still on the same selection to avoid clearing new selection\n  const currentTabId = webEditorTxState.tabId.value;\n  const currentElementKey = webEditorTxState.selectedElement.value?.elementKey ?? null;\n\n  // Only clear if still pointing to the same selection (or already cleared)\n  const shouldClear =\n    currentElementKey === null ||\n    (currentTabId === expectedTabId && currentElementKey === expectedElementKey);\n\n  if (!shouldClear) {\n    // User switched to a different selection - don't clear\n    return;\n  }\n\n  // Clear the reactive state\n  webEditorTxState.selectedElement.value = null;\n  webEditorTxState.selectionPageUrl.value = null;\n\n  // Clear session storage to prevent \"revival\" on refresh/tab switch\n  if (expectedTabId) {\n    const storageKey = `web-editor-v2-selection-${expectedTabId}`;\n    chrome.storage.session.remove(storageKey).catch(() => {\n      // Ignore storage errors\n    });\n  }\n}\n\n// Initialize\nonMounted(async () => {\n  // Initialize theme\n  await themeState.initTheme();\n\n  // Load open project preference\n  await openProjectPreference.loadDefaultTarget();\n\n  // Load input preferences (fake caret, etc.)\n  await inputPreferences.init();\n\n  // Initialize server\n  await server.initialize();\n\n  if (server.isServerReady.value) {\n    // Ensure default project exists and load projects\n    await projects.ensureDefaultProject();\n    await projects.fetchProjects();\n\n    // Load all sessions across all projects for the global sessions list view\n    await sessions.fetchAllSessions();\n\n    // Load selected project or use first one\n    await projects.loadSelectedProjectId();\n    const hasValidSelection =\n      projects.selectedProjectId.value &&\n      projects.projects.value.some((p) => p.id === projects.selectedProjectId.value);\n\n    if (!hasValidSelection && projects.projects.value.length > 0) {\n      projects.selectedProjectId.value = projects.projects.value[0].id;\n      await projects.saveSelectedProjectId();\n    }\n\n    // Load settings and sessions\n    if (projects.selectedProjectId.value) {\n      const project = projects.selectedProject.value;\n      if (project) {\n        selectedCli.value = project.preferredCli ?? '';\n        model.value = project.selectedModel ?? '';\n        useCcr.value = project.useCcr ?? false;\n        enableChromeMcp.value = project.enableChromeMcp !== false;\n      }\n\n      // Load sessions for the project\n      await sessions.loadSelectedSessionId();\n      await sessions.fetchSessions(projects.selectedProjectId.value);\n\n      // Parse URL parameters to determine initial view\n      // Note: This is called after fetchSessions so we can verify the session exists\n      const initialRoute = viewRoute.initFromUrl();\n\n      // Handle deep link: URL specifies session to open directly (e.g., from Apply)\n      // Support cross-project sessions by checking allSessions first\n      if (initialRoute.view === 'chat' && initialRoute.sessionId) {\n        const targetSession =\n          sessions.allSessions.value.find((s) => s.id === initialRoute.sessionId) ??\n          sessions.sessions.value.find((s) => s.id === initialRoute.sessionId);\n\n        if (targetSession) {\n          // Use handleSessionSelectAndNavigate to handle cross-project switching\n          await handleSessionSelectAndNavigate(targetSession.id);\n        } else {\n          // Session doesn't exist in any project, fall back to sessions list\n          viewRoute.goToSessions();\n        }\n      }\n\n      // Ensure a default session exists (for new users)\n      // Note: This won't fetch sessions again since we already did above\n      await sessions.ensureDefaultSession(\n        projects.selectedProjectId.value,\n        (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude',\n      );\n\n      // Only open SSE and load history if we're in chat view with a valid session\n      if (viewRoute.isChatView.value && sessions.selectedSessionId.value) {\n        server.openEventSource();\n        await loadSessionHistory(sessions.selectedSessionId.value);\n      }\n    }\n  }\n});\n\n// Watch for server ready\nwatch(\n  () => server.isServerReady.value,\n  async (ready) => {\n    if (ready && projects.projects.value.length === 0) {\n      await projects.ensureDefaultProject();\n      await projects.fetchProjects();\n\n      // Also fetch all sessions for the global sessions list\n      await sessions.fetchAllSessions();\n\n      const hasValidSelection =\n        projects.selectedProjectId.value &&\n        projects.projects.value.some((p) => p.id === projects.selectedProjectId.value);\n\n      if (!hasValidSelection && projects.projects.value.length > 0) {\n        projects.selectedProjectId.value = projects.projects.value[0].id;\n        await projects.saveSelectedProjectId();\n      }\n    }\n  },\n);\n\n// Close menus on Escape key\nconst handleEscape = (e: KeyboardEvent) => {\n  if (e.key === 'Escape') {\n    closeMenus();\n  }\n};\n\nonMounted(() => {\n  document.addEventListener('keydown', handleEscape);\n});\n\nonUnmounted(() => {\n  document.removeEventListener('keydown', handleEscape);\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/SidepanelNavigator.vue",
    "content": "<template>\n  <div\n    ref=\"wrapperRef\"\n    class=\"navigator-wrapper\"\n    :style=\"wrapperStyle\"\n    :class=\"{ 'navigator-dragging': isDragging }\"\n  >\n    <!-- 触发按钮（同时作为拖拽手柄） -->\n    <button\n      ref=\"triggerRef\"\n      class=\"navigator-trigger\"\n      :class=\"{ 'navigator-trigger-active': isOpen }\"\n      @click=\"handleTriggerClick\"\n      @dblclick=\"resetToDefault\"\n      title=\"切换页面（可拖拽移动，双击重置位置）\"\n    >\n      <svg\n        class=\"navigator-icon\"\n        viewBox=\"0 0 24 24\"\n        width=\"20\"\n        height=\"20\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 6h16M4 12h16M4 18h16\" />\n      </svg>\n    </button>\n\n    <!-- 浮层菜单 -->\n    <Transition name=\"navigator-menu\">\n      <div v-if=\"isOpen\" class=\"navigator-overlay\" @click=\"closeMenu\">\n        <div class=\"navigator-menu\" :style=\"menuStyle\" @click.stop>\n          <div class=\"navigator-header\">\n            <span class=\"navigator-title\">切换页面</span>\n            <button class=\"navigator-close\" @click=\"closeMenu\">\n              <svg\n                viewBox=\"0 0 24 24\"\n                width=\"18\"\n                height=\"18\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n              </svg>\n            </button>\n          </div>\n          <div class=\"navigator-items\">\n            <button\n              class=\"navigator-item\"\n              :class=\"{ 'navigator-item-active': activeTab === 'agent-chat' }\"\n              @click=\"selectTab('agent-chat')\"\n            >\n              <div class=\"navigator-item-icon\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"navigator-item-content\">\n                <span class=\"navigator-item-title\">智能助手</span>\n                <span class=\"navigator-item-desc\">AI Agent 对话与任务</span>\n              </div>\n              <div v-if=\"activeTab === 'agent-chat'\" class=\"navigator-item-check\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"16\"\n                  height=\"16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2.5\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                </svg>\n              </div>\n            </button>\n            <button\n              class=\"navigator-item\"\n              :class=\"{ 'navigator-item-active': activeTab === 'workflows' }\"\n              @click=\"selectTab('workflows')\"\n            >\n              <div class=\"navigator-item-icon\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"navigator-item-content\">\n                <span class=\"navigator-item-title\">工作流管理</span>\n                <span class=\"navigator-item-desc\">录制与回放自动化流程</span>\n              </div>\n              <div v-if=\"activeTab === 'workflows'\" class=\"navigator-item-check\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"16\"\n                  height=\"16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2.5\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                </svg>\n              </div>\n            </button>\n            <button\n              class=\"navigator-item\"\n              :class=\"{ 'navigator-item-active': activeTab === 'element-markers' }\"\n              @click=\"selectTab('element-markers')\"\n            >\n              <div class=\"navigator-item-icon\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"20\"\n                  height=\"20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path\n                    stroke-linecap=\"round\"\n                    stroke-linejoin=\"round\"\n                    d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\"\n                  />\n                </svg>\n              </div>\n              <div class=\"navigator-item-content\">\n                <span class=\"navigator-item-title\">元素标注管理</span>\n                <span class=\"navigator-item-desc\">管理页面元素标注</span>\n              </div>\n              <div v-if=\"activeTab === 'element-markers'\" class=\"navigator-item-check\">\n                <svg\n                  viewBox=\"0 0 24 24\"\n                  width=\"16\"\n                  height=\"16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2.5\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                </svg>\n              </div>\n            </button>\n          </div>\n        </div>\n      </div>\n    </Transition>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed } from 'vue';\nimport { useFloatingDrag } from '../composables/useFloatingDrag';\n\ntype TabType = 'workflows' | 'element-markers' | 'agent-chat';\n\nconst BUTTON_SIZE = 36;\nconst CLAMP_MARGIN = 12;\n\nconst props = defineProps<{\n  activeTab: TabType;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'change', tab: TabType): void;\n}>();\n\nconst isOpen = ref(false);\nconst wrapperRef = ref<HTMLElement | null>(null);\nconst triggerRef = ref<HTMLElement | null>(null);\n\n// Initialize floating drag\nconst { positionStyle, isDragging, resetToDefault } = useFloatingDrag(triggerRef, wrapperRef, {\n  clampMargin: CLAMP_MARGIN,\n  clickThresholdMs: 150,\n  moveThresholdPx: 5,\n  getDefaultPosition: () => ({\n    left: window.innerWidth - BUTTON_SIZE - CLAMP_MARGIN,\n    top: window.innerHeight - BUTTON_SIZE - CLAMP_MARGIN,\n  }),\n});\n\n// Wrapper style with dynamic position\nconst wrapperStyle = computed(() => ({\n  left: positionStyle.value.left,\n  top: positionStyle.value.top,\n}));\n\n// Menu position: prefer appearing above and to the left of the trigger\nconst menuStyle = computed(() => {\n  // Menu appears in fixed position near the trigger\n  return {};\n});\n\nfunction handleTriggerClick() {\n  // Only toggle menu if not currently dragging\n  if (!isDragging.value) {\n    isOpen.value = !isOpen.value;\n  }\n}\n\nfunction closeMenu() {\n  isOpen.value = false;\n}\n\nfunction selectTab(tab: TabType) {\n  emit('change', tab);\n  closeMenu();\n}\n</script>\n\n<style scoped>\n.navigator-wrapper {\n  position: fixed;\n  z-index: 1000;\n  touch-action: none;\n}\n\n.navigator-dragging {\n  cursor: grabbing;\n}\n\n.navigator-trigger {\n  width: 36px;\n  height: 36px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--ac-surface, #ffffff);\n  border: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #6e6e6e);\n  cursor: grab;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n  touch-action: none;\n  user-select: none;\n}\n\n.navigator-trigger:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n  color: var(--ac-text, #1a1a1a);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.05));\n}\n\n.navigator-trigger:active,\n.navigator-dragging .navigator-trigger {\n  cursor: grabbing;\n}\n\n.navigator-trigger-active {\n  background: var(--ac-accent, #d97757);\n  color: var(--ac-accent-contrast, #ffffff);\n  border-color: var(--ac-accent, #d97757);\n}\n\n.navigator-trigger-active:hover {\n  background: var(--ac-accent-hover, #c4664a);\n  color: var(--ac-accent-contrast, #ffffff);\n}\n\n.navigator-icon {\n  flex-shrink: 0;\n  pointer-events: none;\n}\n\n.navigator-overlay {\n  position: fixed;\n  inset: 0;\n  background: rgba(0, 0, 0, 0.3);\n  display: flex;\n  align-items: flex-end;\n  justify-content: flex-end;\n  padding: 12px;\n}\n\n.navigator-menu {\n  width: 280px;\n  max-height: calc(100vh - 80px);\n  background: var(--ac-surface, #ffffff);\n  border: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n  border-radius: var(--ac-radius-card, 12px);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.05));\n  overflow: hidden;\n}\n\n.navigator-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 12px 16px;\n  border-bottom: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n}\n\n.navigator-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--ac-text, #1a1a1a);\n}\n\n.navigator-close {\n  width: 28px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #6e6e6e);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.navigator-close:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n  color: var(--ac-text, #1a1a1a);\n}\n\n.navigator-items {\n  padding: 8px;\n}\n\n.navigator-item {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 12px;\n  background: transparent;\n  border: none;\n  border-radius: var(--ac-radius-inner, 8px);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n  text-align: left;\n}\n\n.navigator-item:hover {\n  background: var(--ac-hover-bg, #f5f5f4);\n}\n\n.navigator-item-active {\n  background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.12));\n}\n\n.navigator-item-active:hover {\n  background: var(--ac-accent-subtle, rgba(217, 119, 87, 0.12));\n}\n\n.navigator-item-icon {\n  width: 36px;\n  height: 36px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--ac-surface-muted, #f2f0eb);\n  border-radius: var(--ac-radius-button, 8px);\n  color: var(--ac-text-muted, #6e6e6e);\n  flex-shrink: 0;\n}\n\n.navigator-item-active .navigator-item-icon {\n  background: var(--ac-accent, #d97757);\n  color: var(--ac-accent-contrast, #ffffff);\n}\n\n.navigator-item-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.navigator-item-title {\n  display: block;\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--ac-text, #1a1a1a);\n  line-height: 1.3;\n}\n\n.navigator-item-desc {\n  display: block;\n  font-size: 12px;\n  color: var(--ac-text-subtle, #a8a29e);\n  line-height: 1.3;\n  margin-top: 2px;\n}\n\n.navigator-item-check {\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--ac-accent, #d97757);\n  flex-shrink: 0;\n}\n\n/* Transition animations */\n.navigator-menu-enter-active,\n.navigator-menu-leave-active {\n  transition: opacity var(--ac-motion-fast, 120ms) ease;\n}\n\n.navigator-menu-enter-active .navigator-menu,\n.navigator-menu-leave-active .navigator-menu {\n  transition:\n    transform var(--ac-motion-fast, 120ms) ease,\n    opacity var(--ac-motion-fast, 120ms) ease;\n}\n\n.navigator-menu-enter-from,\n.navigator-menu-leave-to {\n  opacity: 0;\n}\n\n.navigator-menu-enter-from .navigator-menu,\n.navigator-menu-leave-to .navigator-menu {\n  opacity: 0;\n  transform: translateY(8px);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/AttachmentPreview.vue",
    "content": "<template>\n  <div class=\"flex flex-wrap gap-2 mb-2\">\n    <div\n      v-for=\"(att, idx) in attachments\"\n      :key=\"idx\"\n      class=\"flex items-center gap-1 bg-slate-100 px-2 py-1 rounded text-xs max-w-[150px]\"\n    >\n      <span class=\"truncate\" :title=\"att.name\">{{ att.name }}</span>\n      <button\n        type=\"button\"\n        class=\"text-red-500 hover:text-red-700 flex-shrink-0\"\n        @click=\"$emit('remove', idx)\"\n      >\n        &times;\n      </button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { AgentAttachment } from 'chrome-mcp-shared';\n\ndefineProps<{\n  attachments: AgentAttachment[];\n}>();\n\ndefineEmits<{\n  remove: [index: number];\n}>();\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/ChatInput.vue",
    "content": "<template>\n  <form class=\"p-3 border-t border-slate-200 bg-white space-y-2\" @submit.prevent=\"handleSubmit\">\n    <!-- Attachments preview -->\n    <AttachmentPreview\n      v-if=\"attachments.length > 0\"\n      :attachments=\"attachments\"\n      @remove=\"$emit('remove-attachment', $event)\"\n    />\n\n    <textarea\n      v-model=\"inputValue\"\n      class=\"w-full border border-slate-200 rounded-md px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-slate-400\"\n      rows=\"2\"\n      placeholder=\"Ask the agent to work with your browser via MCP...\"\n      @input=\"$emit('update:modelValue', inputValue)\"\n    ></textarea>\n\n    <!-- Hidden file input -->\n    <input\n      ref=\"fileInputRef\"\n      type=\"file\"\n      class=\"hidden\"\n      accept=\"image/*\"\n      multiple\n      @change=\"$emit('file-select', $event)\"\n    />\n\n    <div class=\"flex items-center justify-between gap-2\">\n      <div class=\"flex items-center gap-2\">\n        <button\n          type=\"button\"\n          class=\"text-slate-500 hover:text-slate-700 text-xs px-2 py-1 border border-slate-200 rounded hover:bg-slate-50\"\n          title=\"Attach images\"\n          @click=\"openFilePicker\"\n        >\n          Attach\n        </button>\n        <div class=\"text-[11px] text-slate-500\">\n          {{ isStreaming ? 'Agent is thinking...' : 'Ready' }}\n        </div>\n      </div>\n      <div class=\"flex gap-2\">\n        <button\n          v-if=\"isStreaming && canCancel\"\n          type=\"button\"\n          class=\"btn-secondary !px-3 !py-2 text-xs\"\n          :disabled=\"cancelling\"\n          @click=\"$emit('cancel')\"\n        >\n          {{ cancelling ? 'Cancelling...' : 'Stop' }}\n        </button>\n        <button\n          type=\"submit\"\n          class=\"btn-primary !px-4 !py-2 text-xs\"\n          :disabled=\"!canSend || sending\"\n        >\n          {{ sending ? 'Sending...' : 'Send' }}\n        </button>\n      </div>\n    </div>\n  </form>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, watch } from 'vue';\nimport type { AgentAttachment } from 'chrome-mcp-shared';\nimport AttachmentPreview from './AttachmentPreview.vue';\n\nconst props = defineProps<{\n  modelValue: string;\n  attachments: AgentAttachment[];\n  isStreaming: boolean;\n  sending: boolean;\n  cancelling: boolean;\n  canCancel: boolean;\n  canSend: boolean;\n}>();\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: string];\n  submit: [];\n  cancel: [];\n  'file-select': [event: Event];\n  'remove-attachment': [index: number];\n}>();\n\nconst inputValue = ref(props.modelValue);\nconst fileInputRef = ref<HTMLInputElement | null>(null);\n\n// Sync with parent\nwatch(\n  () => props.modelValue,\n  (newVal) => {\n    inputValue.value = newVal;\n  },\n);\n\nfunction openFilePicker(): void {\n  fileInputRef.value?.click();\n}\n\nfunction handleSubmit(): void {\n  emit('submit');\n}\n\n// Expose file input ref for parent\ndefineExpose({\n  fileInputRef,\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/CliSettings.vue",
    "content": "<template>\n  <div class=\"flex flex-col gap-2\">\n    <!-- Root override -->\n    <div class=\"flex items-center gap-2\">\n      <span class=\"whitespace-nowrap\">Root override</span>\n      <input\n        :value=\"projectRoot\"\n        class=\"flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400\"\n        placeholder=\"Optional override path; defaults to selected project workspace\"\n        @input=\"$emit('update:project-root', ($event.target as HTMLInputElement).value)\"\n        @change=\"$emit('save-root')\"\n      />\n      <button\n        class=\"btn-secondary !px-2 !py-1 text-[11px]\"\n        type=\"button\"\n        :disabled=\"isSavingRoot\"\n        @click=\"$emit('save-root')\"\n      >\n        {{ isSavingRoot ? 'Saving...' : 'Save' }}\n      </button>\n    </div>\n\n    <!-- CLI & Model selection -->\n    <div class=\"flex items-center gap-2\">\n      <span class=\"whitespace-nowrap\">CLI</span>\n      <select\n        :value=\"selectedCli\"\n        class=\"border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400\"\n        @change=\"handleCliChange\"\n      >\n        <option value=\"\">Auto (per project / server default)</option>\n        <option v-for=\"e in engines\" :key=\"e.name\" :value=\"e.name\">\n          {{ e.name }}\n        </option>\n      </select>\n      <span class=\"whitespace-nowrap\">Model</span>\n      <select\n        :value=\"model\"\n        class=\"flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400\"\n        @change=\"$emit('update:model', ($event.target as HTMLSelectElement).value)\"\n      >\n        <option value=\"\">Default</option>\n        <option v-for=\"m in availableModels\" :key=\"m.id\" :value=\"m.id\">\n          {{ m.name }}\n        </option>\n      </select>\n      <!-- CCR option (Claude Code Router) - only shown when Claude CLI is selected -->\n      <label\n        v-if=\"showCcrOption\"\n        class=\"flex items-center gap-1 whitespace-nowrap cursor-pointer\"\n        title=\"Use Claude Code Router for API routing\"\n      >\n        <input\n          type=\"checkbox\"\n          :checked=\"useCcr\"\n          class=\"w-3 h-3 rounded border-slate-300 text-blue-600 focus:ring-blue-500\"\n          @change=\"$emit('update:use-ccr', ($event.target as HTMLInputElement).checked)\"\n        />\n        <span class=\"text-[11px] text-slate-600\">CCR</span>\n      </label>\n      <button\n        class=\"btn-secondary !px-2 !py-1 text-[11px]\"\n        type=\"button\"\n        :disabled=\"!selectedProject || isSavingPreference\"\n        @click=\"$emit('save-preference')\"\n      >\n        {{ isSavingPreference ? 'Saving...' : 'Save' }}\n      </button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';\nimport {\n  getModelsForCli,\n  getDefaultModelForCli,\n  type ModelDefinition,\n} from '@/common/agent-models';\n\nconst props = defineProps<{\n  projectRoot: string;\n  selectedCli: string;\n  model: string;\n  useCcr: boolean;\n  engines: AgentEngineInfo[];\n  selectedProject: AgentProject | null;\n  isSavingRoot: boolean;\n  isSavingPreference: boolean;\n}>();\n\nconst emit = defineEmits<{\n  'update:project-root': [value: string];\n  'update:selected-cli': [value: string];\n  'update:model': [value: string];\n  'update:use-ccr': [value: boolean];\n  'save-root': [];\n  'save-preference': [];\n}>();\n\n// Get available models based on selected CLI\nconst availableModels = computed<ModelDefinition[]>(() => {\n  return getModelsForCli(props.selectedCli);\n});\n\n// Show CCR option only when Claude CLI is selected\nconst showCcrOption = computed(() => {\n  return props.selectedCli === 'claude';\n});\n\n// Handle CLI change - auto-select default model for the CLI\nfunction handleCliChange(event: Event): void {\n  const cli = (event.target as HTMLSelectElement).value;\n  emit('update:selected-cli', cli);\n\n  // Auto-select default model when CLI changes\n  if (cli) {\n    const defaultModel = getDefaultModelForCli(cli);\n    emit('update:model', defaultModel);\n  } else {\n    emit('update:model', '');\n  }\n\n  // Reset CCR when switching away from Claude\n  if (cli !== 'claude') {\n    emit('update:use-ccr', false);\n  }\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/ConnectionStatus.vue",
    "content": "<template>\n  <div class=\"px-4 py-2 border-b border-slate-200 flex items-center justify-between gap-2\">\n    <div class=\"flex items-center gap-2 text-xs text-slate-600\">\n      <span :class=\"['inline-flex h-2 w-2 rounded-full', statusColor]\"></span>\n      <span>{{ statusText }}</span>\n    </div>\n    <button\n      class=\"btn-secondary !px-3 !py-1 text-xs\"\n      :disabled=\"connecting\"\n      @click=\"$emit('reconnect')\"\n    >\n      {{ connecting ? 'Reconnecting...' : 'Reconnect' }}\n    </button>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\n\nconst props = defineProps<{\n  isServerReady: boolean;\n  nativeConnected: boolean;\n  connecting: boolean;\n}>();\n\ndefineEmits<{\n  reconnect: [];\n}>();\n\nconst statusColor = computed(() => {\n  if (props.isServerReady) return 'bg-green-500';\n  if (props.nativeConnected) return 'bg-yellow-500';\n  return 'bg-slate-400';\n});\n\nconst statusText = computed(() => {\n  if (props.isServerReady) return 'Agent server connected';\n  if (props.nativeConnected) return 'Connecting to agent server...';\n  return 'Native host not connected';\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/MessageItem.vue",
    "content": "<template>\n  <div class=\"flex flex-col gap-1 rounded-lg px-3 py-2 max-w-full\" :class=\"messageClasses\">\n    <div class=\"flex items-center justify-between gap-2 text-[11px] opacity-70\">\n      <span>{{ senderName }}</span>\n      <span v-if=\"message.createdAt\">\n        {{ formatTime(message.createdAt) }}\n      </span>\n    </div>\n    <div class=\"whitespace-pre-wrap break-words text-xs leading-relaxed\">\n      {{ message.content }}\n    </div>\n    <div v-if=\"message.isStreaming && !message.isFinal\" class=\"text-[10px] opacity-60 mt-0.5\">\n      Streaming...\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { AgentMessage } from 'chrome-mcp-shared';\n\nconst props = defineProps<{\n  message: AgentMessage;\n}>();\n\nconst messageClasses = computed(() => {\n  return props.message.role === 'user'\n    ? 'bg-white border border-slate-200 self-end'\n    : 'bg-slate-900 text-slate-50 self-start';\n});\n\nconst senderName = computed(() => {\n  return props.message.role === 'user' ? 'You' : props.message.cliSource || 'Agent';\n});\n\nfunction formatTime(dateStr: string): string {\n  return new Date(dateStr).toLocaleTimeString();\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/MessageList.vue",
    "content": "<template>\n  <div class=\"flex-1 overflow-y-auto px-4 py-3 space-y-3 bg-slate-50\">\n    <div v-if=\"messages.length === 0\" class=\"text-xs text-slate-500 text-center mt-4\">\n      Start a conversation with your local agent. Messages stream from the native server via SSE and\n      are persisted per project.\n    </div>\n\n    <MessageItem v-for=\"message in messages\" :key=\"message.id\" :message=\"message\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { AgentMessage } from 'chrome-mcp-shared';\nimport MessageItem from './MessageItem.vue';\n\ndefineProps<{\n  messages: AgentMessage[];\n}>();\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectCreateForm.vue",
    "content": "<template>\n  <div class=\"flex flex-col gap-2\">\n    <!-- Project name input -->\n    <div class=\"flex items-center gap-2\">\n      <span class=\"whitespace-nowrap w-12\">Name</span>\n      <input\n        :value=\"name\"\n        class=\"flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400\"\n        placeholder=\"Project name\"\n        @input=\"handleNameInput\"\n      />\n    </div>\n\n    <!-- Root path selection -->\n    <div class=\"flex items-center gap-2\">\n      <span class=\"whitespace-nowrap w-12\">Root</span>\n      <input\n        :value=\"rootPath\"\n        readonly\n        class=\"flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-slate-50 text-slate-600 focus:outline-none cursor-default\"\n        :placeholder=\"isLoadingDefault ? 'Loading...' : 'Select a directory'\"\n      />\n      <button\n        class=\"btn-secondary !px-2 !py-1 text-[11px] whitespace-nowrap\"\n        type=\"button\"\n        :disabled=\"isPicking\"\n        title=\"Use default directory (~/.chrome-mcp-agent/workspaces/...)\"\n        @click=\"$emit('use-default')\"\n      >\n        Default\n      </button>\n      <button\n        class=\"btn-secondary !px-2 !py-1 text-[11px] whitespace-nowrap\"\n        type=\"button\"\n        :disabled=\"isPicking\"\n        title=\"Open system directory picker\"\n        @click=\"$emit('pick-directory')\"\n      >\n        {{ isPicking ? '...' : 'Browse' }}\n      </button>\n      <button\n        class=\"btn-primary !px-2 !py-1 text-[11px]\"\n        type=\"button\"\n        :disabled=\"isCreating || !canCreate\"\n        @click=\"$emit('create')\"\n      >\n        {{ isCreating ? 'Creating...' : 'Create' }}\n      </button>\n    </div>\n\n    <!-- Error message -->\n    <div v-if=\"error\" class=\"text-[11px] text-red-600\">\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\ndefineProps<{\n  name: string;\n  rootPath: string;\n  isCreating: boolean;\n  isPicking: boolean;\n  isLoadingDefault: boolean;\n  canCreate: boolean;\n  error: string | null;\n}>();\n\nconst emit = defineEmits<{\n  'update:name': [value: string];\n  'update:root-path': [value: string];\n  'use-default': [];\n  'pick-directory': [];\n  create: [];\n}>();\n\nfunction handleNameInput(event: Event): void {\n  const value = (event.target as HTMLInputElement).value;\n  emit('update:name', value);\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/ProjectSelector.vue",
    "content": "<template>\n  <div\n    class=\"px-4 py-2 border-b border-slate-100 flex flex-col gap-2 text-xs text-slate-600 bg-slate-50\"\n  >\n    <!-- Project selection & workspace -->\n    <div class=\"flex items-center gap-2\">\n      <span class=\"whitespace-nowrap\">Project</span>\n      <select\n        :value=\"selectedProjectId\"\n        class=\"flex-1 border border-slate-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-slate-400\"\n        @change=\"handleProjectChange\"\n      >\n        <option v-for=\"p in projects\" :key=\"p.id\" :value=\"p.id\">\n          {{ p.name }}\n        </option>\n      </select>\n      <button\n        class=\"btn-secondary !px-2 !py-1 text-[11px]\"\n        type=\"button\"\n        :disabled=\"isPicking\"\n        title=\"Create new project from a directory\"\n        @click=\"$emit('new-project')\"\n      >\n        {{ isPicking ? '...' : 'New' }}\n      </button>\n    </div>\n\n    <!-- Current workspace path -->\n    <div v-if=\"selectedProject\" class=\"flex items-center gap-2 text-[11px] text-slate-500\">\n      <span class=\"whitespace-nowrap\">Path</span>\n      <span class=\"flex-1 font-mono truncate\" :title=\"selectedProject.rootPath\">\n        {{ selectedProject.rootPath }}\n      </span>\n    </div>\n\n    <!-- CLI & Model selection -->\n    <CliSettings\n      :project-root=\"projectRoot\"\n      :selected-cli=\"selectedCli\"\n      :model=\"model\"\n      :use-ccr=\"useCcr\"\n      :engines=\"engines\"\n      :selected-project=\"selectedProject\"\n      :is-saving-root=\"isSavingProjectRoot\"\n      :is-saving-preference=\"isSavingPreference\"\n      @update:project-root=\"$emit('update:projectRoot', $event)\"\n      @update:selected-cli=\"$emit('update:selectedCli', $event)\"\n      @update:model=\"$emit('update:model', $event)\"\n      @update:use-ccr=\"$emit('update:useCcr', $event)\"\n      @save-root=\"$emit('save-root')\"\n      @save-preference=\"$emit('save-preference')\"\n    />\n\n    <!-- Error message -->\n    <div v-if=\"error\" class=\"text-[11px] text-red-600\">\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';\nimport CliSettings from './CliSettings.vue';\n\ndefineProps<{\n  projects: AgentProject[];\n  selectedProjectId: string;\n  selectedProject: AgentProject | null;\n  isPicking: boolean;\n  error: string | null;\n  projectRoot: string;\n  selectedCli: string;\n  model: string;\n  useCcr: boolean;\n  engines: AgentEngineInfo[];\n  isSavingProjectRoot: boolean;\n  isSavingPreference: boolean;\n}>();\n\nconst emit = defineEmits<{\n  'update:selectedProjectId': [value: string];\n  'project-changed': [];\n  'new-project': [];\n  'update:projectRoot': [value: string];\n  'update:selectedCli': [value: string];\n  'update:model': [value: string];\n  'update:useCcr': [value: boolean];\n  'save-root': [];\n  'save-preference': [];\n}>();\n\nfunction handleProjectChange(event: Event): void {\n  const value = (event.target as HTMLSelectElement).value;\n  emit('update:selectedProjectId', value);\n  emit('project-changed');\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent/index.ts",
    "content": "/**\n * Agent Chat Components\n * Export all sub-components for the agent chat feature.\n */\nexport { default as ConnectionStatus } from './ConnectionStatus.vue';\nexport { default as ProjectSelector } from './ProjectSelector.vue';\nexport { default as ProjectCreateForm } from './ProjectCreateForm.vue';\nexport { default as CliSettings } from './CliSettings.vue';\nexport { default as MessageList } from './MessageList.vue';\nexport { default as MessageItem } from './MessageItem.vue';\nexport { default as ChatInput } from './ChatInput.vue';\nexport { default as AttachmentPreview } from './AttachmentPreview.vue';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentChatShell.vue",
    "content": "<template>\n  <div ref=\"shellRef\" class=\"h-full flex flex-col overflow-hidden relative\">\n    <!-- Header -->\n    <header\n      class=\"flex-none px-5 py-3 flex items-center justify-between z-20\"\n      :style=\"{\n        backgroundColor: 'var(--ac-header-bg)',\n        borderBottom: 'var(--ac-border-width) solid var(--ac-header-border)',\n        backdropFilter: 'blur(8px)',\n      }\"\n    >\n      <slot name=\"header\" />\n    </header>\n\n    <!-- Content Area -->\n    <main\n      ref=\"contentRef\"\n      class=\"flex-1 overflow-y-auto ac-scroll\"\n      :style=\"{\n        paddingBottom: composerHeight + 'px',\n      }\"\n      @scroll=\"handleScroll\"\n    >\n      <!-- Stable wrapper for ResizeObserver -->\n      <div ref=\"contentSlotRef\">\n        <slot name=\"content\" />\n      </div>\n    </main>\n\n    <!-- Footer / Composer -->\n    <footer\n      ref=\"composerRef\"\n      class=\"flex-none px-5 pb-5 pt-2\"\n      :style=\"{\n        background: `linear-gradient(to top, var(--ac-bg), var(--ac-bg), transparent)`,\n      }\"\n    >\n      <!-- Error Banner (above input) -->\n      <div\n        v-if=\"errorMessage\"\n        class=\"mb-2 px-4 py-2 text-xs rounded-lg flex items-start gap-2\"\n        :style=\"{\n          backgroundColor: 'var(--ac-diff-del-bg)',\n          color: 'var(--ac-danger)',\n          border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',\n          borderRadius: 'var(--ac-radius-inner)',\n        }\"\n      >\n        <!-- Error message with scroll for long content -->\n        <div\n          class=\"min-w-0 flex-1 whitespace-pre-wrap break-all ac-scroll\"\n          :style=\"{ maxHeight: '30vh', overflowY: 'auto', overflowWrap: 'anywhere' }\"\n        >\n          {{ errorMessage }}\n        </div>\n\n        <!-- Dismiss button -->\n        <button\n          type=\"button\"\n          class=\"p-1 flex-shrink-0 ac-btn ac-focus-ring cursor-pointer\"\n          :style=\"{\n            color: 'var(--ac-danger)',\n            borderRadius: 'var(--ac-radius-button)',\n          }\"\n          aria-label=\"Dismiss error\"\n          title=\"Dismiss\"\n          @click=\"emit('error:dismiss')\"\n        >\n          <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M6 18L18 6M6 6l12 12\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <slot name=\"composer\" />\n\n      <!-- Usage & Version label -->\n      <div\n        class=\"text-[10px] text-center mt-2 font-medium tracking-wide flex items-center justify-center gap-2\"\n        :style=\"{ color: 'var(--ac-text-subtle)' }\"\n      >\n        <template v-if=\"usage\">\n          <span\n            :title=\"`Input: ${usage.inputTokens.toLocaleString()}, Output: ${usage.outputTokens.toLocaleString()}`\"\n          >\n            {{ formatTokens(usage.inputTokens + usage.outputTokens) }} tokens\n          </span>\n          <span class=\"opacity-50\">·</span>\n          <span\n            :title=\"`Duration: ${(usage.durationMs / 1000).toFixed(1)}s, Turns: ${usage.numTurns}`\"\n          >\n            ${{ usage.totalCostUsd.toFixed(4) }}\n          </span>\n          <span class=\"opacity-50\">·</span>\n        </template>\n        <span>{{ footerLabel || 'Agent Preview' }}</span>\n      </div>\n    </footer>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport type { AgentUsageStats } from 'chrome-mcp-shared';\n\ndefineProps<{\n  errorMessage?: string | null;\n  usage?: AgentUsageStats | null;\n  /** Footer label to display (e.g., \"Claude Code Preview\", \"Codex Preview\") */\n  footerLabel?: string;\n}>();\n\nconst emit = defineEmits<{\n  /** Emitted when user clicks dismiss button on error banner */\n  'error:dismiss': [];\n}>();\n\n/**\n * Format token count for display (e.g., 1.2k, 3.5M)\n */\nfunction formatTokens(count: number): string {\n  if (count >= 1_000_000) {\n    return (count / 1_000_000).toFixed(1) + 'M';\n  }\n  if (count >= 1_000) {\n    return (count / 1_000).toFixed(1) + 'k';\n  }\n  return count.toString();\n}\n\nconst shellRef = ref<HTMLElement | null>(null);\nconst contentRef = ref<HTMLElement | null>(null);\nconst contentSlotRef = ref<HTMLElement | null>(null);\nconst composerRef = ref<HTMLElement | null>(null);\nconst composerHeight = ref(120); // Default height\n\n// Auto-scroll state\nconst isUserScrolledUp = ref(false);\n// Threshold should account for padding and some tolerance\nconst SCROLL_THRESHOLD = 150;\n\n/**\n * Check if scroll position is near bottom\n */\nfunction isNearBottom(el: HTMLElement): boolean {\n  const { scrollTop, scrollHeight, clientHeight } = el;\n  return scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;\n}\n\n/**\n * Handle user scroll to track if they've scrolled up\n */\nfunction handleScroll(): void {\n  if (!contentRef.value) return;\n  isUserScrolledUp.value = !isNearBottom(contentRef.value);\n}\n\n/**\n * Scroll to bottom of content area\n */\nfunction scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n  if (!contentRef.value) return;\n  contentRef.value.scrollTo({\n    top: contentRef.value.scrollHeight,\n    behavior,\n  });\n}\n\n// Observers\nlet composerResizeObserver: ResizeObserver | null = null;\nlet contentResizeObserver: ResizeObserver | null = null;\n\n// Scroll scheduling to prevent excessive calls during streaming\nlet scrollScheduled = false;\n\n/**\n * Auto-scroll when content or composer changes (if user is at bottom)\n * Uses requestAnimationFrame to debounce rapid updates during streaming\n */\nfunction maybeAutoScroll(): void {\n  if (scrollScheduled || isUserScrolledUp.value || !contentRef.value) {\n    return;\n  }\n  scrollScheduled = true;\n  requestAnimationFrame(() => {\n    scrollScheduled = false;\n    if (!isUserScrolledUp.value) {\n      scrollToBottom('auto');\n    }\n  });\n}\n\nonMounted(() => {\n  // Observe composer height changes\n  if (composerRef.value) {\n    composerResizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        composerHeight.value = entry.contentRect.height + 24; // Add padding\n      }\n      // Also auto-scroll when composer height changes (e.g., error banner appears)\n      maybeAutoScroll();\n    });\n    composerResizeObserver.observe(composerRef.value);\n  }\n\n  // Observe content height changes for auto-scroll using stable wrapper\n  if (contentSlotRef.value) {\n    contentResizeObserver = new ResizeObserver(() => {\n      maybeAutoScroll();\n    });\n    contentResizeObserver.observe(contentSlotRef.value);\n  }\n});\n\nonUnmounted(() => {\n  composerResizeObserver?.disconnect();\n  contentResizeObserver?.disconnect();\n});\n\n// Expose scrollToBottom for parent component to call\ndefineExpose({\n  scrollToBottom,\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentComposer.vue",
    "content": "<template>\n  <div class=\"relative\" @dragover=\"handleDragOver\" @dragleave=\"handleDragLeave\" @drop=\"handleDrop\">\n    <!-- Drag overlay -->\n    <div\n      v-if=\"isDragOver\"\n      class=\"absolute inset-0 z-10 flex items-center justify-center rounded-lg pointer-events-none\"\n      :style=\"{\n        backgroundColor: 'var(--ac-accent)',\n        opacity: 0.1,\n        border: '2px dashed var(--ac-accent)',\n      }\"\n    >\n      <span class=\"text-sm font-medium\" :style=\"{ color: 'var(--ac-accent)' }\">\n        Drop images here\n      </span>\n    </div>\n\n    <!-- Image Previews (thumbnails) -->\n    <div v-if=\"attachments.length > 0\" class=\"flex flex-wrap gap-2 mb-2 px-1\">\n      <div v-for=\"(attachment, index) in attachments\" :key=\"index\" class=\"relative group\">\n        <!-- Thumbnail container -->\n        <div\n          class=\"w-14 h-14 rounded-lg overflow-hidden\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted)',\n            border: 'var(--ac-border-width) solid var(--ac-border)',\n          }\"\n        >\n          <img\n            v-if=\"attachment.type === 'image' && attachment.previewUrl\"\n            :src=\"attachment.previewUrl\"\n            :alt=\"attachment.name\"\n            class=\"w-full h-full object-cover\"\n          />\n          <div\n            v-else\n            class=\"w-full h-full flex items-center justify-center\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n          >\n            <svg class=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n              />\n            </svg>\n          </div>\n        </div>\n        <!-- Remove button (appears on hover) -->\n        <button\n          class=\"absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer\"\n          :style=\"{\n            backgroundColor: 'var(--ac-error)',\n            color: 'white',\n          }\"\n          title=\"Remove image\"\n          @click=\"$emit('attachment:remove', index)\"\n        >\n          <svg class=\"w-2.5 h-2.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"3\"\n              d=\"M6 18L18 6M6 6l12 12\"\n            />\n          </svg>\n        </button>\n        <!-- Filename tooltip on hover -->\n        <div\n          class=\"absolute bottom-0 left-0 right-0 px-0.5 py-0.5 text-[8px] truncate opacity-0 group-hover:opacity-100 transition-opacity rounded-b-lg\"\n          :style=\"{\n            backgroundColor: 'rgba(0,0,0,0.6)',\n            color: 'white',\n          }\"\n        >\n          {{ attachment.name }}\n        </div>\n      </div>\n    </div>\n\n    <!-- Attachment error message -->\n    <div v-if=\"attachmentError\" class=\"px-1 mb-1 text-xs\" :style=\"{ color: 'var(--ac-error)' }\">\n      {{ attachmentError }}\n    </div>\n\n    <!-- Floating Input Card -->\n    <div\n      class=\"flex flex-col transition-all\"\n      :style=\"{\n        backgroundColor: 'var(--ac-surface)',\n        borderRadius: 'var(--ac-radius-card)',\n        border: isDragOver\n          ? '2px solid var(--ac-accent)'\n          : 'var(--ac-border-width) solid var(--ac-border)',\n        boxShadow: 'var(--ac-shadow-float)',\n      }\"\n    >\n      <!-- Textarea wrapper with expand button -->\n      <div class=\"relative\">\n        <textarea\n          ref=\"textareaRef\"\n          :value=\"modelValue\"\n          :class=\"[\n            'w-full bg-transparent border-none focus:ring-0 focus:outline-none resize-none p-3 text-sm',\n            showExpandButton ? 'pr-10' : '',\n          ]\"\n          :style=\"{\n            height: `${textareaHeight}px`,\n            minHeight: `${MIN_HEIGHT}px`,\n            maxHeight: `${MAX_HEIGHT}px`,\n            overflowY: isOverflowing ? 'auto' : 'hidden',\n            fontFamily: 'var(--ac-font-body)',\n            color: 'var(--ac-text)',\n          }\"\n          :placeholder=\"placeholder\"\n          rows=\"1\"\n          @input=\"handleInput\"\n          @keydown.enter.exact.prevent=\"handleEnter\"\n          @paste=\"handlePaste\"\n        />\n\n        <!-- Fake caret overlay (opt-in comet effect, only mount when enabled) -->\n        <FakeCaretOverlay\n          v-if=\"enableFakeCaret\"\n          :textarea-ref=\"textareaRef\"\n          :enabled=\"true\"\n          :value=\"modelValue\"\n        />\n\n        <!-- Expand button (visible when content exceeds max height) -->\n        <Transition name=\"expand-btn\">\n          <button\n            v-if=\"showExpandButton\"\n            type=\"button\"\n            class=\"absolute top-2 right-2 p-1.5 transition-all hover:scale-105 cursor-pointer\"\n            :style=\"expandButtonStyle\"\n            title=\"Expand editor\"\n            @click=\"openDrawer\"\n          >\n            <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\"\n              />\n            </svg>\n          </button>\n        </Transition>\n      </div>\n\n      <div class=\"flex items-center justify-between px-2 pb-2\">\n        <!-- Left Tools -->\n        <div class=\"flex items-center gap-1\">\n          <!-- Attach Button -->\n          <button\n            v-if=\"supportsImages\"\n            class=\"p-1.5 ac-btn\"\n            :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n            data-tooltip=\"Attach image (drag, paste, or click)\"\n            @click=\"$emit('attachment:add')\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n              />\n            </svg>\n          </button>\n\n          <!-- Model Selector (auto-width) -->\n          <div v-if=\"availableModels.length > 0\" class=\"relative\" data-tooltip=\"Switch model\">\n            <!-- Hidden span to measure text width -->\n            <span\n              ref=\"modelWidthRef\"\n              class=\"invisible absolute whitespace-nowrap px-1.5 text-[10px]\"\n              :style=\"{ fontFamily: 'var(--ac-font-mono)' }\"\n            >\n              {{ selectedModelName }}\n            </span>\n            <select\n              :value=\"selectedModel\"\n              class=\"py-0.5 text-[10px] border-none bg-transparent cursor-pointer appearance-none pr-4 pl-1.5\"\n              :style=\"{\n                color: 'var(--ac-text-muted)',\n                fontFamily: 'var(--ac-font-mono)',\n                width: modelSelectWidth,\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              @change=\"handleModelChange\"\n            >\n              <option v-for=\"m in availableModels\" :key=\"m.id\" :value=\"m.id\">\n                {{ m.name }}\n              </option>\n            </select>\n            <!-- Dropdown arrow -->\n            <svg\n              class=\"absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none\"\n              :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              stroke=\"currentColor\"\n            >\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M19 9l-7 7-7-7\"\n              />\n            </svg>\n          </div>\n\n          <!-- Reasoning Effort (Codex only) -->\n          <select\n            v-if=\"\n              isCodexEngine && availableReasoningEfforts && availableReasoningEfforts.length > 0\n            \"\n            :value=\"reasoningEffort\"\n            class=\"px-1.5 py-0.5 text-[10px] border-none bg-transparent cursor-pointer\"\n            :style=\"{\n              color: 'var(--ac-text-muted)',\n              fontFamily: 'var(--ac-font-mono)',\n              borderRadius: 'var(--ac-radius-button)',\n            }\"\n            data-tooltip=\"Reasoning effort\"\n            @change=\"handleReasoningEffortChange\"\n          >\n            <option v-for=\"effort in availableReasoningEfforts\" :key=\"effort\" :value=\"effort\">\n              {{ effort }}\n            </option>\n          </select>\n\n          <!-- Reset Button -->\n          <button\n            class=\"p-1 ac-btn\"\n            :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n            data-tooltip=\"Reset conversation\"\n            @click=\"handleReset\"\n          >\n            <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n              />\n            </svg>\n          </button>\n\n          <!-- Session Settings Button -->\n          <button\n            class=\"p-1 ac-btn\"\n            :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n            data-tooltip=\"Session settings\"\n            @click=\"handleOpenSettings\"\n          >\n            <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4\"\n              />\n            </svg>\n          </button>\n\n          <!-- Status Text -->\n          <div class=\"text-[11px] ml-1 flex items-center gap-1\" :style=\"{ color: statusColor }\">\n            <span\n              v-if=\"sending || isRequestActive\"\n              class=\"inline-block w-1.5 h-1.5 rounded-full animate-pulse\"\n              :style=\"{ backgroundColor: 'var(--ac-accent)' }\"\n            />\n            {{ statusText }}\n          </div>\n        </div>\n\n        <!-- Right Actions -->\n        <div class=\"flex gap-2\">\n          <!-- Primary Action Button: Send (idle) / Stop (loading) -->\n          <button\n            type=\"button\"\n            class=\"p-1 transition-colors cursor-pointer\"\n            :style=\"primaryActionButtonStyle\"\n            :disabled=\"primaryActionDisabled\"\n            :title=\"isRequestActive ? 'Stop' : 'Send'\"\n            :aria-label=\"isRequestActive ? 'Stop request' : 'Send message'\"\n            @click=\"handlePrimaryAction\"\n          >\n            <!-- Stop icon (square) when request is active -->\n            <svg v-if=\"isRequestActive\" class=\"w-3.5 h-3.5\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n              <rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"2\" />\n            </svg>\n            <!-- Send icon (arrow up) when idle -->\n            <svg v-else class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M5 10l7-7m0 0l7 7m-7-7v18\"\n              />\n            </svg>\n          </button>\n        </div>\n      </div>\n    </div>\n\n    <!-- Expanded editor drawer -->\n    <ComposerDrawer\n      :open=\"isDrawerOpen\"\n      :model-value=\"modelValue\"\n      :placeholder=\"placeholder\"\n      :attachments=\"attachments\"\n      :attachment-error=\"attachmentError\"\n      :request-state=\"requestState\"\n      :sending=\"sending\"\n      :cancelling=\"cancelling\"\n      :can-cancel=\"canCancel\"\n      :can-send=\"canSend\"\n      :enable-fake-caret=\"enableFakeCaret\"\n      @close=\"closeDrawer\"\n      @update:model-value=\"handleDrawerInput\"\n      @submit=\"handleSubmit\"\n      @cancel=\"$emit('cancel')\"\n      @attachment:remove=\"$emit('attachment:remove', $event)\"\n      @paste=\"handlePaste\"\n    >\n      <template #left-actions>\n        <div class=\"flex items-center gap-1\">\n          <!-- Attach Button -->\n          <button\n            v-if=\"supportsImages\"\n            class=\"p-1.5 ac-btn\"\n            :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n            data-tooltip=\"Attach image\"\n            @click=\"$emit('attachment:add')\"\n          >\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n              />\n            </svg>\n          </button>\n\n          <!-- Model Selector -->\n          <div v-if=\"availableModels.length > 0\" class=\"relative\" data-tooltip=\"Switch model\">\n            <select\n              :value=\"selectedModel\"\n              class=\"py-0.5 text-[10px] border-none bg-transparent cursor-pointer appearance-none pr-4 pl-1.5\"\n              :style=\"{\n                color: 'var(--ac-text-muted)',\n                fontFamily: 'var(--ac-font-mono)',\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              @change=\"handleModelChange\"\n            >\n              <option v-for=\"m in availableModels\" :key=\"m.id\" :value=\"m.id\">\n                {{ m.name }}\n              </option>\n            </select>\n            <svg\n              class=\"absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none\"\n              :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              stroke=\"currentColor\"\n            >\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M19 9l-7 7-7-7\"\n              />\n            </svg>\n          </div>\n\n          <!-- Status Text -->\n          <div class=\"text-[11px] ml-1 flex items-center gap-1\" :style=\"{ color: statusColor }\">\n            <span\n              v-if=\"sending || isRequestActive\"\n              class=\"inline-block w-1.5 h-1.5 rounded-full animate-pulse\"\n              :style=\"{ backgroundColor: 'var(--ac-accent)' }\"\n            />\n            {{ statusText }}\n          </div>\n        </div>\n      </template>\n    </ComposerDrawer>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, watch, nextTick, toRef } from 'vue';\nimport type { CodexReasoningEffort } from 'chrome-mcp-shared';\nimport type { ModelDefinition } from '@/common/agent-models';\nimport type { AttachmentWithPreview } from '../../composables/useAttachments';\nimport type { RequestState } from '../../composables/useAgentChat';\nimport { useTextareaAutoResize } from '../../composables/useTextareaAutoResize';\nimport ComposerDrawer from './ComposerDrawer.vue';\nimport FakeCaretOverlay from './FakeCaretOverlay.vue';\n\nconst props = defineProps<{\n  modelValue: string;\n  attachments: AttachmentWithPreview[];\n  attachmentError?: string | null;\n  isDragOver?: boolean;\n  /** Message-level streaming state (delta updates) */\n  isStreaming: boolean;\n  /** Request lifecycle state for UI (stop button, loading indicators) */\n  requestState: RequestState;\n  sending: boolean;\n  cancelling: boolean;\n  canCancel: boolean;\n  canSend: boolean;\n  placeholder?: string;\n  // Model selection props\n  engineName?: string;\n  selectedModel: string;\n  availableModels: ModelDefinition[];\n  // Codex reasoning effort props\n  reasoningEffort?: CodexReasoningEffort;\n  availableReasoningEfforts?: readonly CodexReasoningEffort[];\n  // Fake caret feature flag\n  enableFakeCaret?: boolean;\n}>();\n\n/**\n * Whether there is an active request in progress.\n * Derived from requestState for use in UI conditions.\n */\nconst isRequestActive = computed(() => {\n  return (\n    props.requestState === 'starting' ||\n    props.requestState === 'ready' ||\n    props.requestState === 'running'\n  );\n});\n\nconst isCodexEngine = computed(() => props.engineName === 'codex');\n\n// Image upload is supported for Claude and Codex engines\nconst supportsImages = computed(() => {\n  const engine = props.engineName;\n  return engine === 'claude' || engine === 'codex';\n});\n\n// Model selector auto-width\nconst modelWidthRef = ref<HTMLSpanElement | null>(null);\nconst modelSelectWidth = ref('auto');\n\nconst selectedModelName = computed(() => {\n  const model = props.availableModels.find((m) => m.id === props.selectedModel);\n  return model?.name || props.selectedModel || '';\n});\n\n// Update width when model changes\nwatch(\n  [selectedModelName, () => props.availableModels],\n  async () => {\n    await nextTick();\n    if (modelWidthRef.value) {\n      const width = modelWidthRef.value.offsetWidth;\n      // Add extra space for dropdown arrow (16px)\n      modelSelectWidth.value = `${width + 16}px`;\n    }\n  },\n  { immediate: true },\n);\n\nconst statusText = computed(() => {\n  if (props.sending) return 'Sending...';\n  if (props.cancelling) return 'Stopping...';\n  // Use requestState for more accurate status display\n  switch (props.requestState) {\n    case 'starting':\n      return 'Starting...';\n    case 'ready':\n      return 'Preparing...';\n    case 'running':\n      return 'Working...';\n    default:\n      return 'Ready';\n  }\n});\n\nconst statusColor = computed(() => {\n  if (props.sending || isRequestActive.value) return 'var(--ac-accent)';\n  return 'var(--ac-text-subtle)';\n});\n\n// =============================================================================\n// Primary Action Button (Send / Stop)\n// =============================================================================\n\n/**\n * Style for the primary action button.\n * Changes based on whether a request is active.\n */\nconst primaryActionButtonStyle = computed(() => {\n  const baseStyle = {\n    borderRadius: 'var(--ac-radius-button)',\n    // Always have border to prevent size change when switching modes\n    border: 'var(--ac-border-width) solid transparent',\n  };\n\n  if (isRequestActive.value) {\n    // Stop mode: danger style\n    const isDisabled = props.cancelling || !props.canCancel;\n    return {\n      ...baseStyle,\n      backgroundColor: 'var(--ac-diff-del-bg)',\n      color: 'var(--ac-danger)',\n      border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',\n      cursor: isDisabled ? 'not-allowed' : 'pointer',\n      opacity: isDisabled ? 0.6 : 1,\n    };\n  }\n\n  // Send mode: accent style when enabled, muted when disabled\n  return {\n    ...baseStyle,\n    backgroundColor: props.canSend ? 'var(--ac-accent)' : 'var(--ac-surface-muted)',\n    color: props.canSend ? 'var(--ac-accent-contrast)' : 'var(--ac-text-subtle)',\n    cursor: props.canSend ? 'pointer' : 'not-allowed',\n  };\n});\n\n/**\n * Whether the primary action button should be disabled.\n */\nconst primaryActionDisabled = computed(() => {\n  if (isRequestActive.value) {\n    // In stop mode: disabled when already cancelling or cannot cancel\n    return props.cancelling || !props.canCancel;\n  }\n  // In send mode: disabled when cannot send\n  return !props.canSend;\n});\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: string];\n  submit: [];\n  cancel: [];\n  'attachment:add': [];\n  'attachment:remove': [index: number];\n  'attachment:drop': [event: DragEvent];\n  'attachment:paste': [event: ClipboardEvent];\n  'attachment:dragover': [event: DragEvent];\n  'attachment:dragleave': [event: DragEvent];\n  'model:change': [modelId: string];\n  'reasoning-effort:change': [effort: CodexReasoningEffort];\n  'session:settings': [];\n  'session:reset': [];\n}>();\n\nconst textareaRef = ref<HTMLTextAreaElement | null>(null);\n\n// =============================================================================\n// Textarea Auto-Resize\n// =============================================================================\n\nconst MIN_HEIGHT = 50;\nconst MAX_HEIGHT = 200;\n\nconst { height: textareaHeight, isOverflowing } = useTextareaAutoResize({\n  textareaRef,\n  value: toRef(props, 'modelValue'),\n  minHeight: MIN_HEIGHT,\n  maxHeight: MAX_HEIGHT,\n});\n\n// Show expand button when content exceeds max height\nconst showExpandButton = computed(() => isOverflowing.value);\n\n// Expand button style\nconst expandButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\n// =============================================================================\n// Expanded Editor Drawer\n// =============================================================================\n\nconst isDrawerOpen = ref(false);\n\nfunction openDrawer(): void {\n  isDrawerOpen.value = true;\n}\n\nfunction closeDrawer(): void {\n  isDrawerOpen.value = false;\n  // Focus back to main textarea\n  nextTick(() => {\n    textareaRef.value?.focus();\n  });\n}\n\nfunction handleDrawerInput(value: string): void {\n  emit('update:modelValue', value);\n}\n\n// =============================================================================\n// Input Handlers\n// =============================================================================\n\nfunction handleInput(event: Event): void {\n  const value = (event.target as HTMLTextAreaElement).value;\n  emit('update:modelValue', value);\n}\n\nfunction handleEnter(): void {\n  // Don't send when request is active (button shows Stop, not Send)\n  if (isRequestActive.value) return;\n  if (props.canSend && !props.sending) {\n    emit('submit');\n  }\n}\n\nfunction handleSubmit(): void {\n  emit('submit');\n}\n\n/**\n * Handle primary action button click.\n * Sends message when idle, cancels request when active.\n */\nfunction handlePrimaryAction(): void {\n  if (isRequestActive.value) {\n    emit('cancel');\n  } else {\n    handleSubmit();\n  }\n}\n\nfunction handleModelChange(event: Event): void {\n  const modelId = (event.target as HTMLSelectElement).value;\n  emit('model:change', modelId);\n}\n\nfunction handleReasoningEffortChange(event: Event): void {\n  const effort = (event.target as HTMLSelectElement).value as CodexReasoningEffort;\n  emit('reasoning-effort:change', effort);\n}\n\nfunction handleReset(): void {\n  if (\n    confirm(\n      'Reset this conversation? All messages will be deleted and the session will start fresh.',\n    )\n  ) {\n    emit('session:reset');\n  }\n}\n\nfunction handleOpenSettings(): void {\n  emit('session:settings');\n}\n\n// Drag and drop handlers - delegate to parent\n// Always preventDefault to avoid browser default behavior (opening files)\nfunction handleDragOver(event: DragEvent): void {\n  event.preventDefault();\n  event.stopPropagation();\n  if (supportsImages.value) {\n    emit('attachment:dragover', event);\n  }\n}\n\nfunction handleDragLeave(event: DragEvent): void {\n  event.preventDefault();\n  event.stopPropagation();\n  if (supportsImages.value) {\n    emit('attachment:dragleave', event);\n  }\n}\n\nfunction handleDrop(event: DragEvent): void {\n  event.preventDefault();\n  event.stopPropagation();\n  if (supportsImages.value) {\n    emit('attachment:drop', event);\n  }\n}\n\n// Paste handler - delegate to parent\nfunction handlePaste(event: ClipboardEvent): void {\n  if (supportsImages.value) {\n    // Check if clipboard contains images\n    const items = event.clipboardData?.items;\n    if (items) {\n      for (const item of items) {\n        if (item.type.startsWith('image/')) {\n          emit('attachment:paste', event);\n          return;\n        }\n      }\n    }\n  }\n  // Let text paste through normally\n}\n\n// Expose ref for parent focus control\ndefineExpose({\n  focus: () => textareaRef.value?.focus(),\n});\n</script>\n\n<style scoped>\n/* Expand button transition */\n.expand-btn-enter-active,\n.expand-btn-leave-active {\n  transition:\n    opacity 0.15s ease,\n    transform 0.15s ease;\n}\n\n.expand-btn-enter-from,\n.expand-btn-leave-to {\n  opacity: 0;\n  transform: scale(0.9);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentConversation.vue",
    "content": "<template>\n  <div class=\"px-5 py-6 space-y-8\">\n    <!-- Empty State -->\n    <div v-if=\"threads.length === 0\" class=\"py-10 text-center\">\n      <p\n        class=\"text-2xl italic opacity-40\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-heading)',\n          color: 'var(--ac-text-subtle)',\n        }\"\n      >\n        How can I help you code today?\n      </p>\n    </div>\n\n    <!-- Request Threads -->\n    <AgentRequestThread v-for=\"thread in threads\" :key=\"thread.id\" :thread=\"thread\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { AgentThread } from '../../composables/useAgentThreads';\nimport AgentRequestThread from './AgentRequestThread.vue';\n\ndefineProps<{\n  threads: AgentThread[];\n}>();\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentOpenProjectMenu.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed top-12 right-4 z-50 min-w-[160px] py-2\"\n    :style=\"{\n      backgroundColor: 'var(--ac-surface, #ffffff)',\n      border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      borderRadius: 'var(--ac-radius-inner, 8px)',\n      boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',\n    }\"\n  >\n    <!-- Header -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Open In\n    </div>\n\n    <!-- VS Code Option -->\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm flex items-center gap-2 ac-menu-item\"\n      :style=\"{\n        color: defaultTarget === 'vscode' ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',\n      }\"\n      @click=\"handleSelect('vscode')\"\n    >\n      <!-- VS Code Icon -->\n      <svg class=\"w-4 h-4 flex-shrink-0\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n        <path\n          d=\"M17.583 2L6.167 11.667 2 8.5v7l4.167-3.167L17.583 22 22 19.75V4.25L17.583 2zm0 3.5v13l-8-6.5 8-6.5z\"\n        />\n      </svg>\n      <span class=\"flex-1\">VS Code</span>\n      <!-- Default indicator -->\n      <svg\n        v-if=\"defaultTarget === 'vscode'\"\n        class=\"w-4 h-4 flex-shrink-0\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n      </svg>\n    </button>\n\n    <!-- Terminal Option -->\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm flex items-center gap-2 ac-menu-item\"\n      :style=\"{\n        color:\n          defaultTarget === 'terminal' ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',\n      }\"\n      @click=\"handleSelect('terminal')\"\n    >\n      <!-- Terminal Icon -->\n      <svg\n        class=\"w-4 h-4 flex-shrink-0\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n      >\n        <path\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          d=\"M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n        />\n      </svg>\n      <span class=\"flex-1\">Terminal</span>\n      <!-- Default indicator -->\n      <svg\n        v-if=\"defaultTarget === 'terminal'\"\n        class=\"w-4 h-4 flex-shrink-0\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n      </svg>\n    </button>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { OpenProjectTarget } from 'chrome-mcp-shared';\n\ndefineProps<{\n  open: boolean;\n  defaultTarget: OpenProjectTarget | null;\n}>();\n\nconst emit = defineEmits<{\n  select: [target: OpenProjectTarget];\n  close: [];\n}>();\n\nfunction handleSelect(target: OpenProjectTarget): void {\n  emit('select', target);\n  emit('close');\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentProjectMenu.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed top-12 left-4 right-4 z-50 py-2 max-w-[calc(100%-2rem)]\"\n    :style=\"{\n      backgroundColor: 'var(--ac-surface, #ffffff)',\n      border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      borderRadius: 'var(--ac-radius-inner, 8px)',\n      boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',\n    }\"\n  >\n    <!-- Projects Section -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Projects\n    </div>\n\n    <!-- Project List -->\n    <div class=\"max-h-[200px] overflow-y-auto ac-scroll\">\n      <button\n        v-for=\"p in projects\"\n        :key=\"p.id\"\n        class=\"w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item\"\n        :style=\"{\n          color:\n            selectedProjectId === p.id ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',\n        }\"\n        @click=\"$emit('project:select', p.id)\"\n      >\n        <div class=\"flex-1 min-w-0\">\n          <div class=\"truncate\">{{ p.name }}</div>\n          <div\n            class=\"text-[10px] truncate\"\n            :style=\"{\n              fontFamily: 'var(--ac-font-mono, monospace)',\n              color: 'var(--ac-text-subtle, #a8a29e)',\n            }\"\n          >\n            {{ p.rootPath }}\n          </div>\n        </div>\n        <svg\n          v-if=\"selectedProjectId === p.id\"\n          class=\"w-4 h-4 flex-shrink-0 ml-2\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M5 13l4 4L19 7\"\n          />\n        </svg>\n      </button>\n    </div>\n\n    <!-- New Project -->\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm ac-menu-item\"\n      :style=\"{ color: 'var(--ac-link, #3b82f6)' }\"\n      :disabled=\"isPicking\"\n      @click=\"$emit('project:new')\"\n    >\n      {{ isPicking ? 'Selecting...' : '+ New Project' }}\n    </button>\n\n    <!-- Divider -->\n    <div\n      class=\"my-2\"\n      :style=\"{ borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)' }\"\n    />\n\n    <!-- CLI & Model Settings -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Settings\n    </div>\n\n    <!-- CLI Selection -->\n    <div class=\"px-3 py-2 flex items-center gap-2\">\n      <span class=\"text-xs w-12\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"> CLI </span>\n      <select\n        :value=\"selectedCli\"\n        class=\"flex-1 px-2 py-1 text-xs rounded\"\n        :style=\"{\n          backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',\n          border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n          color: 'var(--ac-text, #1a1a1a)',\n          borderRadius: 'var(--ac-radius-button, 8px)',\n        }\"\n        @change=\"handleCliChange\"\n      >\n        <option value=\"\">Auto</option>\n        <option v-for=\"e in engines\" :key=\"e.name\" :value=\"e.name\">\n          {{ e.name }}\n        </option>\n      </select>\n    </div>\n\n    <!-- Model Selection -->\n    <div class=\"px-3 py-2 flex items-center gap-2\">\n      <span class=\"text-xs w-12\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"> Model </span>\n      <select\n        :value=\"normalizedModel\"\n        class=\"flex-1 px-2 py-1 text-xs rounded\"\n        :style=\"{\n          backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',\n          border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n          color: 'var(--ac-text, #1a1a1a)',\n          borderRadius: 'var(--ac-radius-button, 8px)',\n        }\"\n        :disabled=\"isModelDisabled\"\n        @change=\"handleModelChange\"\n      >\n        <option value=\"\">Default</option>\n        <option v-for=\"m in availableModels\" :key=\"m.id\" :value=\"m.id\">\n          {{ m.name }}\n        </option>\n      </select>\n    </div>\n\n    <!-- Reasoning Effort (Codex only) -->\n    <div v-if=\"showReasoningEffortOption\" class=\"px-3 py-2\">\n      <div class=\"flex items-center gap-2\">\n        <span class=\"text-xs w-12\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n          Effort\n        </span>\n        <select\n          :value=\"normalizedReasoningEffort\"\n          class=\"flex-1 px-2 py-1 text-xs rounded\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',\n            border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n            color: 'var(--ac-text, #1a1a1a)',\n            borderRadius: 'var(--ac-radius-button, 8px)',\n          }\"\n          @change=\"handleReasoningEffortChange\"\n        >\n          <option v-for=\"effort in availableReasoningEfforts\" :key=\"effort\" :value=\"effort\">\n            {{ effort }}\n          </option>\n        </select>\n      </div>\n      <p class=\"text-[10px] mt-1 ml-14\" :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\">\n        Applies to new sessions. Edit existing session in Session Settings.\n      </p>\n    </div>\n\n    <!-- CCR Option (Claude Code Router) - only shown when Claude CLI is selected -->\n    <div v-if=\"showCcrOption\" class=\"px-3 py-2 flex items-center gap-2\">\n      <span class=\"text-xs w-12\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"> CCR </span>\n      <label\n        class=\"flex items-center gap-2 cursor-pointer\"\n        title=\"Use Claude Code Router for API routing\"\n      >\n        <input\n          type=\"checkbox\"\n          :checked=\"useCcr\"\n          class=\"w-4 h-4 rounded\"\n          :style=\"{\n            accentColor: 'var(--ac-accent, #c87941)',\n          }\"\n          @change=\"handleCcrChange\"\n        />\n        <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">\n          Use Claude Code Router\n        </span>\n      </label>\n    </div>\n\n    <!-- Chrome MCP Option - only shown when Claude or Codex CLI is selected -->\n    <div v-if=\"showChromeMcpOption\" class=\"px-3 py-2 flex items-center gap-2\">\n      <span class=\"text-xs w-12\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"> MCP </span>\n      <label\n        class=\"flex items-center gap-2 cursor-pointer\"\n        title=\"Enable local Chrome MCP server integration\"\n      >\n        <input\n          type=\"checkbox\"\n          :checked=\"enableChromeMcp\"\n          class=\"w-4 h-4 rounded\"\n          :style=\"{\n            accentColor: 'var(--ac-accent, #c87941)',\n          }\"\n          @change=\"handleChromeMcpChange\"\n        />\n        <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">\n          Enable Chrome MCP Server\n        </span>\n      </label>\n    </div>\n\n    <!-- Save Button -->\n    <div class=\"px-3 py-2\">\n      <button\n        class=\"w-full px-3 py-1.5 text-xs rounded transition-colors hover:opacity-90 cursor-pointer\"\n        :style=\"{\n          backgroundColor: 'var(--ac-accent, #c87941)',\n          color: 'var(--ac-accent-contrast, #ffffff)',\n          borderRadius: 'var(--ac-radius-button, 8px)',\n        }\"\n        :disabled=\"isSaving\"\n        @click=\"handleSave\"\n      >\n        {{ isSaving ? 'Saving...' : 'Save Settings' }}\n      </button>\n    </div>\n\n    <!-- Error -->\n    <div v-if=\"error\" class=\"px-3 py-1 text-[10px]\" :style=\"{ color: 'var(--ac-danger, #dc2626)' }\">\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { AgentProject, AgentEngineInfo, CodexReasoningEffort } from 'chrome-mcp-shared';\nimport {\n  getModelsForCli,\n  getDefaultModelForCli,\n  getCodexReasoningEfforts,\n  type ModelDefinition,\n} from '@/common/agent-models';\n\nconst props = defineProps<{\n  open: boolean;\n  projects: AgentProject[];\n  selectedProjectId: string;\n  selectedCli: string;\n  model: string;\n  reasoningEffort: CodexReasoningEffort;\n  useCcr: boolean;\n  enableChromeMcp: boolean;\n  engines: AgentEngineInfo[];\n  isPicking: boolean;\n  isSaving: boolean;\n  error: string | null;\n}>();\n\nconst emit = defineEmits<{\n  'project:select': [projectId: string];\n  'project:new': [];\n  'cli:update': [cli: string];\n  'model:update': [model: string];\n  'reasoning-effort:update': [effort: CodexReasoningEffort];\n  'ccr:update': [useCcr: boolean];\n  'chrome-mcp:update': [enableChromeMcp: boolean];\n  save: [];\n}>();\n\n// Get available models based on selected CLI\nconst availableModels = computed<ModelDefinition[]>(() => {\n  return getModelsForCli(props.selectedCli);\n});\n\n// Normalize model value: ensure it exists in available models or fallback to empty\nconst normalizedModel = computed(() => {\n  const trimmedModel = props.model.trim();\n  if (!trimmedModel) return '';\n  // No CLI selected = model disabled, show empty (server will use default)\n  if (!props.selectedCli) return '';\n  const models = availableModels.value;\n  // If CLI selected but no models defined, fallback to empty\n  if (models.length === 0) return '';\n  // Check if current model is valid for selected CLI\n  const isValid = models.some((m) => m.id === trimmedModel);\n  return isValid ? trimmedModel : '';\n});\n\n// Check if Model select should be disabled\nconst isModelDisabled = computed(() => {\n  return !props.selectedCli || availableModels.value.length === 0;\n});\n\n// Show reasoning effort option only when Codex CLI is selected\nconst showReasoningEffortOption = computed(() => {\n  return props.selectedCli === 'codex';\n});\n\n// Get available reasoning efforts based on selected model\nconst availableReasoningEfforts = computed<readonly CodexReasoningEffort[]>(() => {\n  if (!showReasoningEffortOption.value) return [];\n  const effectiveModel = normalizedModel.value || getDefaultModelForCli('codex');\n  return getCodexReasoningEfforts(effectiveModel);\n});\n\n// Normalize reasoning effort value - fallback to highest supported\nconst normalizedReasoningEffort = computed(() => {\n  const supported = availableReasoningEfforts.value;\n  if (supported.length === 0) return props.reasoningEffort;\n  if (supported.includes(props.reasoningEffort)) return props.reasoningEffort;\n  // Fallback to highest supported effort (last in the sorted array)\n  return supported[supported.length - 1];\n});\n\n// Show CCR option only when Claude CLI is selected\nconst showCcrOption = computed(() => {\n  return props.selectedCli === 'claude';\n});\n\n// Show Chrome MCP option when Claude, Codex, or Auto (empty) CLI is selected\n// Auto typically defaults to Claude, and users should be able to manage this project-level setting\nconst showChromeMcpOption = computed(() => {\n  return !props.selectedCli || props.selectedCli === 'claude' || props.selectedCli === 'codex';\n});\n\n// Handle CLI change - auto-select default model for the CLI\nfunction handleCliChange(event: Event): void {\n  const cli = (event.target as HTMLSelectElement).value;\n  emit('cli:update', cli);\n\n  // Auto-select default model when CLI changes\n  if (cli) {\n    const defaultModel = getDefaultModelForCli(cli);\n    // Validate default model exists in available models\n    const models = getModelsForCli(cli);\n    const isValidDefault = models.some((m) => m.id === defaultModel);\n    emit('model:update', isValidDefault ? defaultModel : (models[0]?.id ?? ''));\n  } else {\n    emit('model:update', '');\n  }\n\n  // Reset CCR when switching away from Claude\n  if (cli !== 'claude') {\n    emit('ccr:update', false);\n  }\n}\n\nfunction handleCcrChange(event: Event): void {\n  emit('ccr:update', (event.target as HTMLInputElement).checked);\n}\n\nfunction handleChromeMcpChange(event: Event): void {\n  emit('chrome-mcp:update', (event.target as HTMLInputElement).checked);\n}\n\nfunction handleModelChange(event: Event): void {\n  const newModel = (event.target as HTMLSelectElement).value;\n  emit('model:update', newModel);\n\n  // When model changes for Codex, validate reasoning effort\n  if (props.selectedCli === 'codex') {\n    const supported = getCodexReasoningEfforts(newModel || getDefaultModelForCli('codex'));\n    if (!supported.includes(props.reasoningEffort)) {\n      // Auto-downgrade to highest supported effort\n      emit('reasoning-effort:update', supported[supported.length - 1]);\n    }\n  }\n}\n\nfunction handleReasoningEffortChange(event: Event): void {\n  emit(\n    'reasoning-effort:update',\n    (event.target as HTMLSelectElement).value as CodexReasoningEffort,\n  );\n}\n\nfunction handleSave(): void {\n  emit('save');\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentRequestThread.vue",
    "content": "<template>\n  <div ref=\"rootRef\" class=\"group\">\n    <!-- User Query Header -->\n    <div class=\"mb-4\">\n      <div class=\"flex justify-between items-start\">\n        <!-- Special rendering for web editor apply messages -->\n        <ApplyMessageChip v-if=\"thread.header?.webEditorApply\" :header=\"thread.header\" />\n\n        <!-- Default title rendering for regular messages -->\n        <h2\n          v-else\n          class=\"text-lg font-medium leading-snug\"\n          :style=\"{\n            color: 'var(--ac-text)',\n          }\"\n        >\n          {{ thread.title }}\n        </h2>\n\n        <!-- Edit button (placeholder, appears on hover) -->\n        <button\n          class=\"opacity-0 group-hover:opacity-100 transition-opacity p-1 cursor-pointer\"\n          :style=\"{ color: 'var(--ac-text-subtle)' }\"\n          title=\"Edit (coming soon)\"\n        >\n          <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <!-- Attachment thumbnails -->\n      <div v-if=\"thread.attachments.length > 0\" class=\"flex flex-wrap gap-2 mt-3\">\n        <button\n          v-for=\"attachment in thread.attachments\"\n          :key=\"`${attachment.messageId}:${attachment.index}`\"\n          type=\"button\"\n          class=\"relative group/thumb w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted)',\n            border: 'var(--ac-border-width) solid var(--ac-border)',\n          }\"\n          :title=\"attachment.originalName\"\n          @click=\"openViewer(attachment)\"\n        >\n          <img\n            v-if=\"getAttachmentUrl(attachment)\"\n            :src=\"getAttachmentUrl(attachment)!\"\n            :alt=\"attachment.originalName\"\n            class=\"w-full h-full object-cover\"\n            loading=\"lazy\"\n          />\n          <!-- Fallback placeholder when server not ready -->\n          <div\n            v-else\n            class=\"w-full h-full flex items-center justify-center\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n            title=\"Server not ready\"\n          >\n            <svg class=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n              />\n            </svg>\n          </div>\n\n          <!-- Filename overlay on hover -->\n          <div\n            class=\"absolute bottom-0 left-0 right-0 px-0.5 py-0.5 text-[8px] truncate opacity-0 group-hover/thumb:opacity-100 transition-opacity\"\n            :style=\"{\n              backgroundColor: 'rgba(0,0,0,0.6)',\n              color: 'white',\n            }\"\n          >\n            {{ attachment.originalName }}\n          </div>\n        </button>\n      </div>\n    </div>\n\n    <!-- Timeline -->\n    <AgentTimeline :items=\"thread.items\" :state=\"thread.state\" />\n\n    <!-- Image Viewer Modal (teleported to avoid stacking context issues) -->\n    <Teleport :to=\"overlayTarget\" :disabled=\"!overlayTarget\">\n      <div\n        v-if=\"viewerAttachment\"\n        class=\"fixed inset-0 z-50 flex items-center justify-center\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Image preview\"\n      >\n        <!-- Backdrop -->\n        <div class=\"absolute inset-0 bg-black/60\" @click=\"closeViewer\" />\n\n        <!-- Image container -->\n        <div\n          class=\"relative max-w-[92vw] max-h-[92vh] overflow-hidden\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface, #ffffff)',\n            border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n            borderRadius: 'var(--ac-radius-card, 12px)',\n            boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.2))',\n          }\"\n        >\n          <!-- Close button -->\n          <button\n            type=\"button\"\n            class=\"absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer\"\n            :style=\"{ color: 'white' }\"\n            aria-label=\"Close image preview\"\n            @click=\"closeViewer\"\n          >\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M6 18L18 6M6 6l12 12\"\n              />\n            </svg>\n          </button>\n\n          <!-- Full-size image -->\n          <img\n            v-if=\"viewerUrl\"\n            :src=\"viewerUrl\"\n            :alt=\"viewerAttachment.originalName\"\n            class=\"block max-w-[92vw] max-h-[92vh] object-contain\"\n          />\n          <div v-else class=\"p-6 text-sm\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n            Agent server not ready (missing server port).\n          </div>\n        </div>\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, inject, onMounted, ref, watch } from 'vue';\nimport type { AttachmentMetadata } from 'chrome-mcp-shared';\nimport type { AgentThread } from '../../composables/useAgentThreads';\nimport { AGENT_SERVER_PORT_KEY } from '../../composables';\nimport AgentTimeline from './AgentTimeline.vue';\nimport ApplyMessageChip from './ApplyMessageChip.vue';\n\nconst props = defineProps<{\n  thread: AgentThread;\n}>();\n\n// Inject server port from parent\nconst serverPort = inject(AGENT_SERVER_PORT_KEY, ref<number | null>(null));\n\n// Compute base URL for attachment requests\nconst baseUrl = computed(() => {\n  const port = serverPort.value;\n  if (!Number.isInteger(port) || port === null || port <= 0) return null;\n  return `http://127.0.0.1:${port}`;\n});\n\n/**\n * Build full URL for an attachment.\n * Ensures urlPath starts with / for proper concatenation.\n */\nfunction getAttachmentUrl(attachment: AttachmentMetadata): string | null {\n  const base = baseUrl.value;\n  if (!base) return null;\n  const path = attachment.urlPath.startsWith('/') ? attachment.urlPath : `/${attachment.urlPath}`;\n  return `${base}${path}`;\n}\n\n// Teleport target for modal overlay\nconst rootRef = ref<HTMLElement | null>(null);\nconst overlayTarget = ref<Element | null>(null);\n\n// Image viewer state\nconst viewerAttachment = ref<AttachmentMetadata | null>(null);\nconst viewerUrl = computed(() => {\n  if (!viewerAttachment.value) return null;\n  return getAttachmentUrl(viewerAttachment.value);\n});\n\nfunction openViewer(attachment: AttachmentMetadata): void {\n  viewerAttachment.value = attachment;\n}\n\nfunction closeViewer(): void {\n  viewerAttachment.value = null;\n}\n\n// Handle Escape key to close viewer\nfunction handleKeydown(e: KeyboardEvent): void {\n  if (e.key === 'Escape' && viewerAttachment.value) {\n    closeViewer();\n  }\n}\n\n// Register/unregister keyboard listener only when viewer is open\nwatch(\n  () => viewerAttachment.value,\n  (current, _prev, onCleanup) => {\n    if (!current) return;\n    document.addEventListener('keydown', handleKeydown);\n    onCleanup(() => document.removeEventListener('keydown', handleKeydown));\n  },\n);\n\nonMounted(() => {\n  // Find teleport target (agent-theme container or body)\n  overlayTarget.value =\n    rootRef.value?.closest('.agent-theme') ?? rootRef.value?.ownerDocument?.body ?? null;\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionListItem.vue",
    "content": "<template>\n  <div\n    class=\"group relative px-3 py-3 cursor-pointer transition-colors\"\n    :style=\"containerStyle\"\n    @click=\"handleClick\"\n  >\n    <!-- Main Content -->\n    <div class=\"flex items-start gap-3\">\n      <!-- Engine Badge (left side) -->\n      <div\n        class=\"flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold uppercase\"\n        :style=\"engineBadgeStyle\"\n      >\n        {{ engineAbbrev }}\n      </div>\n\n      <!-- Session Info (center) -->\n      <div class=\"flex-1 min-w-0\">\n        <!-- Title Row: Name + Model + Running Badge -->\n        <div class=\"flex items-center gap-2 mb-0.5\">\n          <!-- Inline Rename Input -->\n          <template v-if=\"isEditing\">\n            <input\n              ref=\"renameInputRef\"\n              v-model=\"editingName\"\n              type=\"text\"\n              class=\"flex-1 px-2 py-0.5 text-sm\"\n              :style=\"inputStyle\"\n              @click.stop\n              @keydown.enter=\"confirmRename\"\n              @keydown.escape=\"cancelRename\"\n              @blur=\"confirmRename\"\n            />\n          </template>\n          <!-- Display Name + Model -->\n          <template v-else>\n            <span class=\"text-sm font-medium truncate\" :style=\"titleStyle\">\n              {{ displayName }}\n            </span>\n            <!-- Model Badge -->\n            <span\n              v-if=\"session.model\"\n              class=\"flex-shrink-0 text-[10px] px-1.5 py-0.5 rounded\"\n              :style=\"modelBadgeStyle\"\n            >\n              {{ session.model }}\n            </span>\n            <!-- Running Badge -->\n            <span\n              v-if=\"isRunning\"\n              class=\"flex-shrink-0 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide animate-pulse\"\n              :style=\"runningBadgeStyle\"\n            >\n              Running\n            </span>\n          </template>\n        </div>\n\n        <!-- Preview (first message) - show chip for web editor apply, plain text otherwise -->\n        <div v-if=\"hasPreview\" class=\"mt-1\">\n          <!-- Web Editor Apply Chip Style -->\n          <div v-if=\"isWebEditorApplyPreview\" class=\"flex items-center gap-1 text-xs min-w-0\">\n            <span\n              class=\"flex-shrink-0 inline-flex items-center justify-center w-4 h-4 rounded\"\n              :style=\"previewChipIconStyle\"\n            >\n              <svg class=\"w-2.5 h-2.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\"\n                />\n              </svg>\n            </span>\n            <span class=\"flex-1 min-w-0 truncate\" :style=\"previewStyle\">\n              {{ previewDisplayText }}\n            </span>\n            <span\n              v-if=\"previewElementCount\"\n              class=\"flex-shrink-0 px-1 py-0.5 text-[9px] rounded\"\n              :style=\"previewChipBadgeStyle\"\n            >\n              {{ previewElementCount }}\n            </span>\n          </div>\n          <!-- Plain Text Preview -->\n          <div v-else class=\"text-xs truncate\" :style=\"previewStyle\">\n            {{ session.preview }}\n          </div>\n        </div>\n\n        <!-- Project Path -->\n        <div\n          v-if=\"displayProjectPath\"\n          class=\"mt-1 text-[10px] flex items-center gap-1 truncate\"\n          :style=\"{ color: 'var(--ac-text-subtle)', fontFamily: 'var(--ac-font-mono)' }\"\n          :title=\"projectPath\"\n        >\n          <svg\n            class=\"w-3 h-3 flex-shrink-0\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\"\n            />\n          </svg>\n          <span class=\"truncate\">{{ displayProjectPath }}</span>\n        </div>\n      </div>\n\n      <!-- Right side: Time + Actions (vertically stacked, right-aligned) -->\n      <div class=\"flex-shrink-0 flex flex-col items-end gap-1\">\n        <!-- Time -->\n        <span class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-subtle)' }\">\n          {{ formattedDate }}\n        </span>\n        <!-- Action buttons -->\n        <div class=\"flex items-center gap-1\">\n          <!-- Open Project Button -->\n          <button\n            v-if=\"!isEditing\"\n            class=\"p-1.5 rounded-md transition-colors cursor-pointer\"\n            :style=\"actionButtonStyle\"\n            title=\"Open project\"\n            @click.stop=\"handleOpenProject\"\n          >\n            <svg\n              class=\"w-3.5 h-3.5\"\n              fill=\"none\"\n              viewBox=\"0 0 24 24\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n            >\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"\n              />\n              <line x1=\"12\" y1=\"11\" x2=\"12\" y2=\"17\" />\n              <line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\" />\n            </svg>\n          </button>\n          <!-- Rename Button -->\n          <button\n            v-if=\"!isEditing\"\n            class=\"p-1.5 rounded-md transition-colors cursor-pointer\"\n            :style=\"actionButtonStyle\"\n            title=\"Rename\"\n            @click.stop=\"startRename\"\n          >\n            <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\"\n              />\n            </svg>\n          </button>\n          <!-- Delete Button -->\n          <button\n            v-if=\"!isEditing\"\n            class=\"p-1.5 rounded-md transition-colors cursor-pointer\"\n            :style=\"deleteButtonStyle\"\n            title=\"Delete\"\n            @click.stop=\"handleDelete\"\n          >\n            <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n              />\n            </svg>\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, nextTick, watch } from 'vue';\nimport type { AgentSession } from 'chrome-mcp-shared';\n\n// =============================================================================\n// Props & Emits\n// =============================================================================\n\nconst props = defineProps<{\n  session: AgentSession;\n  selected?: boolean;\n  isRunning?: boolean;\n  /** Project root path for display */\n  projectPath?: string;\n}>();\n\nconst emit = defineEmits<{\n  click: [sessionId: string];\n  rename: [sessionId: string, name: string];\n  delete: [sessionId: string];\n  'open-project': [sessionId: string];\n}>();\n\n// =============================================================================\n// Local State\n// =============================================================================\n\nconst isEditing = ref(false);\nconst editingName = ref('');\nconst renameInputRef = ref<HTMLInputElement | null>(null);\n\n// =============================================================================\n// Computed: Display Values\n// =============================================================================\n\nconst displayName = computed(() => {\n  if (props.session.name) return props.session.name;\n  return 'Unnamed Session';\n});\n\nconst engineAbbrev = computed(() => {\n  const name = props.session.engineName;\n  switch (name) {\n    case 'claude':\n      return 'CL';\n    case 'codex':\n      return 'CX';\n    case 'cursor':\n      return 'CR';\n    case 'qwen':\n      return 'QW';\n    case 'glm':\n      return 'GL';\n    default:\n      // Fallback for any unknown engine name\n      return (\n        String(name || '')\n          .slice(0, 2)\n          .toUpperCase() || 'AI'\n      );\n  }\n});\n\nconst formattedDate = computed(() => {\n  const date = new Date(props.session.updatedAt);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffMins < 1) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  if (diffDays < 7) return `${diffDays}d ago`;\n  return date.toLocaleDateString();\n});\n\n/**\n * Format project path for display.\n * Shows abbreviated path with home dir shortened.\n */\nconst displayProjectPath = computed(() => {\n  if (!props.projectPath) return '';\n  const path = props.projectPath;\n  // Abbreviate home directory for macOS/Linux\n  if (path.includes('/Users/')) {\n    return path.replace(/^\\/Users\\/[^/]+/, '~');\n  }\n  // Abbreviate home directory for Linux\n  if (path.startsWith('/home/')) {\n    return path.replace(/^\\/home\\/[^/]+/, '~');\n  }\n  return path;\n});\n\n// =============================================================================\n// Computed: Preview Chip (for web editor apply messages)\n// =============================================================================\n\nconst hasPreview = computed(() => !!props.session.preview || !!props.session.previewMeta);\n\nconst isWebEditorApplyPreview = computed(() => {\n  const meta = props.session.previewMeta;\n  if (!meta?.clientMeta?.kind) return false;\n  return (\n    meta.clientMeta.kind === 'web_editor_apply_batch' ||\n    meta.clientMeta.kind === 'web_editor_apply_single'\n  );\n});\n\nconst previewDisplayText = computed(() => {\n  const meta = props.session.previewMeta;\n  return meta?.displayText || props.session.preview || '';\n});\n\nconst previewElementCount = computed(() => {\n  const meta = props.session.previewMeta;\n  return meta?.clientMeta?.elementCount;\n});\n\n// =============================================================================\n// Computed: Styles\n// =============================================================================\n\nconst containerStyle = computed(() => ({\n  backgroundColor: props.selected ? 'var(--ac-hover-bg)' : 'transparent',\n  borderBottom: 'var(--ac-border-width) solid var(--ac-border)',\n}));\n\nconst engineBadgeStyle = computed(() => {\n  const colors: Record<string, string> = {\n    claude: '#c87941',\n    codex: '#10a37f',\n    cursor: '#8b5cf6',\n    qwen: '#6366f1',\n    glm: '#ef4444',\n  };\n  const bg = colors[props.session.engineName] || '#6b7280';\n  return {\n    backgroundColor: bg,\n    color: '#ffffff',\n  };\n});\n\nconst titleStyle = computed(() => ({\n  color: props.selected ? 'var(--ac-accent)' : 'var(--ac-text)',\n}));\n\nconst modelBadgeStyle = computed(() => ({\n  color: 'var(--ac-text-subtle)',\n  backgroundColor: 'var(--ac-surface-muted)',\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst previewStyle = computed(() => ({\n  color: 'var(--ac-text-muted)',\n}));\n\nconst previewChipIconStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent)',\n  color: 'var(--ac-accent-contrast)',\n}));\n\nconst previewChipBadgeStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text-muted)',\n}));\n\nconst runningBadgeStyle = computed(() => ({\n  backgroundColor: 'var(--ac-success)',\n  color: '#ffffff',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst inputStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  border: 'var(--ac-border-width) solid var(--ac-accent)',\n  borderRadius: 'var(--ac-radius-button)',\n  color: 'var(--ac-text)',\n  outline: 'none',\n}));\n\nconst actionButtonStyle = computed(() => ({\n  color: 'var(--ac-text-muted)',\n  backgroundColor: 'transparent',\n}));\n\nconst deleteButtonStyle = computed(() => ({\n  color: 'var(--ac-danger)',\n  backgroundColor: 'transparent',\n}));\n\n// =============================================================================\n// Event Handlers\n// =============================================================================\n\nfunction handleClick(): void {\n  if (isEditing.value) return;\n  emit('click', props.session.id);\n}\n\nfunction startRename(): void {\n  editingName.value = props.session.name || '';\n  isEditing.value = true;\n  nextTick(() => {\n    renameInputRef.value?.focus();\n    renameInputRef.value?.select();\n  });\n}\n\nfunction confirmRename(): void {\n  if (!isEditing.value) return;\n  const trimmed = editingName.value.trim();\n  if (trimmed && trimmed !== props.session.name) {\n    emit('rename', props.session.id, trimmed);\n  }\n  isEditing.value = false;\n}\n\nfunction cancelRename(): void {\n  isEditing.value = false;\n}\n\nfunction handleDelete(): void {\n  // Simple confirmation to prevent accidental deletion\n  const sessionName = props.session.name || props.session.preview || 'this session';\n  if (confirm(`Delete \"${sessionName}\"?`)) {\n    emit('delete', props.session.id);\n  }\n}\n\nfunction handleOpenProject(): void {\n  emit('open-project', props.session.id);\n}\n\n// Reset editing state when session changes\nwatch(\n  () => props.session.id,\n  () => {\n    isEditing.value = false;\n  },\n);\n</script>\n\n<style scoped>\n/* Hover effect for container */\n.group:hover {\n  background-color: var(--ac-hover-bg);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionMenu.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed top-12 left-4 right-4 z-50 py-2 max-w-[calc(100%-2rem)]\"\n    :style=\"{\n      backgroundColor: 'var(--ac-surface, #ffffff)',\n      border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      borderRadius: 'var(--ac-radius-inner, 8px)',\n      boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',\n    }\"\n  >\n    <!-- Sessions Section -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Sessions\n    </div>\n\n    <!-- Loading State -->\n    <div\n      v-if=\"isLoading\"\n      class=\"px-3 py-4 text-center text-xs\"\n      :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n    >\n      Loading sessions...\n    </div>\n\n    <!-- Empty State -->\n    <div\n      v-else-if=\"sessions.length === 0\"\n      class=\"px-3 py-4 text-center text-xs\"\n      :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n    >\n      No sessions yet\n    </div>\n\n    <!-- Session List -->\n    <div v-else class=\"max-h-[240px] overflow-y-auto ac-scroll\">\n      <div v-for=\"session in sessions\" :key=\"session.id\" class=\"group relative\">\n        <button\n          class=\"w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item\"\n          :style=\"{\n            color:\n              selectedSessionId === session.id\n                ? 'var(--ac-accent, #c87941)'\n                : 'var(--ac-text, #1a1a1a)',\n          }\"\n          @click=\"handleSessionSelect(session.id)\"\n        >\n          <div class=\"flex-1 min-w-0 pr-16\">\n            <!-- Session Name (inline editing) -->\n            <div class=\"truncate flex items-center gap-2\">\n              <template v-if=\"editingSessionId === session.id\">\n                <input\n                  ref=\"renameInputRef\"\n                  v-model=\"editingName\"\n                  type=\"text\"\n                  class=\"w-full px-1 py-0.5 text-sm\"\n                  :style=\"{\n                    backgroundColor: 'var(--ac-surface, #ffffff)',\n                    border: 'var(--ac-border-width, 1px) solid var(--ac-accent, #c87941)',\n                    borderRadius: 'var(--ac-radius-button, 8px)',\n                    color: 'var(--ac-text, #1a1a1a)',\n                    outline: 'none',\n                  }\"\n                  @click.stop\n                  @keydown.enter=\"confirmRename(session.id)\"\n                  @keydown.escape=\"cancelRename\"\n                  @blur=\"confirmRename(session.id)\"\n                />\n              </template>\n              <template v-else>\n                <span>{{ getSessionDisplayName(session) }}</span>\n                <span\n                  class=\"text-[10px] px-1.5 py-0.5\"\n                  :style=\"{\n                    backgroundColor: getEngineColor(session.engineName),\n                    color: '#ffffff',\n                    borderRadius: 'var(--ac-radius-button, 8px)',\n                  }\"\n                >\n                  {{ session.engineName }}\n                </span>\n              </template>\n            </div>\n            <!-- Session Info -->\n            <div\n              class=\"text-[10px] truncate flex items-center gap-2\"\n              :style=\"{\n                fontFamily: 'var(--ac-font-mono, monospace)',\n                color: 'var(--ac-text-subtle, #a8a29e)',\n              }\"\n            >\n              <span v-if=\"session.model\">{{ session.model }}</span>\n              <span>{{ formatDate(session.updatedAt) }}</span>\n            </div>\n          </div>\n\n          <!-- Action Buttons (shown on hover) -->\n          <div\n            class=\"absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\"\n          >\n            <!-- Rename Button -->\n            <button\n              v-if=\"editingSessionId !== session.id\"\n              class=\"p-1 ac-btn cursor-pointer\"\n              :style=\"{\n                color: 'var(--ac-text-muted, #6e6e6e)',\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              title=\"Rename session\"\n              @click.stop=\"startRename(session)\"\n            >\n              <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\"\n                />\n              </svg>\n            </button>\n            <!-- Delete Button -->\n            <button\n              class=\"p-1 ac-btn cursor-pointer\"\n              :style=\"{\n                color: 'var(--ac-danger, #dc2626)',\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              title=\"Delete session\"\n              @click.stop=\"handleDeleteSession(session.id)\"\n            >\n              <svg class=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n                />\n              </svg>\n            </button>\n          </div>\n\n          <!-- Selected Check -->\n          <svg\n            v-if=\"selectedSessionId === session.id\"\n            class=\"w-4 h-4 flex-shrink-0\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M5 13l4 4L19 7\"\n            />\n          </svg>\n        </button>\n      </div>\n    </div>\n\n    <!-- New Session Button -->\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm ac-menu-item\"\n      :style=\"{ color: 'var(--ac-link, #3b82f6)' }\"\n      :disabled=\"isCreating\"\n      @click=\"handleNewSession\"\n    >\n      {{ isCreating ? 'Creating...' : '+ New Session' }}\n    </button>\n\n    <!-- Error -->\n    <div v-if=\"error\" class=\"px-3 py-1 text-[10px]\" :style=\"{ color: 'var(--ac-danger, #dc2626)' }\">\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, nextTick } from 'vue';\nimport type { AgentSession } from 'chrome-mcp-shared';\n\nconst props = defineProps<{\n  open: boolean;\n  sessions: AgentSession[];\n  selectedSessionId: string;\n  isLoading: boolean;\n  isCreating: boolean;\n  error: string | null;\n}>();\n\nconst emit = defineEmits<{\n  'session:select': [sessionId: string];\n  'session:new': [];\n  'session:delete': [sessionId: string];\n  'session:rename': [sessionId: string, name: string];\n}>();\n\n// Inline rename state\nconst editingSessionId = ref<string | null>(null);\nconst editingName = ref('');\nconst renameInputRef = ref<HTMLInputElement | null>(null);\n\nfunction getEngineColor(engineName: string): string {\n  const colors: Record<string, string> = {\n    claude: '#c87941',\n    codex: '#10a37f',\n    cursor: '#8b5cf6',\n    qwen: '#6366f1',\n    glm: '#ef4444',\n  };\n  return colors[engineName] || '#6b7280';\n}\n\n/**\n * Get display name for a session.\n * Priority: preview (first user message) > name > 'Unnamed Session'\n */\nfunction getSessionDisplayName(session: AgentSession): string {\n  // Use preview if available (first user message)\n  if (session.preview) {\n    return session.preview;\n  }\n  // Fall back to session name\n  if (session.name) {\n    return session.name;\n  }\n  return 'Unnamed Session';\n}\n\nfunction formatDate(dateStr: string): string {\n  const date = new Date(dateStr);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n  const diffHours = Math.floor(diffMins / 60);\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffMins < 1) return 'just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  if (diffHours < 24) return `${diffHours}h ago`;\n  if (diffDays < 7) return `${diffDays}d ago`;\n  return date.toLocaleDateString();\n}\n\nfunction handleSessionSelect(sessionId: string): void {\n  // Don't select if we're editing\n  if (editingSessionId.value) return;\n  emit('session:select', sessionId);\n}\n\nfunction handleNewSession(): void {\n  emit('session:new');\n}\n\nfunction handleDeleteSession(sessionId: string): void {\n  if (confirm('Delete this session? This cannot be undone.')) {\n    emit('session:delete', sessionId);\n  }\n}\n\n// Inline rename handlers\nfunction startRename(session: AgentSession): void {\n  editingSessionId.value = session.id;\n  editingName.value = session.name || '';\n  nextTick(() => {\n    renameInputRef.value?.focus();\n    renameInputRef.value?.select();\n  });\n}\n\nfunction confirmRename(sessionId: string): void {\n  const trimmedName = editingName.value.trim();\n  if (trimmedName && editingSessionId.value === sessionId) {\n    emit('session:rename', sessionId, trimmedName);\n  }\n  cancelRename();\n}\n\nfunction cancelRename(): void {\n  editingSessionId.value = null;\n  editingName.value = '';\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionSettingsPanel.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed inset-0 z-50 flex items-center justify-center\"\n    @click.self=\"handleClose\"\n  >\n    <!-- Backdrop -->\n    <div class=\"absolute inset-0 bg-black/40\" />\n\n    <!-- Panel -->\n    <div\n      class=\"relative w-full max-w-md mx-4 max-h-[85vh] overflow-hidden flex flex-col\"\n      :style=\"{\n        backgroundColor: 'var(--ac-surface, #ffffff)',\n        border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n        borderRadius: 'var(--ac-radius-card, 12px)',\n        boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.2))',\n      }\"\n    >\n      <!-- Header -->\n      <div\n        class=\"flex items-center justify-between px-4 py-3\"\n        :style=\"{ borderBottom: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)' }\"\n      >\n        <h2 class=\"text-sm font-semibold\" :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">\n          Session Settings\n        </h2>\n        <button\n          class=\"p-1 ac-btn\"\n          :style=\"{\n            color: 'var(--ac-text-muted, #6e6e6e)',\n            borderRadius: 'var(--ac-radius-button)',\n          }\"\n          @click=\"handleClose\"\n        >\n          <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M6 18L18 6M6 6l12 12\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      <!-- Content (scrollable) -->\n      <div class=\"flex-1 overflow-y-auto ac-scroll px-4 py-3 space-y-4\">\n        <!-- Loading State -->\n        <div v-if=\"isLoading\" class=\"py-8 text-center\">\n          <div class=\"text-sm\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n            Loading session info...\n          </div>\n        </div>\n\n        <template v-else>\n          <!-- Session Info -->\n          <div class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              Session Info\n            </label>\n            <div class=\"text-xs space-y-1\" :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">\n              <div class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Engine</span>\n                <span\n                  class=\"px-1.5 py-0.5 text-[10px]\"\n                  :style=\"{\n                    backgroundColor: getEngineColor(session?.engineName || ''),\n                    color: '#ffffff',\n                    borderRadius: 'var(--ac-radius-button, 8px)',\n                  }\"\n                >\n                  {{ session?.engineName || 'Unknown' }}\n                </span>\n              </div>\n              <div v-if=\"localModel\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Model</span>\n                <span class=\"font-mono text-[10px]\">{{ localModel }}</span>\n              </div>\n              <div v-if=\"session?.engineSessionId\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Engine Session</span>\n                <span class=\"font-mono text-[10px] truncate max-w-[180px]\">{{\n                  session.engineSessionId\n                }}</span>\n              </div>\n            </div>\n          </div>\n\n          <!-- Model Selection -->\n          <div class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              Model\n            </label>\n            <select\n              v-model=\"localModel\"\n              class=\"w-full px-2 py-1.5 text-xs\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface, #ffffff)',\n                border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n                borderRadius: 'var(--ac-radius-button, 8px)',\n                color: 'var(--ac-text, #1a1a1a)',\n              }\"\n            >\n              <option value=\"\">Default (server setting)</option>\n              <option v-for=\"m in availableModels\" :key=\"m.id\" :value=\"m.id\">\n                {{ m.name }}\n              </option>\n            </select>\n          </div>\n\n          <!-- Reasoning Effort (Codex only) -->\n          <div v-if=\"isCodexEngine\" class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              Reasoning Effort\n            </label>\n            <select\n              v-model=\"localReasoningEffort\"\n              class=\"w-full px-2 py-1.5 text-xs\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface, #ffffff)',\n                border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n                borderRadius: 'var(--ac-radius-button, 8px)',\n                color: 'var(--ac-text, #1a1a1a)',\n              }\"\n            >\n              <option v-for=\"effort in availableReasoningEfforts\" :key=\"effort\" :value=\"effort\">\n                {{ effort }}\n              </option>\n            </select>\n            <p class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\">\n              Controls the reasoning depth. Higher effort = better quality but slower.\n              <span v-if=\"!availableReasoningEfforts.includes('xhigh')\" class=\"block mt-1\">\n                Note: xhigh is only available for gpt-5.2 and gpt-5.1-codex-max models.\n              </span>\n            </p>\n          </div>\n\n          <!-- Permission Mode (Claude only) -->\n          <div v-if=\"isClaudeEngine\" class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              Permission Mode\n            </label>\n            <select\n              v-model=\"localPermissionMode\"\n              class=\"w-full px-2 py-1.5 text-xs\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface, #ffffff)',\n                border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n                borderRadius: 'var(--ac-radius-button, 8px)',\n                color: 'var(--ac-text, #1a1a1a)',\n              }\"\n            >\n              <option value=\"\">Default</option>\n              <option value=\"default\">default - Ask for approval</option>\n              <option value=\"acceptEdits\">acceptEdits - Auto-accept file edits</option>\n              <option value=\"bypassPermissions\">bypassPermissions - Auto-accept all</option>\n              <option value=\"plan\">plan - Plan mode only</option>\n              <option value=\"dontAsk\">dontAsk - No confirmation</option>\n            </select>\n            <p class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\">\n              Controls how the Claude SDK handles tool approval requests.\n            </p>\n          </div>\n\n          <!-- System Prompt Config (Claude only) -->\n          <div v-if=\"isClaudeEngine\" class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              System Prompt\n            </label>\n            <div class=\"space-y-2\">\n              <label class=\"flex items-center gap-2 text-xs cursor-pointer\">\n                <input\n                  type=\"radio\"\n                  :checked=\"!localUseCustomPrompt\"\n                  @change=\"localUseCustomPrompt = false\"\n                />\n                <span :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">Use preset (claude_code)</span>\n              </label>\n              <div v-if=\"!localUseCustomPrompt\" class=\"pl-5\">\n                <label class=\"flex items-center gap-2 text-[10px]\">\n                  <input v-model=\"localAppendToPrompt\" type=\"checkbox\" />\n                  <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n                    >Append custom text</span\n                  >\n                </label>\n                <textarea\n                  v-if=\"localAppendToPrompt\"\n                  v-model=\"localPromptAppend\"\n                  class=\"mt-1 w-full px-2 py-1.5 text-xs resize-none\"\n                  :style=\"{\n                    backgroundColor: 'var(--ac-surface, #ffffff)',\n                    border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n                    borderRadius: 'var(--ac-radius-button, 8px)',\n                    color: 'var(--ac-text, #1a1a1a)',\n                    fontFamily: 'var(--ac-font-mono, monospace)',\n                  }\"\n                  rows=\"3\"\n                  placeholder=\"Additional instructions to append...\"\n                />\n              </div>\n              <label class=\"flex items-center gap-2 text-xs cursor-pointer\">\n                <input\n                  type=\"radio\"\n                  :checked=\"localUseCustomPrompt\"\n                  @change=\"localUseCustomPrompt = true\"\n                />\n                <span :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">Use custom prompt</span>\n              </label>\n              <textarea\n                v-if=\"localUseCustomPrompt\"\n                v-model=\"localCustomPrompt\"\n                class=\"w-full px-2 py-1.5 text-xs resize-none\"\n                :style=\"{\n                  backgroundColor: 'var(--ac-surface, #ffffff)',\n                  border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n                  borderRadius: 'var(--ac-radius-button, 8px)',\n                  color: 'var(--ac-text, #1a1a1a)',\n                  fontFamily: 'var(--ac-font-mono, monospace)',\n                }\"\n                rows=\"4\"\n                placeholder=\"Enter custom system prompt...\"\n              />\n            </div>\n          </div>\n\n          <!-- Management Info (Claude only, read-only) -->\n          <div v-if=\"isClaudeEngine && managementInfo\" class=\"space-y-2\">\n            <label\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              SDK Info\n            </label>\n            <div\n              class=\"text-[10px] space-y-1 p-2\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface-inset, #f5f5f5)',\n                borderRadius: 'var(--ac-radius-inner, 8px)',\n              }\"\n            >\n              <div v-if=\"managementInfo.model\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Active Model</span>\n                <span class=\"font-mono\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">{{\n                  managementInfo.model\n                }}</span>\n              </div>\n              <div v-if=\"managementInfo.claudeCodeVersion\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Claude Code</span>\n                <span class=\"font-mono\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">{{\n                  managementInfo.claudeCodeVersion\n                }}</span>\n              </div>\n              <div v-if=\"managementInfo.tools?.length\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">Tools</span>\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">{{\n                  managementInfo.tools.length\n                }}</span>\n              </div>\n              <div v-if=\"managementInfo.mcpServers?.length\" class=\"flex justify-between\">\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">MCP Servers</span>\n                <span :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">{{\n                  managementInfo.mcpServers.length\n                }}</span>\n              </div>\n            </div>\n            <!-- Tool List (expandable) -->\n            <details v-if=\"managementInfo.tools?.length\" class=\"text-[10px]\">\n              <summary class=\"cursor-pointer\" :style=\"{ color: 'var(--ac-link, #3b82f6)' }\">\n                View tools ({{ managementInfo.tools.length }})\n              </summary>\n              <div\n                class=\"mt-1 p-2 max-h-32 overflow-y-auto ac-scroll\"\n                :style=\"{\n                  backgroundColor: 'var(--ac-surface-inset, #f5f5f5)',\n                  borderRadius: 'var(--ac-radius-inner, 8px)',\n                }\"\n              >\n                <div\n                  v-for=\"tool in managementInfo.tools\"\n                  :key=\"tool\"\n                  class=\"font-mono truncate\"\n                  :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n                >\n                  {{ tool }}\n                </div>\n              </div>\n            </details>\n            <!-- MCP Server List (expandable) -->\n            <details v-if=\"managementInfo.mcpServers?.length\" class=\"text-[10px]\">\n              <summary class=\"cursor-pointer\" :style=\"{ color: 'var(--ac-link, #3b82f6)' }\">\n                View MCP servers ({{ managementInfo.mcpServers.length }})\n              </summary>\n              <div\n                class=\"mt-1 p-2 max-h-32 overflow-y-auto ac-scroll\"\n                :style=\"{\n                  backgroundColor: 'var(--ac-surface-inset, #f5f5f5)',\n                  borderRadius: 'var(--ac-radius-inner, 8px)',\n                }\"\n              >\n                <div\n                  v-for=\"server in managementInfo.mcpServers\"\n                  :key=\"server.name\"\n                  class=\"font-mono truncate flex justify-between gap-2\"\n                  :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n                >\n                  <span>{{ server.name }}</span>\n                  <span\n                    class=\"text-[9px] px-1\"\n                    :style=\"{\n                      backgroundColor: server.status === 'connected' ? '#10b981' : '#6b7280',\n                      color: '#fff',\n                      borderRadius: 'var(--ac-radius-button, 8px)',\n                    }\"\n                    >{{ server.status }}</span\n                  >\n                </div>\n              </div>\n            </details>\n          </div>\n        </template>\n      </div>\n\n      <!-- Footer -->\n      <div\n        class=\"flex items-center justify-end gap-2 px-4 py-3\"\n        :style=\"{ borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)' }\"\n      >\n        <button\n          class=\"px-3 py-1.5 text-xs ac-btn\"\n          :style=\"{\n            color: 'var(--ac-text-muted, #6e6e6e)',\n            border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n            borderRadius: 'var(--ac-radius-button, 8px)',\n          }\"\n          @click=\"handleClose\"\n        >\n          Cancel\n        </button>\n        <button\n          class=\"px-3 py-1.5 text-xs ac-btn\"\n          :style=\"{\n            backgroundColor: 'var(--ac-accent, #c87941)',\n            color: 'var(--ac-accent-contrast, #ffffff)',\n            borderRadius: 'var(--ac-radius-button, 8px)',\n          }\"\n          :disabled=\"isSaving\"\n          @click=\"handleSave\"\n        >\n          {{ isSaving ? 'Saving...' : 'Save' }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, watch } from 'vue';\nimport type {\n  AgentSession,\n  AgentManagementInfo,\n  AgentSystemPromptConfig,\n  CodexReasoningEffort,\n  AgentSessionOptionsConfig,\n} from 'chrome-mcp-shared';\nimport {\n  getModelsForCli,\n  getCodexReasoningEfforts,\n  getDefaultModelForCli,\n} from '@/common/agent-models';\n\nconst props = defineProps<{\n  open: boolean;\n  session: AgentSession | null;\n  managementInfo: AgentManagementInfo | null;\n  isLoading: boolean;\n  isSaving: boolean;\n}>();\n\nconst emit = defineEmits<{\n  close: [];\n  save: [settings: SessionSettings];\n}>();\n\nexport interface SessionSettings {\n  model: string;\n  permissionMode: string;\n  systemPromptConfig: AgentSystemPromptConfig | null;\n  optionsConfig?: AgentSessionOptionsConfig;\n}\n\n// Local state\nconst localModel = ref('');\nconst localPermissionMode = ref('');\nconst localReasoningEffort = ref<CodexReasoningEffort>('medium');\nconst localUseCustomPrompt = ref(false);\nconst localCustomPrompt = ref('');\nconst localAppendToPrompt = ref(false);\nconst localPromptAppend = ref('');\n\n// Computed\nconst isClaudeEngine = computed(() => props.session?.engineName === 'claude');\nconst isCodexEngine = computed(() => props.session?.engineName === 'codex');\n\n// Get available reasoning efforts based on selected model\nconst availableReasoningEfforts = computed<readonly CodexReasoningEffort[]>(() => {\n  if (!isCodexEngine.value) return [];\n  const effectiveModel = localModel.value || getDefaultModelForCli('codex');\n  return getCodexReasoningEfforts(effectiveModel);\n});\n\n// Normalize reasoning effort when model changes\nconst normalizedReasoningEffort = computed(() => {\n  const supported = availableReasoningEfforts.value;\n  if (supported.length === 0) return localReasoningEffort.value;\n  if (supported.includes(localReasoningEffort.value)) return localReasoningEffort.value;\n  return supported[supported.length - 1]; // fallback to highest supported\n});\n\nconst availableModels = computed(() => {\n  if (!props.session?.engineName) return [];\n  return getModelsForCli(props.session.engineName);\n});\n\n// Initialize local state when session changes\nwatch(\n  () => props.session,\n  (session) => {\n    if (session) {\n      localModel.value = session.model || '';\n      localPermissionMode.value = session.permissionMode || '';\n\n      // Initialize reasoning effort from session's codex config\n      const codexConfig = session.optionsConfig?.codexConfig;\n      if (codexConfig?.reasoningEffort) {\n        localReasoningEffort.value = codexConfig.reasoningEffort;\n      } else {\n        localReasoningEffort.value = 'medium';\n      }\n\n      // Parse system prompt config based on type\n      const config = session.systemPromptConfig;\n      if (config) {\n        if (config.type === 'custom') {\n          localUseCustomPrompt.value = true;\n          localCustomPrompt.value = config.text || '';\n          localAppendToPrompt.value = false;\n          localPromptAppend.value = '';\n        } else if (config.type === 'preset') {\n          localUseCustomPrompt.value = false;\n          localCustomPrompt.value = '';\n          localAppendToPrompt.value = !!config.append;\n          localPromptAppend.value = config.append || '';\n        }\n      } else {\n        localUseCustomPrompt.value = false;\n        localCustomPrompt.value = '';\n        localAppendToPrompt.value = false;\n        localPromptAppend.value = '';\n      }\n    }\n  },\n  { immediate: true },\n);\n\n// Auto-adjust reasoning effort when model changes\nwatch(localModel, () => {\n  if (isCodexEngine.value) {\n    localReasoningEffort.value = normalizedReasoningEffort.value;\n  }\n});\n\nfunction getEngineColor(engineName: string): string {\n  const colors: Record<string, string> = {\n    claude: '#c87941',\n    codex: '#10a37f',\n    cursor: '#8b5cf6',\n    qwen: '#6366f1',\n    glm: '#ef4444',\n  };\n  return colors[engineName] || '#6b7280';\n}\n\nfunction handleClose(): void {\n  emit('close');\n}\n\nfunction handleSave(): void {\n  // Build systemPromptConfig based on local state\n  let systemPromptConfig: AgentSystemPromptConfig | null = null;\n\n  if (localUseCustomPrompt.value && localCustomPrompt.value.trim()) {\n    systemPromptConfig = {\n      type: 'custom',\n      text: localCustomPrompt.value.trim(),\n    };\n  } else if (localAppendToPrompt.value && localPromptAppend.value.trim()) {\n    systemPromptConfig = {\n      type: 'preset',\n      preset: 'claude_code',\n      append: localPromptAppend.value.trim(),\n    };\n  } else {\n    // Use default preset without append\n    systemPromptConfig = {\n      type: 'preset',\n      preset: 'claude_code',\n    };\n  }\n\n  // Build optionsConfig for Codex engine\n  let optionsConfig: AgentSessionOptionsConfig | undefined;\n  if (isCodexEngine.value) {\n    const existingOptions = props.session?.optionsConfig ?? {};\n    const existingCodexConfig = existingOptions.codexConfig ?? {};\n    optionsConfig = {\n      ...existingOptions,\n      codexConfig: {\n        ...existingCodexConfig,\n        reasoningEffort: normalizedReasoningEffort.value,\n      },\n    };\n  }\n\n  const settings: SessionSettings = {\n    model: localModel.value.trim(),\n    permissionMode: localPermissionMode.value,\n    systemPromptConfig,\n    optionsConfig,\n  };\n  emit('save', settings);\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSessionsView.vue",
    "content": "<template>\n  <div class=\"h-full flex flex-col\" :style=\"containerStyle\">\n    <!-- Header: Search + New Button -->\n    <div class=\"flex-shrink-0 px-4 py-3 border-b\" :style=\"headerStyle\">\n      <div class=\"flex items-center gap-2\">\n        <!-- Search Input -->\n        <div class=\"flex-1 relative\">\n          <svg\n            class=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"\n            />\n          </svg>\n          <input\n            v-model=\"searchQuery\"\n            type=\"text\"\n            placeholder=\"Search sessions...\"\n            class=\"w-full pl-9 pr-3 py-2 text-sm\"\n            :style=\"inputStyle\"\n          />\n        </div>\n\n        <!-- New Session Button -->\n        <button\n          class=\"flex-shrink-0 px-3 py-2 text-sm font-medium cursor-pointer\"\n          :style=\"newButtonStyle\"\n          :disabled=\"isCreating\"\n          @click=\"handleNewSession\"\n        >\n          <span v-if=\"isCreating\">Creating...</span>\n          <span v-else class=\"flex items-center gap-1\">\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M12 4v16m8-8H4\"\n              />\n            </svg>\n            New\n          </span>\n        </button>\n      </div>\n    </div>\n\n    <!-- Sessions List -->\n    <div class=\"flex-1 overflow-y-auto ac-scroll\">\n      <!-- Loading State -->\n      <div\n        v-if=\"isLoading\"\n        class=\"flex items-center justify-center py-12\"\n        :style=\"{ color: 'var(--ac-text-muted)' }\"\n      >\n        <span class=\"text-sm\">Loading sessions...</span>\n      </div>\n\n      <!-- Empty State -->\n      <div\n        v-else-if=\"filteredSessions.length === 0\"\n        class=\"flex flex-col items-center justify-center py-12 px-4\"\n      >\n        <div\n          class=\"w-16 h-16 rounded-full flex items-center justify-center mb-4\"\n          :style=\"{ backgroundColor: 'var(--ac-surface-muted)' }\"\n        >\n          <svg\n            class=\"w-8 h-8\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"1.5\"\n              d=\"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z\"\n            />\n          </svg>\n        </div>\n        <div class=\"text-sm font-medium mb-1\" :style=\"{ color: 'var(--ac-text)' }\">\n          {{ searchQuery ? 'No matching sessions' : 'No sessions yet' }}\n        </div>\n        <div class=\"text-xs text-center mb-4\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n          {{ searchQuery ? 'Try a different search term' : 'Start a new conversation with AI' }}\n        </div>\n        <button\n          v-if=\"!searchQuery\"\n          class=\"px-4 py-2 text-sm font-medium cursor-pointer\"\n          :style=\"newButtonStyle\"\n          @click=\"handleNewSession\"\n        >\n          Start New Session\n        </button>\n      </div>\n\n      <!-- Session Items -->\n      <div v-else>\n        <AgentSessionListItem\n          v-for=\"session in filteredSessions\"\n          :key=\"session.id\"\n          :session=\"session\"\n          :project-path=\"getProjectPath(session)\"\n          :selected=\"selectedSessionId === session.id\"\n          :is-running=\"isSessionRunning(session.id)\"\n          @click=\"handleSessionClick\"\n          @rename=\"handleSessionRename\"\n          @delete=\"handleSessionDelete\"\n          @open-project=\"handleSessionOpenProject\"\n        />\n      </div>\n    </div>\n\n    <!-- Error Message -->\n    <div\n      v-if=\"error\"\n      class=\"flex-shrink-0 px-4 py-2 text-xs\"\n      :style=\"{ color: 'var(--ac-danger)', backgroundColor: 'var(--ac-surface-muted)' }\"\n    >\n      {{ error }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed } from 'vue';\nimport type { AgentSession, AgentProject } from 'chrome-mcp-shared';\nimport AgentSessionListItem from './AgentSessionListItem.vue';\n\n// =============================================================================\n// Props & Emits\n// =============================================================================\n\nconst props = defineProps<{\n  sessions: AgentSession[];\n  selectedSessionId: string;\n  isLoading: boolean;\n  isCreating: boolean;\n  error: string | null;\n  /**\n   * Map of sessionId -> running status.\n   * Used to display running badge on sessions with active executions.\n   */\n  runningSessionIds?: Set<string>;\n  /**\n   * Map of projectId -> AgentProject for looking up project paths.\n   * Used to display project path for each session.\n   */\n  projectsMap?: Map<string, AgentProject>;\n}>();\n\nconst emit = defineEmits<{\n  'session:select': [sessionId: string];\n  'session:new': [];\n  'session:delete': [sessionId: string];\n  'session:rename': [sessionId: string, name: string];\n  'session:open-project': [sessionId: string];\n}>();\n\n// =============================================================================\n// Local State\n// =============================================================================\n\nconst searchQuery = ref('');\n\n// =============================================================================\n// Computed\n// =============================================================================\n\n/**\n * Filter sessions by search query.\n * Searches in: name, preview, model, engineName\n */\nconst filteredSessions = computed(() => {\n  const query = searchQuery.value.toLowerCase().trim();\n  if (!query) {\n    // Sort by updatedAt descending (most recent first)\n    return [...props.sessions].sort(\n      (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),\n    );\n  }\n\n  return props.sessions\n    .filter((session) => {\n      const searchFields = [\n        session.name || '',\n        session.preview || '',\n        session.model || '',\n        session.engineName || '',\n      ]\n        .join(' ')\n        .toLowerCase();\n\n      return searchFields.includes(query);\n    })\n    .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());\n});\n\n// =============================================================================\n// Computed: Styles\n// =============================================================================\n\nconst containerStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n}));\n\nconst headerStyle = computed(() => ({\n  borderColor: 'var(--ac-border)',\n  backgroundColor: 'var(--ac-surface)',\n}));\n\nconst inputStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-button)',\n  color: 'var(--ac-text)',\n  outline: 'none',\n}));\n\nconst newButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent)',\n  color: 'var(--ac-accent-contrast)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\n// =============================================================================\n// Methods\n// =============================================================================\n\nfunction isSessionRunning(sessionId: string): boolean {\n  return props.runningSessionIds?.has(sessionId) ?? false;\n}\n\n/**\n * Get the project root path for a session.\n */\nfunction getProjectPath(session: AgentSession): string | undefined {\n  return props.projectsMap?.get(session.projectId)?.rootPath;\n}\n\nfunction handleSessionClick(sessionId: string): void {\n  emit('session:select', sessionId);\n}\n\nfunction handleNewSession(): void {\n  emit('session:new');\n}\n\nfunction handleSessionRename(sessionId: string, name: string): void {\n  emit('session:rename', sessionId, name);\n}\n\nfunction handleSessionDelete(sessionId: string): void {\n  emit('session:delete', sessionId);\n}\n\nfunction handleSessionOpenProject(sessionId: string): void {\n  emit('session:open-project', sessionId);\n}\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentSettingsMenu.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed top-12 right-4 z-50 min-w-[180px] py-2\"\n    :style=\"{\n      backgroundColor: 'var(--ac-surface, #ffffff)',\n      border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      borderRadius: 'var(--ac-radius-inner, 8px)',\n      boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',\n    }\"\n  >\n    <!-- Theme Section -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Theme\n    </div>\n\n    <button\n      v-for=\"t in themes\"\n      :key=\"t.id\"\n      class=\"w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item\"\n      :style=\"{\n        color: theme === t.id ? 'var(--ac-accent, #c87941)' : 'var(--ac-text, #1a1a1a)',\n      }\"\n      @click=\"$emit('theme:set', t.id)\"\n    >\n      <span>{{ t.label }}</span>\n      <svg\n        v-if=\"theme === t.id\"\n        class=\"w-4 h-4\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n      </svg>\n    </button>\n\n    <!-- Divider -->\n    <div\n      class=\"my-2\"\n      :style=\"{\n        borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      }\"\n    />\n\n    <!-- Input Section -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Input\n    </div>\n\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item\"\n      :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\"\n      @click=\"$emit('fakeCaret:toggle', !fakeCaretEnabled)\"\n    >\n      <span>Comet caret</span>\n      <svg\n        v-if=\"fakeCaretEnabled\"\n        class=\"w-4 h-4\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\" />\n      </svg>\n    </button>\n\n    <!-- Divider -->\n    <div\n      class=\"my-2\"\n      :style=\"{\n        borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      }\"\n    />\n\n    <!-- Storage Section -->\n    <div\n      class=\"px-3 py-1 text-[10px] font-bold uppercase tracking-wider\"\n      :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n    >\n      Storage\n    </div>\n\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm ac-menu-item\"\n      :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\"\n      @click=\"$emit('attachments:open')\"\n    >\n      Clear Attachment Cache\n    </button>\n\n    <!-- Divider -->\n    <div\n      class=\"my-2\"\n      :style=\"{\n        borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n      }\"\n    />\n\n    <!-- Reconnect -->\n    <button\n      class=\"w-full px-3 py-2 text-left text-sm ac-menu-item\"\n      :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\"\n      @click=\"$emit('reconnect')\"\n    >\n      Reconnect Server\n    </button>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { type AgentThemeId, THEME_LABELS } from '../../composables';\n\ndefineProps<{\n  open: boolean;\n  theme: AgentThemeId;\n  /** Fake caret (comet effect) enabled state */\n  fakeCaretEnabled?: boolean;\n}>();\n\ndefineEmits<{\n  'theme:set': [theme: AgentThemeId];\n  reconnect: [];\n  'attachments:open': [];\n  'fakeCaret:toggle': [enabled: boolean];\n}>();\n\nconst themes: { id: AgentThemeId; label: string }[] = [\n  { id: 'warm-editorial', label: THEME_LABELS['warm-editorial'] },\n  { id: 'blueprint-architect', label: THEME_LABELS['blueprint-architect'] },\n  { id: 'zen-journal', label: THEME_LABELS['zen-journal'] },\n  { id: 'neo-pop', label: THEME_LABELS['neo-pop'] },\n  { id: 'dark-console', label: THEME_LABELS['dark-console'] },\n  { id: 'swiss-grid', label: THEME_LABELS['swiss-grid'] },\n];\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTimeline.vue",
    "content": "<template>\n  <div class=\"pl-1 space-y-3\">\n    <!-- Timeline container -->\n    <div class=\"relative pl-5 space-y-4 ml-1\">\n      <AgentTimelineItem\n        v-for=\"(item, index) in items\"\n        :key=\"item.id\"\n        :item=\"item\"\n        :is-last=\"index === items.length - 1\"\n      />\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { TimelineItem, AgentThreadState } from '../../composables/useAgentThreads';\nimport AgentTimelineItem from './AgentTimelineItem.vue';\n\ndefineProps<{\n  items: TimelineItem[];\n  state: AgentThreadState;\n}>();\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTimelineItem.vue",
    "content": "<template>\n  <div class=\"relative group/step\">\n    <!-- Timeline Node: Loading icon for running status, colored dot otherwise -->\n    <template v-if=\"showLoadingIcon\">\n      <!-- Loading scribble icon for running/starting status -->\n      <svg\n        class=\"absolute loading-scribble flex-shrink-0\"\n        :style=\"{\n          left: '-24px',\n          top: nodeTopOffset,\n          width: '14px',\n          height: '14px',\n        }\"\n        viewBox=\"0 0 100 100\"\n        fill=\"none\"\n      >\n        <path\n          d=\"M50 50 C50 48, 52 46, 54 46 C58 46, 60 50, 60 54 C60 60, 54 64, 48 64 C40 64, 36 56, 36 48 C36 38, 44 32, 54 32 C66 32, 74 42, 74 54 C74 68, 62 78, 48 78 C32 78, 22 64, 22 48 C22 30, 36 18, 54 18 C74 18, 88 34, 88 54 C88 76, 72 92, 50 92\"\n          stroke=\"var(--ac-accent, #D97757)\"\n          stroke-width=\"8\"\n          stroke-linecap=\"round\"\n        />\n      </svg>\n    </template>\n    <template v-else>\n      <!-- Colored dot -->\n      <span\n        class=\"absolute w-2 h-2 rounded-full transition-colors\"\n        :style=\"{\n          left: '-20px',\n          top: nodeTopOffset,\n          backgroundColor: nodeColor,\n          boxShadow: isStreaming ? 'var(--ac-timeline-node-pulse-shadow)' : 'none',\n        }\"\n        :class=\"{ 'ac-pulse': isStreaming }\"\n      />\n    </template>\n\n    <!-- Content based on item kind -->\n    <TimelineUserPromptStep v-if=\"item.kind === 'user_prompt'\" :item=\"item\" />\n    <TimelineNarrativeStep v-else-if=\"item.kind === 'assistant_text'\" :item=\"item\" />\n    <TimelineToolCallStep v-else-if=\"item.kind === 'tool_use'\" :item=\"item\" />\n    <TimelineToolResultCardStep v-else-if=\"item.kind === 'tool_result'\" :item=\"item\" />\n    <TimelineStatusStep\n      v-else-if=\"item.kind === 'status'\"\n      :item=\"item\"\n      :hide-icon=\"showLoadingIcon\"\n    />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { TimelineItem } from '../../composables/useAgentThreads';\nimport TimelineUserPromptStep from './timeline/TimelineUserPromptStep.vue';\nimport TimelineNarrativeStep from './timeline/TimelineNarrativeStep.vue';\nimport TimelineToolCallStep from './timeline/TimelineToolCallStep.vue';\nimport TimelineToolResultCardStep from './timeline/TimelineToolResultCardStep.vue';\nimport TimelineStatusStep from './timeline/TimelineStatusStep.vue';\n\nconst props = defineProps<{\n  item: TimelineItem;\n  /** Whether this is the last item in the timeline */\n  isLast?: boolean;\n}>();\n\nconst isStreaming = computed(() => {\n  if (props.item.kind === 'assistant_text' || props.item.kind === 'tool_use') {\n    return props.item.isStreaming;\n  }\n  if (props.item.kind === 'status') {\n    return props.item.status === 'running' || props.item.status === 'starting';\n  }\n  return false;\n});\n\n// Show loading icon for status items that are running/starting\nconst showLoadingIcon = computed(() => {\n  if (props.item.kind === 'status') {\n    return props.item.status === 'running' || props.item.status === 'starting';\n  }\n  return false;\n});\n\n// Calculate top offset based on item type to align with first line of text\nconst nodeTopOffset = computed(() => {\n  // user_prompt and assistant_text have py-1 (4px) + text-sm leading-relaxed\n  if (props.item.kind === 'user_prompt' || props.item.kind === 'assistant_text') {\n    return '12px';\n  }\n  // tool_use/tool_result have items-baseline with text-[11px]\n  if (props.item.kind === 'tool_use' || props.item.kind === 'tool_result') {\n    return '6px';\n  }\n  // status has flex items-center with text-xs (12px line-height ~18px)\n  // For loading icon (14px), center it: (18-14)/2 = 2px\n  if (props.item.kind === 'status') {\n    return '2px';\n  }\n  return '7px';\n});\n\nconst nodeColor = computed(() => {\n  // Active/streaming node\n  if (isStreaming.value) {\n    return 'var(--ac-timeline-node-active)';\n  }\n\n  // Tool result nodes - success/error colors\n  if (props.item.kind === 'tool_result') {\n    if (props.item.isError) {\n      return 'var(--ac-danger)';\n    }\n    return 'var(--ac-success)';\n  }\n\n  // Tool use nodes - use tool color\n  if (props.item.kind === 'tool_use') {\n    return 'var(--ac-timeline-node-tool)';\n  }\n\n  // Assistant text - use accent color\n  if (props.item.kind === 'assistant_text') {\n    return 'var(--ac-timeline-node-active)';\n  }\n\n  // User prompt - slightly stronger than default node for visual distinction\n  if (props.item.kind === 'user_prompt') {\n    return 'var(--ac-timeline-node-hover)';\n  }\n\n  // Status nodes (completed/error/cancelled) - use muted color\n  if (props.item.kind === 'status') {\n    return 'var(--ac-timeline-node)';\n  }\n\n  // Default node color\n  return 'var(--ac-timeline-node)';\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AgentTopBar.vue",
    "content": "<template>\n  <div class=\"flex items-center justify-between w-full\">\n    <!-- Brand / Context -->\n    <div class=\"flex items-center gap-2 overflow-hidden -ml-1\">\n      <!-- Back Button (when in chat view) -->\n      <button\n        v-if=\"showBackButton\"\n        class=\"flex items-center justify-center w-8 h-8 flex-shrink-0 ac-btn\"\n        :style=\"{\n          color: 'var(--ac-text-muted)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n        title=\"Back to sessions\"\n        @click=\"$emit('back')\"\n      >\n        <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M15 19l-7-7 7-7\"\n          />\n        </svg>\n      </button>\n\n      <!-- Brand -->\n      <h1\n        class=\"text-lg font-medium tracking-tight flex-shrink-0\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-heading)',\n          color: 'var(--ac-text)',\n        }\"\n      >\n        {{ brandLabel || 'Agent' }}\n      </h1>\n\n      <!-- Divider -->\n      <div\n        class=\"h-4 w-[1px] flex-shrink-0\"\n        :style=\"{ backgroundColor: 'var(--ac-border-strong)' }\"\n      />\n\n      <!-- Project Breadcrumb -->\n      <button\n        class=\"flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-mono)',\n          color: 'var(--ac-text-muted)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n        @click=\"$emit('toggle:projectMenu')\"\n      >\n        <span class=\"truncate\">{{ projectLabel }}</span>\n        <svg\n          class=\"w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M19 9l-7 7-7-7\"\n          />\n        </svg>\n      </button>\n\n      <!-- Session Breadcrumb -->\n      <div class=\"h-3 w-[1px] flex-shrink-0\" :style=\"{ backgroundColor: 'var(--ac-border)' }\" />\n      <button\n        class=\"flex items-center gap-1.5 text-xs px-2 py-1 truncate group ac-btn\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-mono)',\n          color: 'var(--ac-text-subtle)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n        @click=\"$emit('toggle:sessionMenu')\"\n      >\n        <span class=\"truncate\">{{ sessionLabel }}</span>\n        <svg\n          class=\"w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M19 9l-7 7-7-7\"\n          />\n        </svg>\n      </button>\n    </div>\n\n    <!-- Connection / Status / Settings -->\n    <div class=\"flex items-center gap-3\">\n      <!-- Connection Indicator -->\n      <div class=\"flex items-center gap-1.5\" :title=\"connectionText\">\n        <span\n          class=\"w-2 h-2 rounded-full\"\n          :style=\"{\n            backgroundColor: connectionColor,\n            boxShadow: connectionState === 'ready' ? `0 0 8px ${connectionColor}` : 'none',\n          }\"\n        />\n      </div>\n\n      <!-- Open Project Button -->\n      <button\n        class=\"p-1 ac-btn ac-hover-text\"\n        :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n        title=\"Open project in VS Code or Terminal\"\n        @click=\"$emit('toggle:openProjectMenu')\"\n      >\n        <svg\n          class=\"w-5 h-5\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n        >\n          <path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\" />\n          <line x1=\"12\" y1=\"11\" x2=\"12\" y2=\"17\" />\n          <line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\" />\n        </svg>\n      </button>\n\n      <!-- Theme & Settings Icon (Color Palette) -->\n      <button\n        class=\"p-1 ac-btn ac-hover-text\"\n        :style=\"{ color: 'var(--ac-text-subtle)', borderRadius: 'var(--ac-radius-button)' }\"\n        @click=\"$emit('toggle:settingsMenu')\"\n      >\n        <svg\n          class=\"w-5 h-5\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n        >\n          <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" />\n          <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" />\n          <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />\n          <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" />\n          <path\n            d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z\"\n          />\n        </svg>\n      </button>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\n\nexport type ConnectionState = 'ready' | 'connecting' | 'disconnected';\n\nconst props = defineProps<{\n  projectLabel: string;\n  sessionLabel: string;\n  connectionState: ConnectionState;\n  /** Whether to show back button (for returning to sessions list) */\n  showBackButton?: boolean;\n  /** Brand label to display (e.g., \"Claude Code\", \"Codex\") */\n  brandLabel?: string;\n}>();\n\ndefineEmits<{\n  'toggle:projectMenu': [];\n  'toggle:sessionMenu': [];\n  'toggle:settingsMenu': [];\n  'toggle:openProjectMenu': [];\n  /** Emitted when back button is clicked */\n  back: [];\n}>();\n\nconst connectionColor = computed(() => {\n  switch (props.connectionState) {\n    case 'ready':\n      return 'var(--ac-success)';\n    case 'connecting':\n      return 'var(--ac-warning)';\n    default:\n      return 'var(--ac-text-subtle)';\n  }\n});\n\nconst connectionText = computed(() => {\n  switch (props.connectionState) {\n    case 'ready':\n      return 'Connected';\n    case 'connecting':\n      return 'Connecting...';\n    default:\n      return 'Disconnected';\n  }\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ApplyMessageChip.vue",
    "content": "<template>\n  <div\n    ref=\"chipRef\"\n    class=\"inline-flex items-center gap-1.5 text-sm leading-none cursor-default\"\n    @mouseenter=\"handleMouseEnter\"\n    @mouseleave=\"handleMouseLeave\"\n  >\n    <!-- Icon -->\n    <span\n      class=\"inline-flex items-center justify-center w-5 h-5 rounded\"\n      :style=\"iconContainerStyle\"\n    >\n      <svg\n        class=\"w-3.5 h-3.5\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n        aria-hidden=\"true\"\n      >\n        <path\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          stroke-width=\"2\"\n          d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\"\n        />\n      </svg>\n    </span>\n\n    <!-- Label -->\n    <span class=\"font-medium\" :style=\"{ color: 'var(--ac-text)' }\">\n      {{ displayText }}\n    </span>\n\n    <!-- Element count badge -->\n    <span\n      v-if=\"elementCount\"\n      class=\"px-1.5 py-0.5 text-[10px] font-medium rounded\"\n      :style=\"badgeStyle\"\n    >\n      {{ elementCount }} element{{ elementCount === 1 ? '' : 's' }}\n    </span>\n\n    <!-- Expand icon (hint for hover) -->\n    <svg\n      class=\"w-3.5 h-3.5 opacity-50\"\n      fill=\"none\"\n      viewBox=\"0 0 24 24\"\n      stroke=\"currentColor\"\n      :style=\"{ color: 'var(--ac-text-subtle)' }\"\n    >\n      <path\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        stroke-width=\"2\"\n        d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n      />\n    </svg>\n  </div>\n\n  <!-- Tooltip - Teleported to agent-theme root -->\n  <Teleport :to=\"tooltipTarget\" :disabled=\"!tooltipTarget\">\n    <Transition name=\"apply-tooltip-fade\">\n      <div\n        v-if=\"showTooltip\"\n        class=\"fixed\"\n        :style=\"tooltipPositionStyle\"\n        role=\"tooltip\"\n        @mouseenter=\"handleTooltipEnter\"\n        @mouseleave=\"handleTooltipLeave\"\n      >\n        <div class=\"px-3 py-2.5 text-[11px] space-y-2\" :style=\"tooltipStyle\">\n          <!-- Header -->\n          <div class=\"flex items-center justify-between gap-4\">\n            <span class=\"font-semibold\" :style=\"{ color: 'var(--ac-text)' }\">\n              Web Editor Apply\n            </span>\n            <span\n              v-if=\"pageUrl\"\n              class=\"text-[10px] truncate max-w-[200px]\"\n              :style=\"{ color: 'var(--ac-text-subtle)', fontFamily: 'var(--ac-font-mono)' }\"\n            >\n              {{ pageHostname }}\n            </span>\n          </div>\n\n          <!-- Element labels -->\n          <div v-if=\"elementLabels && elementLabels.length > 0\" class=\"space-y-1\">\n            <div class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n              Modified elements:\n            </div>\n            <div class=\"flex flex-wrap gap-1\">\n              <span\n                v-for=\"(label, i) in displayLabels\"\n                :key=\"i\"\n                class=\"px-1.5 py-0.5 text-[10px] rounded\"\n                :style=\"elementLabelStyle\"\n              >\n                {{ label }}\n              </span>\n              <span\n                v-if=\"remainingCount > 0\"\n                class=\"px-1.5 py-0.5 text-[10px] rounded\"\n                :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              >\n                +{{ remainingCount }} more\n              </span>\n            </div>\n          </div>\n\n          <!-- Prompt preview (truncated) -->\n          <div class=\"space-y-1\">\n            <div class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n              Prompt preview:\n            </div>\n            <pre\n              class=\"text-[10px] max-h-[100px] overflow-auto whitespace-pre-wrap break-all p-2 rounded\"\n              :style=\"preStyle\"\n              >{{ truncatedPrompt }}</pre\n            >\n          </div>\n        </div>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, onBeforeUnmount, onMounted } from 'vue';\nimport type { ThreadHeader, WebEditorApplyMeta } from '../../composables';\n\nconst props = defineProps<{\n  header: ThreadHeader;\n}>();\n\n// Refs\nconst chipRef = ref<HTMLElement | null>(null);\nconst chipRect = ref<DOMRect | null>(null);\nconst tooltipTarget = ref<Element | null>(null);\n\n// Hover/visibility state with delayed hide for better UX\nconst isTooltipOpen = ref(false);\nconst isHoveringChip = ref(false);\nconst isHoveringTooltip = ref(false);\n\nconst HIDE_DELAY_MS = 180;\nlet hideTimeout: ReturnType<typeof setTimeout> | null = null;\n\n// Computed\nconst webEditorApply = computed<WebEditorApplyMeta | undefined>(() => props.header.webEditorApply);\nconst displayText = computed(() => props.header.displayText || 'Apply changes');\nconst elementCount = computed(() => webEditorApply.value?.elementCount);\nconst elementLabels = computed(() => webEditorApply.value?.elementLabels || []);\nconst pageUrl = computed(() => webEditorApply.value?.pageUrl);\n\nconst pageHostname = computed(() => {\n  if (!pageUrl.value) return '';\n  try {\n    return new URL(pageUrl.value).hostname;\n  } catch {\n    return pageUrl.value;\n  }\n});\n\nconst displayLabels = computed(() => elementLabels.value.slice(0, 4));\nconst remainingCount = computed(() => Math.max(0, elementLabels.value.length - 4));\n\nconst truncatedPrompt = computed(() => {\n  const full = props.header.fullContent;\n  const maxLen = 500;\n  if (full.length <= maxLen) return full;\n  return full.slice(0, maxLen) + '...';\n});\n\nconst showTooltip = computed(() => isTooltipOpen.value);\n\n// Styles\nconst iconContainerStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent)',\n  color: 'var(--ac-accent-contrast)',\n}));\n\nconst badgeStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text-muted)',\n}));\n\nconst tooltipStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-card)',\n  boxShadow: 'var(--ac-shadow-float)',\n  color: 'var(--ac-text)',\n  minWidth: '280px',\n  maxWidth: '400px',\n}));\n\nconst elementLabelStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text)',\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst preStyle = computed(() => ({\n  backgroundColor: 'var(--ac-code-bg)',\n  color: 'var(--ac-code-text)',\n  fontFamily: 'var(--ac-font-mono)',\n  border: 'var(--ac-border-width) solid var(--ac-code-border)',\n}));\n\nconst tooltipPositionStyle = computed(() => {\n  const rect = chipRect.value;\n  if (!rect) {\n    return { opacity: 0, zIndex: 9999 };\n  }\n\n  const tooltipWidth = 360;\n  const gap = 8;\n  let left = rect.left;\n  const viewportWidth = window.innerWidth;\n  const padding = 8;\n\n  if (left + tooltipWidth > viewportWidth - padding) {\n    left = viewportWidth - tooltipWidth - padding;\n  }\n  if (left < padding) {\n    left = padding;\n  }\n\n  return {\n    left: `${left}px`,\n    top: `${rect.bottom + gap}px`,\n    zIndex: 9999,\n  };\n});\n\n// Event handlers\nfunction updateChipRect(): void {\n  if (chipRef.value) {\n    chipRect.value = chipRef.value.getBoundingClientRect();\n  }\n}\n\nfunction clearHideTimeout(): void {\n  if (hideTimeout !== null) {\n    clearTimeout(hideTimeout);\n    hideTimeout = null;\n  }\n}\n\nfunction openTooltip(): void {\n  clearHideTimeout();\n  isTooltipOpen.value = true;\n}\n\nfunction scheduleCloseTooltip(): void {\n  clearHideTimeout();\n  hideTimeout = setTimeout(() => {\n    if (!isHoveringChip.value && !isHoveringTooltip.value) {\n      isTooltipOpen.value = false;\n    }\n  }, HIDE_DELAY_MS);\n}\n\nfunction handleMouseEnter(): void {\n  updateChipRect();\n  isHoveringChip.value = true;\n  openTooltip();\n}\n\nfunction handleMouseLeave(): void {\n  isHoveringChip.value = false;\n  scheduleCloseTooltip();\n}\n\nfunction handleTooltipEnter(): void {\n  isHoveringTooltip.value = true;\n  openTooltip();\n}\n\nfunction handleTooltipLeave(): void {\n  isHoveringTooltip.value = false;\n  scheduleCloseTooltip();\n}\n\n// Lifecycle\nonMounted(() => {\n  if (chipRef.value) {\n    const agentTheme = chipRef.value.closest('.agent-theme');\n    tooltipTarget.value = agentTheme ?? document.body;\n  }\n});\n\nonBeforeUnmount(() => {\n  clearHideTimeout();\n});\n</script>\n\n<style>\n/* Tooltip transition - unique name to avoid conflicts with ElementChip */\n.apply-tooltip-fade-enter-active,\n.apply-tooltip-fade-leave-active {\n  transition:\n    opacity 0.15s ease,\n    transform 0.15s ease;\n}\n\n.apply-tooltip-fade-enter-from,\n.apply-tooltip-fade-leave-to {\n  opacity: 0;\n  transform: translateY(-4px);\n}\n\n.apply-tooltip-fade-enter-to,\n.apply-tooltip-fade-leave-from {\n  opacity: 1;\n  transform: translateY(0);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/AttachmentCachePanel.vue",
    "content": "<template>\n  <div\n    v-if=\"open\"\n    class=\"fixed inset-0 z-50 flex items-center justify-center\"\n    role=\"dialog\"\n    aria-modal=\"true\"\n    aria-label=\"Attachment cache management\"\n    @click.self=\"handleClose\"\n  >\n    <!-- Backdrop -->\n    <div class=\"absolute inset-0 bg-black/40\" />\n\n    <!-- Panel -->\n    <div\n      class=\"relative w-full max-w-2xl mx-4 max-h-[85vh] overflow-hidden flex flex-col\"\n      :style=\"{\n        backgroundColor: 'var(--ac-surface, #ffffff)',\n        border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n        borderRadius: 'var(--ac-radius-card, 12px)',\n        boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.2))',\n      }\"\n    >\n      <!-- Header -->\n      <div\n        class=\"flex items-start justify-between px-4 py-3 gap-3\"\n        :style=\"{ borderBottom: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)' }\"\n      >\n        <div class=\"min-w-0\">\n          <h2 class=\"text-sm font-semibold\" :style=\"{ color: 'var(--ac-text, #1a1a1a)' }\">\n            Attachment Cache\n          </h2>\n          <p class=\"text-[10px] mt-0.5\" :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\">\n            Manage cached images stored on disk by the agent server.\n          </p>\n        </div>\n\n        <div class=\"flex items-center gap-2 flex-shrink-0\">\n          <!-- Refresh button -->\n          <button\n            type=\"button\"\n            class=\"p-1 ac-btn\"\n            :disabled=\"!serverReady || isLoading || isClearing\"\n            :style=\"{\n              color: 'var(--ac-text-muted, #6e6e6e)',\n              borderRadius: 'var(--ac-radius-button, 8px)',\n              opacity: !serverReady || isLoading || isClearing ? 0.6 : 1,\n            }\"\n            title=\"Refresh\"\n            @click=\"refresh\"\n          >\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M4 4v6h6M20 20v-6h-6M20 8a8 8 0 00-14.828-2M4 16a8 8 0 0014.828 2\"\n              />\n            </svg>\n          </button>\n\n          <!-- Close button -->\n          <button\n            type=\"button\"\n            class=\"p-1 ac-btn\"\n            :style=\"{\n              color: 'var(--ac-text-muted, #6e6e6e)',\n              borderRadius: 'var(--ac-radius-button, 8px)',\n            }\"\n            aria-label=\"Close\"\n            @click=\"handleClose\"\n          >\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M6 18L18 6M6 6l12 12\"\n              />\n            </svg>\n          </button>\n        </div>\n      </div>\n\n      <!-- Content -->\n      <div class=\"flex-1 overflow-y-auto ac-scroll px-4 py-3 space-y-4\">\n        <!-- Server not ready -->\n        <div v-if=\"!serverReady\" class=\"py-10 text-center\">\n          <div class=\"text-sm\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n            Agent server not ready.\n          </div>\n          <div class=\"text-[10px] mt-1\" :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\">\n            Start or reconnect the server, then open this panel again.\n          </div>\n        </div>\n\n        <!-- Loading -->\n        <div v-else-if=\"isLoading && !stats\" class=\"py-10 text-center\">\n          <div\n            class=\"inline-flex items-center gap-2 text-sm\"\n            :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\"\n          >\n            <svg class=\"w-4 h-4 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <circle\n                class=\"opacity-25\"\n                cx=\"12\"\n                cy=\"12\"\n                r=\"10\"\n                stroke=\"currentColor\"\n                stroke-width=\"4\"\n              />\n              <path\n                class=\"opacity-75\"\n                fill=\"currentColor\"\n                d=\"M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z\"\n              />\n            </svg>\n            Loading attachment stats...\n          </div>\n        </div>\n\n        <!-- Error -->\n        <div v-else-if=\"errorMessage\" class=\"space-y-3\">\n          <div\n            class=\"px-4 py-3 text-xs rounded-lg\"\n            :style=\"{\n              backgroundColor: 'var(--ac-diff-del-bg)',\n              color: 'var(--ac-danger)',\n              border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',\n              borderRadius: 'var(--ac-radius-inner)',\n            }\"\n          >\n            {{ errorMessage }}\n          </div>\n          <div class=\"flex items-center gap-2\">\n            <button\n              type=\"button\"\n              class=\"px-3 py-2 text-xs font-medium cursor-pointer\"\n              :style=\"{\n                backgroundColor: 'var(--ac-chip-bg)',\n                color: 'var(--ac-chip-text)',\n                border: 'var(--ac-border-width) solid var(--ac-chip-border)',\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              :disabled=\"isLoading || isClearing\"\n              @click=\"refresh\"\n            >\n              Retry\n            </button>\n            <button\n              type=\"button\"\n              class=\"px-3 py-2 text-xs font-medium cursor-pointer\"\n              :style=\"{\n                backgroundColor: 'transparent',\n                color: 'var(--ac-text-muted)',\n                borderRadius: 'var(--ac-radius-button)',\n              }\"\n              @click=\"handleClose\"\n            >\n              Close\n            </button>\n          </div>\n        </div>\n\n        <!-- Loaded -->\n        <template v-else-if=\"stats\">\n          <!-- Summary -->\n          <div class=\"grid grid-cols-2 gap-3\">\n            <div\n              class=\"px-3 py-2 rounded-lg\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface-muted)',\n                border: 'var(--ac-border-width) solid var(--ac-border)',\n              }\"\n            >\n              <div\n                class=\"text-[10px] font-bold uppercase tracking-wider\"\n                :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              >\n                Total Size\n              </div>\n              <div class=\"text-sm font-semibold\" :style=\"{ color: 'var(--ac-text)' }\">\n                {{ formatBytes(totalBytes) }}\n              </div>\n              <div class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n                {{ totalFiles.toLocaleString() }} files\n              </div>\n            </div>\n\n            <div\n              class=\"px-3 py-2 rounded-lg\"\n              :style=\"{\n                backgroundColor: 'var(--ac-surface-muted)',\n                border: 'var(--ac-border-width) solid var(--ac-border)',\n              }\"\n            >\n              <div\n                class=\"text-[10px] font-bold uppercase tracking-wider\"\n                :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              >\n                Root Directory\n              </div>\n              <div\n                class=\"text-[11px] font-mono truncate\"\n                :style=\"{ color: 'var(--ac-text)' }\"\n                :title=\"stats.rootDir\"\n              >\n                {{ stats.rootDir || '-' }}\n              </div>\n              <div\n                v-if=\"orphanProjectIds.length > 0\"\n                class=\"text-[10px] mt-0.5\"\n                :style=\"{ color: 'var(--ac-text-subtle)' }\"\n              >\n                {{ orphanProjectIds.length }} orphan project{{\n                  orphanProjectIds.length === 1 ? '' : 's'\n                }}\n              </div>\n            </div>\n          </div>\n\n          <!-- Selection Controls -->\n          <div class=\"flex items-center justify-between gap-3\">\n            <div\n              class=\"text-[10px] font-bold uppercase tracking-wider\"\n              :style=\"{ color: 'var(--ac-text-subtle, #a8a29e)' }\"\n            >\n              Projects\n            </div>\n\n            <div class=\"flex items-center gap-1.5 flex-wrap justify-end\">\n              <button\n                type=\"button\"\n                class=\"px-2 py-1 text-[11px] font-medium cursor-pointer\"\n                :style=\"chipStyle\"\n                :disabled=\"isClearing || selectableProjectIds.length === 0\"\n                @click=\"selectAll\"\n              >\n                Select all\n              </button>\n              <button\n                type=\"button\"\n                class=\"px-2 py-1 text-[11px] font-medium cursor-pointer\"\n                :style=\"chipStyle\"\n                :disabled=\"isClearing || selectableProjectIds.length === 0\"\n                @click=\"invertSelection\"\n              >\n                Invert\n              </button>\n              <button\n                type=\"button\"\n                class=\"px-2 py-1 text-[11px] font-medium cursor-pointer\"\n                :style=\"chipStyle\"\n                :disabled=\"isClearing || selectedCount === 0\"\n                @click=\"clearSelection\"\n              >\n                Clear\n              </button>\n            </div>\n          </div>\n\n          <!-- Project List -->\n          <div v-if=\"projectsSorted.length === 0\" class=\"py-8 text-center\">\n            <div class=\"text-sm\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n              No attachment data found.\n            </div>\n          </div>\n\n          <div v-else class=\"space-y-2\">\n            <div\n              v-for=\"p in projectsSorted\"\n              :key=\"p.projectId\"\n              class=\"flex items-start gap-3 px-3 py-2 rounded-lg\"\n              :style=\"{\n                backgroundColor: 'var(--ac-hover-bg-subtle)',\n                border: 'var(--ac-border-width) solid var(--ac-border)',\n                opacity: isClearing ? 0.7 : 1,\n              }\"\n            >\n              <input\n                type=\"checkbox\"\n                class=\"mt-0.5\"\n                :checked=\"isSelected(p.projectId)\"\n                :disabled=\"isClearing || !isSelectable(p)\"\n                :style=\"{ accentColor: 'var(--ac-accent)' }\"\n                @change=\"toggleProject(p.projectId)\"\n              />\n\n              <div class=\"flex-1 min-w-0\">\n                <div class=\"flex items-center gap-2 min-w-0\">\n                  <div\n                    class=\"text-xs font-medium truncate\"\n                    :style=\"{ color: 'var(--ac-text)' }\"\n                    :title=\"projectTitle(p)\"\n                  >\n                    {{ projectTitle(p) }}\n                  </div>\n                  <span\n                    v-if=\"isOrphanProject(p.projectId)\"\n                    class=\"text-[10px] px-1.5 py-0.5 rounded\"\n                    :style=\"{\n                      backgroundColor: 'var(--ac-accent-subtle)',\n                      color: 'var(--ac-text)',\n                    }\"\n                  >\n                    orphan\n                  </span>\n                  <span\n                    v-if=\"!p.exists\"\n                    class=\"text-[10px] px-1.5 py-0.5 rounded\"\n                    :style=\"{\n                      backgroundColor: 'var(--ac-chip-bg)',\n                      color: 'var(--ac-text-muted)',\n                      border: 'var(--ac-border-width) solid var(--ac-chip-border)',\n                    }\"\n                  >\n                    missing\n                  </span>\n                </div>\n\n                <div\n                  class=\"text-[10px] mt-0.5 flex flex-wrap items-center gap-2\"\n                  :style=\"{ color: 'var(--ac-text-subtle)' }\"\n                >\n                  <span>{{ p.fileCount.toLocaleString() }} files</span>\n                  <span class=\"opacity-50\">&middot;</span>\n                  <span>{{ formatBytes(p.totalBytes) }}</span>\n                </div>\n              </div>\n\n              <div class=\"text-right flex-shrink-0\">\n                <div class=\"text-[11px] font-mono\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n                  {{ formatBytes(p.totalBytes) }}\n                </div>\n              </div>\n            </div>\n          </div>\n        </template>\n      </div>\n\n      <!-- Footer -->\n      <div\n        class=\"flex-none px-4 py-3 flex items-center justify-between gap-3\"\n        :style=\"{ borderTop: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)' }\"\n      >\n        <div class=\"text-[10px] min-w-0\" :style=\"{ color: 'var(--ac-text-subtle)' }\">\n          <span v-if=\"statusMessage\">{{ statusMessage }}</span>\n          <span v-else> Select projects to remove cached attachment files from disk. </span>\n        </div>\n\n        <button\n          type=\"button\"\n          class=\"px-3 py-2 text-xs font-semibold rounded-lg flex-shrink-0 cursor-pointer\"\n          :disabled=\"!canClear\"\n          :style=\"clearButtonStyle\"\n          @click=\"clearSelected\"\n        >\n          {{ isClearing ? 'Clearing...' : `Clear Selected (${selectedCount})` }}\n        </button>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, inject, onUnmounted, ref, watch } from 'vue';\nimport type {\n  AttachmentCleanupResponse,\n  AttachmentProjectStats,\n  AttachmentStatsResponse,\n} from 'chrome-mcp-shared';\nimport { AGENT_SERVER_PORT_KEY } from '../../composables';\n\nconst props = defineProps<{\n  open: boolean;\n}>();\n\nconst emit = defineEmits<{\n  close: [];\n}>();\n\n// Inject server port from parent\nconst serverPort = inject(AGENT_SERVER_PORT_KEY, ref<number | null>(null));\n\n// Compute base URL for API requests\nconst baseUrl = computed(() => {\n  const port = serverPort.value;\n  if (port === null) return null;\n  if (!Number.isInteger(port) || port <= 0) return null;\n  return `http://127.0.0.1:${port}`;\n});\n\nconst serverReady = computed(() => baseUrl.value !== null);\n\n// State\nconst stats = ref<AttachmentStatsResponse | null>(null);\nconst isLoading = ref(false);\nconst isClearing = ref(false);\nconst errorMessage = ref<string | null>(null);\nconst statusMessage = ref<string | null>(null);\nconst selectedProjectIds = ref<Set<string>>(new Set());\n\n// Derived state\nconst totalBytes = computed(() => stats.value?.totalBytes ?? 0);\nconst totalFiles = computed(() => stats.value?.totalFiles ?? 0);\nconst orphanProjectIds = computed(() => stats.value?.orphanProjectIds ?? []);\nconst projects = computed(() => stats.value?.projects ?? []);\n\nconst projectsSorted = computed<AttachmentProjectStats[]>(() => {\n  return [...projects.value].sort((a, b) => (b.totalBytes ?? 0) - (a.totalBytes ?? 0));\n});\n\n/**\n * Check if a project can be selected (has files that exist).\n */\nfunction isSelectable(p: AttachmentProjectStats): boolean {\n  return p.exists === true && p.fileCount > 0;\n}\n\nconst selectableProjectIds = computed(() =>\n  projectsSorted.value.filter(isSelectable).map((p) => p.projectId),\n);\n\nconst selectedCount = computed(() => selectedProjectIds.value.size);\n\nconst canClear = computed(() => {\n  return serverReady.value && !isLoading.value && !isClearing.value && selectedCount.value > 0;\n});\n\n// Styles\nconst chipStyle = computed(() => ({\n  backgroundColor: 'var(--ac-chip-bg)',\n  color: 'var(--ac-chip-text)',\n  border: 'var(--ac-border-width) solid var(--ac-chip-border)',\n  borderRadius: 'var(--ac-radius-button)',\n  opacity: isClearing.value ? 0.7 : 1,\n}));\n\nconst clearButtonStyle = computed(() => {\n  if (!canClear.value) {\n    return {\n      backgroundColor: 'var(--ac-chip-bg)',\n      color: 'var(--ac-text-subtle)',\n      border: 'var(--ac-border-width) solid var(--ac-border)',\n      borderRadius: 'var(--ac-radius-button)',\n      opacity: 0.7,\n    };\n  }\n  return {\n    backgroundColor: 'var(--ac-diff-del-bg)',\n    color: 'var(--ac-danger)',\n    border: 'var(--ac-border-width) solid var(--ac-diff-del-border)',\n    borderRadius: 'var(--ac-radius-button)',\n  };\n});\n\n/**\n * Format bytes to human-readable string.\n */\nfunction formatBytes(bytes: number): string {\n  const safe = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;\n  const kb = safe / 1024;\n  if (kb <= 0) return '0 KB';\n  if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`;\n  const mb = kb / 1024;\n  if (mb < 1024) return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;\n  const gb = mb / 1024;\n  return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`;\n}\n\n/**\n * Get display title for a project.\n */\nfunction projectTitle(p: AttachmentProjectStats): string {\n  return p.projectName?.trim() || p.projectId;\n}\n\n/**\n * Check if a project is an orphan (exists on disk but not in database).\n */\nfunction isOrphanProject(projectId: string): boolean {\n  return orphanProjectIds.value.includes(projectId);\n}\n\n/**\n * Check if a project is selected.\n */\nfunction isSelected(projectId: string): boolean {\n  return selectedProjectIds.value.has(projectId);\n}\n\n/**\n * Toggle project selection.\n */\nfunction toggleProject(projectId: string): void {\n  const next = new Set(selectedProjectIds.value);\n  if (next.has(projectId)) {\n    next.delete(projectId);\n  } else {\n    next.add(projectId);\n  }\n  selectedProjectIds.value = next;\n}\n\n/**\n * Select all selectable projects.\n */\nfunction selectAll(): void {\n  selectedProjectIds.value = new Set(selectableProjectIds.value);\n}\n\n/**\n * Clear all selections.\n */\nfunction clearSelection(): void {\n  selectedProjectIds.value = new Set();\n}\n\n/**\n * Invert current selection.\n */\nfunction invertSelection(): void {\n  const selectable = selectableProjectIds.value;\n  const current = selectedProjectIds.value;\n  const next = new Set<string>();\n  for (const id of selectable) {\n    if (!current.has(id)) {\n      next.add(id);\n    }\n  }\n  selectedProjectIds.value = next;\n}\n\n// Abort controller for ongoing requests\nlet statsAbort: AbortController | null = null;\n\ninterface LoadStatsOptions {\n  /** Whether to reset the status message. Defaults to true. */\n  resetStatusMessage?: boolean;\n}\n\n/**\n * Load attachment stats from server.\n */\nasync function loadStats(opts: LoadStatsOptions = {}): Promise<void> {\n  const { resetStatusMessage = true } = opts;\n\n  if (!baseUrl.value) return;\n\n  // Abort previous request\n  statsAbort?.abort();\n  const controller = new AbortController();\n  statsAbort = controller;\n\n  isLoading.value = true;\n  errorMessage.value = null;\n  if (resetStatusMessage) {\n    statusMessage.value = null;\n  }\n\n  try {\n    const url = `${baseUrl.value}/agent/attachments/stats`;\n    const response = await fetch(url, { signal: controller.signal });\n\n    if (!response.ok) {\n      const text = await response.text().catch(() => '');\n      throw new Error(text || `HTTP ${response.status}`);\n    }\n\n    const data = (await response.json().catch(() => null)) as AttachmentStatsResponse | null;\n    if (!data || data.success !== true) {\n      throw new Error('Invalid response from server.');\n    }\n\n    stats.value = data;\n\n    // Keep selection only for currently selectable projects (exists + has files)\n    const selectableIds = new Set(data.projects.filter(isSelectable).map((p) => p.projectId));\n    selectedProjectIds.value = new Set(\n      [...selectedProjectIds.value].filter((id) => selectableIds.has(id)),\n    );\n  } catch (err: unknown) {\n    if ((err as { name?: string }).name === 'AbortError') return;\n    console.error('Failed to load attachment stats:', err);\n    errorMessage.value = err instanceof Error ? err.message : 'Failed to load attachment stats.';\n  } finally {\n    if (!controller.signal.aborted) {\n      isLoading.value = false;\n    }\n  }\n}\n\n/**\n * Refresh stats.\n */\nasync function refresh(): Promise<void> {\n  if (!serverReady.value) return;\n  await loadStats();\n}\n\n/**\n * Clear selected projects' attachments.\n */\nasync function clearSelected(): Promise<void> {\n  if (!baseUrl.value) return;\n  const projectIds = [...selectedProjectIds.value];\n  if (projectIds.length === 0) return;\n\n  isClearing.value = true;\n  errorMessage.value = null;\n  statusMessage.value = null;\n\n  try {\n    const url = `${baseUrl.value}/agent/attachments`;\n    const response = await fetch(url, {\n      method: 'DELETE',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ projectIds }),\n    });\n\n    if (!response.ok) {\n      const text = await response.text().catch(() => '');\n      throw new Error(text || `HTTP ${response.status}`);\n    }\n\n    const result = (await response.json().catch(() => null)) as AttachmentCleanupResponse | null;\n    if (!result || result.success !== true) {\n      throw new Error('Invalid response from server.');\n    }\n\n    statusMessage.value = `Removed ${formatBytes(result.removedBytes)} (${result.removedFiles.toLocaleString()} files).`;\n    selectedProjectIds.value = new Set();\n\n    // Reload stats to reflect changes (preserve status message)\n    await loadStats({ resetStatusMessage: false });\n  } catch (err: unknown) {\n    console.error('Failed to clear attachments:', err);\n    errorMessage.value = err instanceof Error ? err.message : 'Failed to clear attachments.';\n  } finally {\n    isClearing.value = false;\n  }\n}\n\n/**\n * Handle close action.\n */\nfunction handleClose(): void {\n  emit('close');\n}\n\n// Register Escape key listener when panel is open and cleanup on close\nwatch(\n  () => props.open,\n  (open, _prev, onCleanup) => {\n    if (!open) {\n      // Panel closed - abort any ongoing requests and reset loading state\n      statsAbort?.abort();\n      isLoading.value = false;\n      return;\n    }\n    const onKeyDown = (e: KeyboardEvent): void => {\n      if (e.key === 'Escape') handleClose();\n    };\n    document.addEventListener('keydown', onKeyDown);\n    onCleanup(() => document.removeEventListener('keydown', onKeyDown));\n  },\n);\n\n// Load stats when panel opens\nwatch(\n  () => [props.open, baseUrl.value] as const,\n  ([open, url]) => {\n    if (!open || !url) return;\n    void loadStats();\n  },\n  { immediate: true },\n);\n\n// Cleanup on unmount\nonUnmounted(() => {\n  statsAbort?.abort();\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ComposerDrawer.vue",
    "content": "<template>\n  <Teleport :to=\"teleportTarget\" :disabled=\"!teleportTarget\">\n    <Transition name=\"composer-drawer\">\n      <div\n        v-if=\"open\"\n        class=\"fixed inset-0 z-50\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Expanded editor\"\n        @keydown.esc=\"emit('close')\"\n      >\n        <!-- Backdrop -->\n        <div class=\"absolute inset-0 bg-black/40 composer-drawer-backdrop\" @click=\"emit('close')\" />\n\n        <!-- Sheet -->\n        <div\n          class=\"absolute inset-x-0 bottom-0 composer-drawer-sheet overflow-hidden flex flex-col\"\n          :style=\"sheetStyle\"\n          @click.stop\n        >\n          <!-- Header -->\n          <div class=\"flex items-center justify-between px-4 py-3\" :style=\"headerStyle\">\n            <div class=\"min-w-0\">\n              <div class=\"text-sm font-semibold\" :style=\"{ color: 'var(--ac-text)' }\">\n                Expanded editor\n              </div>\n              <div class=\"text-[10px]\" :style=\"{ color: 'var(--ac-text-subtle)' }\">\n                Press {{ modifierKey }}+Enter to send\n              </div>\n            </div>\n\n            <button\n              type=\"button\"\n              class=\"p-1.5 transition-colors hover:opacity-80 cursor-pointer\"\n              :style=\"closeButtonStyle\"\n              aria-label=\"Close expanded editor\"\n              @click=\"emit('close')\"\n            >\n              <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M6 18L18 6M6 6l12 12\"\n                />\n              </svg>\n            </button>\n          </div>\n\n          <!-- Content -->\n          <div class=\"flex-1 min-h-0 overflow-hidden flex flex-col px-4 py-3 gap-3\">\n            <!-- Attachment previews -->\n            <div v-if=\"attachments.length > 0\" class=\"flex flex-wrap gap-2\">\n              <div v-for=\"(attachment, index) in attachments\" :key=\"index\" class=\"relative group\">\n                <div class=\"w-14 h-14 rounded-lg overflow-hidden\" :style=\"thumbnailStyle\">\n                  <img\n                    v-if=\"attachment.type === 'image' && attachment.previewUrl\"\n                    :src=\"attachment.previewUrl\"\n                    :alt=\"attachment.name\"\n                    class=\"w-full h-full object-cover\"\n                  />\n                  <div\n                    v-else\n                    class=\"w-full h-full flex items-center justify-center\"\n                    :style=\"{ color: 'var(--ac-text-subtle)' }\"\n                  >\n                    <svg class=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                      <path\n                        stroke-linecap=\"round\"\n                        stroke-linejoin=\"round\"\n                        stroke-width=\"2\"\n                        d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n                      />\n                    </svg>\n                  </div>\n                </div>\n\n                <!-- Remove button -->\n                <button\n                  class=\"absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer\"\n                  :style=\"removeButtonStyle\"\n                  title=\"Remove image\"\n                  @click=\"emit('attachment:remove', index)\"\n                >\n                  <svg class=\"w-2.5 h-2.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path\n                      stroke-linecap=\"round\"\n                      stroke-linejoin=\"round\"\n                      stroke-width=\"3\"\n                      d=\"M6 18L18 6M6 6l12 12\"\n                    />\n                  </svg>\n                </button>\n\n                <!-- Filename overlay -->\n                <div\n                  class=\"absolute bottom-0 left-0 right-0 px-0.5 py-0.5 text-[8px] truncate opacity-0 group-hover:opacity-100 transition-opacity rounded-b-lg\"\n                  :style=\"filenameOverlayStyle\"\n                >\n                  {{ attachment.name }}\n                </div>\n              </div>\n            </div>\n\n            <!-- Attachment error -->\n            <div v-if=\"attachmentError\" class=\"text-xs\" :style=\"{ color: 'var(--ac-error)' }\">\n              {{ attachmentError }}\n            </div>\n\n            <!-- Expanded textarea with fake caret -->\n            <div class=\"relative flex-1 min-h-0 flex flex-col\">\n              <textarea\n                ref=\"textareaRef\"\n                :value=\"modelValue\"\n                class=\"w-full flex-1 min-h-0 bg-transparent border-none focus:ring-0 focus:outline-none resize-none p-3 text-sm\"\n                :style=\"textareaStyle\"\n                :placeholder=\"placeholder\"\n                @input=\"handleInput\"\n                @keydown.enter.meta.exact.prevent=\"handleModifierEnter\"\n                @keydown.enter.ctrl.exact.prevent=\"handleModifierEnter\"\n                @paste=\"handlePaste\"\n              />\n\n              <!-- Fake caret overlay (opt-in comet effect, only mount when enabled) -->\n              <FakeCaretOverlay\n                v-if=\"enableFakeCaret\"\n                :textarea-ref=\"textareaRef\"\n                :enabled=\"true\"\n                :value=\"modelValue\"\n              />\n            </div>\n\n            <!-- Footer actions -->\n            <div class=\"flex items-center justify-between\">\n              <slot name=\"left-actions\" />\n\n              <div class=\"flex gap-2\">\n                <!-- Cancel button: Show when request is active (not just streaming) -->\n                <button\n                  v-if=\"isRequestActive && canCancel && !sending\"\n                  class=\"px-3 py-1.5 text-xs transition-colors cursor-pointer\"\n                  :style=\"cancelButtonStyle\"\n                  :disabled=\"cancelling\"\n                  @click=\"emit('cancel')\"\n                >\n                  {{ cancelling ? 'Stopping...' : 'Stop' }}\n                </button>\n\n                <!-- Send button -->\n                <button\n                  class=\"px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer\"\n                  :style=\"sendButtonStyle\"\n                  :disabled=\"!canSend || sending\"\n                  @click=\"handleSubmit\"\n                >\n                  Send\n                </button>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, watch, nextTick, onMounted } from 'vue';\nimport type { AttachmentWithPreview } from '../../composables/useAttachments';\nimport type { RequestState } from '../../composables/useAgentChat';\nimport FakeCaretOverlay from './FakeCaretOverlay.vue';\n\nconst props = defineProps<{\n  /** Whether the drawer is open */\n  open: boolean;\n  /** Current input value */\n  modelValue: string;\n  /** Placeholder text */\n  placeholder?: string;\n  /** Attachments list */\n  attachments: AttachmentWithPreview[];\n  /** Attachment error message */\n  attachmentError?: string | null;\n  /** Request lifecycle state (starting/running/completed/cancelled) */\n  requestState: RequestState;\n  /** Whether message is being sent */\n  sending: boolean;\n  /** Whether cancel is in progress */\n  cancelling: boolean;\n  /** Whether cancel is available */\n  canCancel: boolean;\n  /** Whether send is available */\n  canSend: boolean;\n  /** Fake caret feature flag */\n  enableFakeCaret?: boolean;\n}>();\n\n/**\n * Whether there is an active request in progress.\n * Derived from requestState for use in UI conditions.\n */\nconst isRequestActive = computed(() => {\n  return (\n    props.requestState === 'starting' ||\n    props.requestState === 'ready' ||\n    props.requestState === 'running'\n  );\n});\n\nconst emit = defineEmits<{\n  close: [];\n  'update:modelValue': [value: string];\n  submit: [];\n  cancel: [];\n  'attachment:remove': [index: number];\n  paste: [event: ClipboardEvent];\n}>();\n\nconst textareaRef = ref<HTMLTextAreaElement | null>(null);\nconst teleportTarget = ref<Element | null>(null);\n\n// Detect OS for keyboard shortcut display\nconst modifierKey = computed(() => {\n  if (typeof navigator === 'undefined') return 'Ctrl';\n  return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';\n});\n\n// Styles\nconst sheetStyle = computed(() => ({\n  height: '65vh',\n  backgroundColor: 'var(--ac-surface)',\n  borderTop: 'var(--ac-border-width) solid var(--ac-border)',\n  borderTopLeftRadius: 'var(--ac-radius-card)',\n  borderTopRightRadius: 'var(--ac-radius-card)',\n  boxShadow: 'var(--ac-shadow-float)',\n}));\n\nconst headerStyle = computed(() => ({\n  borderBottom: 'var(--ac-border-width) solid var(--ac-border)',\n}));\n\nconst closeButtonStyle = computed(() => ({\n  backgroundColor: 'transparent',\n  color: 'var(--ac-text-muted)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst thumbnailStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n}));\n\nconst removeButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-error)',\n  color: 'white',\n}));\n\nconst filenameOverlayStyle = computed(() => ({\n  backgroundColor: 'rgba(0,0,0,0.6)',\n  color: 'white',\n}));\n\nconst textareaStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-body)',\n  color: 'var(--ac-text)',\n  backgroundColor: 'var(--ac-surface-muted)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-card)',\n}));\n\nconst cancelButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-hover-bg)',\n  color: 'var(--ac-text)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst sendButtonStyle = computed(() => ({\n  backgroundColor: props.canSend ? 'var(--ac-accent)' : 'var(--ac-surface-muted)',\n  color: props.canSend ? 'var(--ac-accent-contrast)' : 'var(--ac-text-subtle)',\n  borderRadius: 'var(--ac-radius-button)',\n  cursor: props.canSend ? 'pointer' : 'not-allowed',\n}));\n\n// Event handlers\nfunction handleInput(event: Event): void {\n  const value = (event.target as HTMLTextAreaElement).value;\n  emit('update:modelValue', value);\n}\n\nfunction handleModifierEnter(): void {\n  if (props.canSend && !props.sending) {\n    emit('submit');\n  }\n}\n\nfunction handleSubmit(): void {\n  emit('submit');\n}\n\nfunction handlePaste(event: ClipboardEvent): void {\n  emit('paste', event);\n}\n\n// Escape key handler for document-level capture (handles cases where focus is elsewhere)\nfunction handleEscapeKey(e: KeyboardEvent): void {\n  if (e.key === 'Escape') {\n    emit('close');\n  }\n}\n\n// Focus textarea when drawer opens, and setup/cleanup Escape key listener\nwatch(\n  () => props.open,\n  async (isOpen, _prevOpen, onCleanup) => {\n    if (isOpen) {\n      await nextTick();\n      textareaRef.value?.focus();\n\n      // Add document-level Escape listener\n      document.addEventListener('keydown', handleEscapeKey);\n      onCleanup(() => {\n        document.removeEventListener('keydown', handleEscapeKey);\n      });\n    }\n  },\n);\n\n// Find teleport target - search from a root element to find .agent-theme ancestor\n// This is more robust than document.querySelector for multi-instance scenarios\nconst rootRef = ref<HTMLElement | null>(null);\n\nonMounted(() => {\n  // Create a temporary element to measure from (since drawer content is teleported)\n  // We find .agent-theme from the current document context\n  const agentTheme = document.querySelector('.agent-theme');\n  teleportTarget.value = agentTheme ?? document.body;\n});\n\n// Expose focus method\ndefineExpose({\n  focus: () => textareaRef.value?.focus(),\n});\n</script>\n\n<style scoped>\n/* Drawer transition */\n.composer-drawer-enter-active,\n.composer-drawer-leave-active {\n  transition: opacity 0.16s ease;\n}\n\n.composer-drawer-enter-active .composer-drawer-backdrop,\n.composer-drawer-leave-active .composer-drawer-backdrop {\n  transition: opacity 0.16s ease;\n}\n\n.composer-drawer-enter-active .composer-drawer-sheet,\n.composer-drawer-leave-active .composer-drawer-sheet {\n  transition: transform 0.2s cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n.composer-drawer-enter-from,\n.composer-drawer-leave-to {\n  opacity: 0;\n}\n\n.composer-drawer-enter-from .composer-drawer-sheet,\n.composer-drawer-leave-to .composer-drawer-sheet {\n  transform: translateY(100%);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/ElementChip.vue",
    "content": "<template>\n  <div\n    ref=\"chipRef\"\n    class=\"relative inline-flex items-center gap-1.5 text-[11px] leading-none flex-shrink-0 select-none transition-colors\"\n    :style=\"chipContainerStyle\"\n    @mouseenter=\"handleMouseEnter\"\n    @mouseleave=\"handleMouseLeave\"\n  >\n    <!-- Main Toggle Area (click to include/exclude) -->\n    <button\n      type=\"button\"\n      class=\"inline-flex items-center gap-1.5 px-2 py-1 bg-transparent border-none cursor-pointer\"\n      :aria-pressed=\"!excluded\"\n      :aria-label=\"ariaLabel\"\n      @click=\"handleToggle\"\n      @focus=\"handleFocus\"\n      @blur=\"handleBlur\"\n    >\n      <!-- Change Type Icon -->\n      <span class=\"inline-flex items-center justify-center w-3.5 h-3.5\" :style=\"typeIconStyle\">\n        <!-- Style Icon -->\n        <svg\n          v-if=\"element.type === 'style'\"\n          class=\"w-3.5 h-3.5\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n          aria-hidden=\"true\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\"\n          />\n        </svg>\n\n        <!-- Text Icon -->\n        <svg\n          v-else-if=\"element.type === 'text'\"\n          class=\"w-3.5 h-3.5\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n          aria-hidden=\"true\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M4 6h16M4 12h10M4 18h12\"\n          />\n        </svg>\n\n        <!-- Class Icon -->\n        <svg\n          v-else-if=\"element.type === 'class'\"\n          class=\"w-3.5 h-3.5\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n          aria-hidden=\"true\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M7 20l4-16m2 16l4-16M6 9h14M4 15h14\"\n          />\n        </svg>\n\n        <!-- Mixed Icon (layers) -->\n        <svg\n          v-else\n          class=\"w-3.5 h-3.5\"\n          fill=\"none\"\n          viewBox=\"0 0 24 24\"\n          stroke=\"currentColor\"\n          aria-hidden=\"true\"\n        >\n          <path\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"2\"\n            d=\"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\"\n          />\n        </svg>\n      </span>\n\n      <!-- Element Label (tagName only) -->\n      <span class=\"truncate max-w-[140px]\" :style=\"labelStyle\">\n        {{ chipTagName }}\n      </span>\n\n      <!-- Include/Exclude State Pill -->\n      <span class=\"ml-0.5 px-1 py-0.5 text-[9px] uppercase tracking-wider\" :style=\"statePillStyle\">\n        {{ excluded ? 'ex' : 'in' }}\n      </span>\n    </button>\n\n    <!-- Revert Button (visible on hover) -->\n    <button\n      v-show=\"isHovering\"\n      type=\"button\"\n      class=\"flex items-center justify-center w-4 h-4 -ml-1 mr-1 rounded-full transition-colors cursor-pointer\"\n      :style=\"revertButtonStyle\"\n      :aria-label=\"`Revert changes to ${element.label}`\"\n      :title=\"`Revert all changes to ${element.label}`\"\n      @click.stop.prevent=\"handleRevert\"\n    >\n      <svg\n        class=\"w-2.5 h-2.5\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n        stroke-width=\"2.5\"\n        aria-hidden=\"true\"\n      >\n        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n      </svg>\n    </button>\n  </div>\n\n  <!-- Tooltip - Teleported to agent-theme root to avoid overflow clipping while preserving theme -->\n  <Teleport :to=\"tooltipTarget\" :disabled=\"!tooltipTarget\">\n    <Transition name=\"tooltip-fade\">\n      <div\n        v-if=\"showTooltip\"\n        class=\"fixed pointer-events-none\"\n        :style=\"tooltipPositionStyle\"\n        role=\"tooltip\"\n      >\n        <div class=\"px-3 py-2 text-[11px] space-y-1.5\" :style=\"tooltipStyle\">\n          <!-- Full Label -->\n          <div class=\"font-medium truncate max-w-[320px]\" :style=\"tooltipLabelStyle\">\n            {{ element.fullLabel || element.label }}\n          </div>\n\n          <!-- Meta Info -->\n          <div class=\"text-[10px] flex items-center gap-2\" :style=\"tooltipMetaStyle\">\n            <span :style=\"tooltipMonoStyle\">{{ element.type }}</span>\n            <span class=\"opacity-50\">&middot;</span>\n            <span>{{ excluded ? 'Excluded' : 'Included' }}</span>\n            <span class=\"opacity-50\">&middot;</span>\n            <span\n              >{{ element.transactionIds.length }} change{{\n                element.transactionIds.length !== 1 ? 's' : ''\n              }}</span\n            >\n          </div>\n\n          <!-- Style Changes -->\n          <div v-if=\"element.changes.style\" class=\"text-[10px] space-y-0.5\">\n            <div class=\"flex items-center gap-2\">\n              <span class=\"font-medium\">Style</span>\n              <span :style=\"tooltipMutedMonoStyle\">\n                <template v-if=\"element.changes.style.added > 0\">\n                  <span :style=\"{ color: 'var(--ac-success, #10b981)' }\"\n                    >+{{ element.changes.style.added }}</span\n                  >\n                </template>\n                <template v-if=\"element.changes.style.modified > 0\">\n                  <span v-if=\"element.changes.style.added > 0\" class=\"mx-0.5\">/</span>\n                  <span :style=\"{ color: 'var(--ac-warning, #f59e0b)' }\"\n                    >~{{ element.changes.style.modified }}</span\n                  >\n                </template>\n                <template v-if=\"element.changes.style.removed > 0\">\n                  <span\n                    v-if=\"element.changes.style.added > 0 || element.changes.style.modified > 0\"\n                    class=\"mx-0.5\"\n                    >/</span\n                  >\n                  <span :style=\"{ color: 'var(--ac-danger, #ef4444)' }\"\n                    >-{{ element.changes.style.removed }}</span\n                  >\n                </template>\n              </span>\n            </div>\n            <div v-if=\"styleDetailsText\" :style=\"tooltipDetailsStyle\">\n              {{ styleDetailsText }}\n            </div>\n          </div>\n\n          <!-- Text Changes -->\n          <div v-if=\"element.changes.text\" class=\"text-[10px] space-y-0.5\">\n            <div class=\"font-medium\">Text</div>\n            <div class=\"flex items-start gap-2\">\n              <span class=\"opacity-60 w-10 flex-shrink-0\">before</span>\n              <code class=\"truncate max-w-[260px]\" :style=\"codeStyle\">\n                {{ element.changes.text.beforePreview || '(empty)' }}\n              </code>\n            </div>\n            <div class=\"flex items-start gap-2\">\n              <span class=\"opacity-60 w-10 flex-shrink-0\">after</span>\n              <code class=\"truncate max-w-[260px]\" :style=\"codeStyle\">\n                {{ element.changes.text.afterPreview || '(empty)' }}\n              </code>\n            </div>\n          </div>\n\n          <!-- Class Changes -->\n          <div v-if=\"element.changes.class && hasClassChanges\" class=\"text-[10px] space-y-0.5\">\n            <div class=\"font-medium\">Class</div>\n            <div v-if=\"classAddedText\" :style=\"tooltipDetailsStyle\">\n              <span :style=\"{ color: 'var(--ac-success, #10b981)' }\">+</span> {{ classAddedText }}\n            </div>\n            <div v-if=\"classRemovedText\" :style=\"tooltipDetailsStyle\">\n              <span :style=\"{ color: 'var(--ac-danger, #ef4444)' }\">-</span> {{ classRemovedText }}\n            </div>\n          </div>\n        </div>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, onMounted, onUnmounted, inject, watch, type Ref } from 'vue';\nimport type { ElementChangeSummary, WebEditorElementKey } from '@/common/web-editor-types';\n\n// =============================================================================\n// Props & Emits\n// =============================================================================\n\nconst props = withDefaults(\n  defineProps<{\n    /** Element change summary to display */\n    element: ElementChangeSummary;\n    /** Whether this element is excluded from Apply */\n    excluded: boolean;\n    /** Whether this element is currently selected in web-editor */\n    selected?: boolean;\n  }>(),\n  {\n    selected: false,\n  },\n);\n\nconst emit = defineEmits<{\n  /** Toggle include/exclude state */\n  'toggle:exclude': [elementKey: WebEditorElementKey];\n  /** Revert element to original state (Phase 2 - Selective Undo) */\n  revert: [elementKey: WebEditorElementKey];\n  /** Mouse enter - start highlight */\n  'hover:start': [element: ElementChangeSummary];\n  /** Mouse leave - clear highlight */\n  'hover:end': [element: ElementChangeSummary];\n}>();\n\n// =============================================================================\n// Local State\n// =============================================================================\n\nconst chipRef = ref<HTMLDivElement | null>(null);\nconst isHovering = ref(false);\nconst isFocused = ref(false);\n\n/** Cached chip position for tooltip placement */\nconst chipRect = ref<DOMRect | null>(null);\n\n/** Teleport target - find .agent-theme ancestor for theme variable inheritance */\nconst tooltipTarget = ref<Element | null>(null);\n\n/**\n * Inject scroll/resize trigger from parent WebEditorChanges component.\n * This centralizes event listeners for better performance.\n */\nconst scrollResizeTrigger = inject<Ref<number>>('scrollResizeTrigger');\n\n// =============================================================================\n// Computed: UI State\n// =============================================================================\n\n/**\n * Extract tagName from label for compact chip display.\n * Label format is usually \"tagName#id.class\" or \"tagName.class\"\n */\nconst chipTagName = computed(() => {\n  const label = (props.element.label || '').trim();\n  // Extract tagName (first part before #, ., or space)\n  const match = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);\n  return match?.[1]?.toLowerCase() || 'element';\n});\n\nconst showTooltip = computed(() => isHovering.value || isFocused.value);\n\n/** Calculate tooltip position based on chip element position */\nconst tooltipPositionStyle = computed(() => {\n  const rect = chipRect.value;\n  if (!rect) {\n    return {\n      opacity: 0,\n      zIndex: 9999,\n    };\n  }\n\n  // Position tooltip centered above the chip\n  const tooltipWidth = 300; // Approximate max width\n  const gap = 8; // Gap between chip and tooltip\n\n  // Calculate left position, clamped to viewport\n  let left = rect.left + rect.width / 2 - tooltipWidth / 2;\n  const viewportWidth = window.innerWidth;\n  const padding = 8;\n\n  if (left < padding) {\n    left = padding;\n  } else if (left + tooltipWidth > viewportWidth - padding) {\n    left = viewportWidth - tooltipWidth - padding;\n  }\n\n  return {\n    left: `${left}px`,\n    top: `${rect.top - gap}px`,\n    transform: 'translateY(-100%)',\n    zIndex: 9999,\n  };\n});\n\nconst ariaLabel = computed(() => {\n  const state = props.excluded ? 'excluded' : 'included';\n  return `${props.element.label} (${props.element.type} change, ${state}). Click to toggle.`;\n});\n\n// =============================================================================\n// Computed: Styles\n// =============================================================================\n\nconst chipContainerStyle = computed(() => {\n  const active = showTooltip.value;\n  const isSelected = props.selected;\n\n  // Selected elements get accent border\n  const borderColor = isSelected\n    ? 'var(--ac-accent)'\n    : active\n      ? 'var(--ac-border-strong)'\n      : 'var(--ac-border)';\n\n  return {\n    backgroundColor: active ? 'var(--ac-hover-bg)' : 'var(--ac-surface)',\n    border: `var(--ac-border-width) solid ${borderColor}`,\n    borderRadius: 'var(--ac-radius-button)',\n    boxShadow: active ? 'var(--ac-shadow-card)' : 'none',\n    color: props.excluded ? 'var(--ac-text-subtle)' : 'var(--ac-text-muted)',\n    opacity: props.excluded ? 0.7 : 1,\n  };\n});\n\nconst revertButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text-subtle)',\n  cursor: 'pointer',\n  // Hover state handled via CSS :hover\n}));\n\nconst typeIconStyle = computed(() => ({\n  color: props.excluded ? 'var(--ac-text-subtle)' : 'var(--ac-accent)',\n}));\n\nconst labelStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst statePillStyle = computed(() => ({\n  backgroundColor: props.excluded ? 'var(--ac-surface-muted)' : 'var(--ac-accent)',\n  color: props.excluded ? 'var(--ac-text-subtle)' : 'var(--ac-accent-contrast)',\n  borderRadius: 'var(--ac-radius-button)',\n  fontFamily: 'var(--ac-font-mono)',\n  fontWeight: '600',\n}));\n\nconst tooltipStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-inner)',\n  boxShadow: 'var(--ac-shadow-float)',\n  color: 'var(--ac-text)',\n  minWidth: '240px',\n  maxWidth: '360px',\n}));\n\nconst tooltipLabelStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst tooltipMetaStyle = computed(() => ({\n  color: 'var(--ac-text-subtle)',\n}));\n\nconst tooltipMonoStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst tooltipMutedMonoStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n  color: 'var(--ac-text-muted)',\n}));\n\nconst tooltipDetailsStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n  color: 'var(--ac-text-subtle)',\n}));\n\nconst codeStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n  backgroundColor: 'var(--ac-surface-muted)',\n  borderRadius: 'var(--ac-radius-button)',\n  padding: '1px 4px',\n  color: 'var(--ac-text)',\n}));\n\n// =============================================================================\n// Computed: Content Formatting\n// =============================================================================\n\nconst styleDetailsText = computed(() => {\n  const details = props.element.changes.style?.details;\n  if (!details || details.length === 0) return '';\n  return formatListWithLimit(details, 6);\n});\n\nconst hasClassChanges = computed(() => {\n  const cls = props.element.changes.class;\n  if (!cls) return false;\n  return (cls.added?.length ?? 0) > 0 || (cls.removed?.length ?? 0) > 0;\n});\n\nconst classAddedText = computed(() => {\n  const added = props.element.changes.class?.added;\n  if (!added || added.length === 0) return '';\n  return formatListWithLimit(added, 4);\n});\n\nconst classRemovedText = computed(() => {\n  const removed = props.element.changes.class?.removed;\n  if (!removed || removed.length === 0) return '';\n  return formatListWithLimit(removed, 4);\n});\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Format a list of items with a display limit.\n * @param items - Array of strings to format\n * @param limit - Maximum number of items to show\n * @returns Formatted string with overflow indicator\n */\nfunction formatListWithLimit(items: readonly string[], limit: number): string {\n  const cleaned = items.map((s) => String(s ?? '').trim()).filter(Boolean);\n\n  if (cleaned.length === 0) return '';\n\n  const visible = cleaned.slice(0, limit);\n  const overflow = cleaned.length - visible.length;\n\n  if (overflow > 0) {\n    return `${visible.join(', ')} (+${overflow} more)`;\n  }\n\n  return visible.join(', ');\n}\n\n// =============================================================================\n// Event Handlers\n// =============================================================================\n\n/** Update cached chip position for tooltip placement */\nfunction updateChipRect(): void {\n  if (chipRef.value) {\n    chipRect.value = chipRef.value.getBoundingClientRect();\n  }\n}\n\nfunction handleToggle(): void {\n  emit('toggle:exclude', props.element.elementKey);\n}\n\nfunction handleRevert(): void {\n  emit('revert', props.element.elementKey);\n}\n\nfunction handleMouseEnter(): void {\n  updateChipRect();\n  isHovering.value = true;\n  emit('hover:start', props.element);\n}\n\nfunction handleMouseLeave(): void {\n  isHovering.value = false;\n  emit('hover:end', props.element);\n}\n\nfunction handleFocus(): void {\n  updateChipRect();\n  isFocused.value = true;\n}\n\nfunction handleBlur(): void {\n  isFocused.value = false;\n}\n\n// =============================================================================\n// Lifecycle - Handle scroll/resize updates\n// =============================================================================\n\n/**\n * Watch for scroll/resize trigger changes from parent component.\n * This replaces per-instance event listeners with a centralized approach.\n */\nwatch(\n  () => scrollResizeTrigger?.value,\n  () => {\n    if (showTooltip.value) {\n      updateChipRect();\n    }\n  },\n);\n\nonMounted(() => {\n  // Find .agent-theme ancestor for tooltip teleport (preserves theme CSS variables)\n  if (chipRef.value) {\n    const agentTheme = chipRef.value.closest('.agent-theme');\n    tooltipTarget.value = agentTheme;\n  }\n});\n\nonUnmounted(() => {\n  // Clear any active highlight when chip is unmounted (e.g., when toggling include/exclude)\n  if (isHovering.value) {\n    emit('hover:end', props.element);\n  }\n});\n</script>\n\n<style>\n/* Tooltip transition styles - not scoped because tooltip is teleported to body */\n.tooltip-fade-enter-active,\n.tooltip-fade-leave-active {\n  transition:\n    opacity 0.15s ease,\n    transform 0.15s ease;\n}\n\n.tooltip-fade-enter-from,\n.tooltip-fade-leave-to {\n  opacity: 0;\n  transform: translateY(calc(-100% + 4px));\n}\n\n.tooltip-fade-enter-to,\n.tooltip-fade-leave-from {\n  opacity: 1;\n  transform: translateY(-100%);\n}\n</style>\n\n<style scoped>\n/* Revert button hover effect */\nbutton[aria-label^='Revert']:hover {\n  background-color: var(--ac-danger, #ef4444) !important;\n  color: white !important;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/FakeCaretOverlay.vue",
    "content": "<template>\n  <div\n    v-show=\"enabled\"\n    class=\"ac-fake-caret-overlay\"\n    :style=\"fakeCaret.overlayStyle.value\"\n    aria-hidden=\"true\"\n  >\n    <!-- Canvas for comet trail -->\n    <canvas ref=\"canvasRef\" class=\"ac-fake-caret-canvas\" />\n    <!-- Fake caret element -->\n    <div ref=\"caretRef\" class=\"ac-fake-caret-caret\" />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, nextTick, onBeforeUnmount, ref, watch, toRef } from 'vue';\nimport { useFakeCaret } from '../../composables';\n\n// =============================================================================\n// Props\n// =============================================================================\n\nconst props = defineProps<{\n  /** Reference to the textarea element (passed as template ref) */\n  textareaRef: HTMLTextAreaElement | null;\n  /** Whether fake caret is enabled */\n  enabled: boolean;\n  /** Current textarea value (for reactivity) */\n  value: string;\n}>();\n\n// =============================================================================\n// Fake Caret Composable\n// =============================================================================\n\nconst enabledRef = computed(() => props.enabled);\nconst textareaRefWrapped = toRef(props, 'textareaRef');\n\nconst fakeCaret = useFakeCaret({\n  textareaRef: textareaRefWrapped,\n  enabled: enabledRef,\n});\n\n// =============================================================================\n// Refs\n// =============================================================================\n\nconst canvasRef = ref<HTMLCanvasElement | null>(null);\nconst caretRef = ref<HTMLDivElement | null>(null);\n\n// =============================================================================\n// Canvas State\n// =============================================================================\n\nlet scheduled = false;\nlet lastCssWidth = 0;\nlet lastCssHeight = 0;\nlet lastDpr = 0;\n\n// Cached style values (refreshed on resize/style change, not every frame)\nlet cachedAccentColor = '#d97757';\nlet cachedLineHeight = 18;\n\n// =============================================================================\n// Helper Functions\n// =============================================================================\n\n/**\n * Get the accent color from CSS variables.\n */\nfunction getAccentColor(): string {\n  return cachedAccentColor;\n}\n\n/**\n * Get line height in pixels.\n */\nfunction getLineHeightPx(): number {\n  return cachedLineHeight;\n}\n\n/**\n * Refresh cached style values from textarea.\n * Call this on resize/style change, not every frame.\n */\nfunction refreshCachedStyles(textarea: HTMLTextAreaElement): void {\n  if (typeof window === 'undefined') return;\n\n  const cs = window.getComputedStyle(textarea);\n\n  // Cache accent color\n  const accent = cs.getPropertyValue('--ac-accent').trim();\n  cachedAccentColor = accent || '#d97757';\n\n  // Cache line height\n  const lineHeight = Number.parseFloat(cs.lineHeight);\n  if (Number.isFinite(lineHeight)) {\n    cachedLineHeight = lineHeight;\n  } else {\n    const fontSize = Number.parseFloat(cs.fontSize);\n    cachedLineHeight = Number.isFinite(fontSize) ? Math.round(fontSize * 1.25) : 18;\n  }\n}\n\n/**\n * Get device pixel ratio.\n */\nfunction getDpr(): number {\n  if (typeof window === 'undefined') return 1;\n  return window.devicePixelRatio || 1;\n}\n\n/**\n * Sync canvas size with textarea dimensions.\n */\nfunction syncCanvas(\n  textarea: HTMLTextAreaElement,\n): { ctx: CanvasRenderingContext2D; cssWidth: number; cssHeight: number } | null {\n  const canvas = canvasRef.value;\n  if (!canvas) return null;\n\n  const cssWidth = textarea.clientWidth;\n  const cssHeight = textarea.clientHeight;\n  if (cssWidth <= 0 || cssHeight <= 0) return null;\n\n  const dpr = getDpr();\n\n  // Only resize if dimensions changed\n  if (cssWidth !== lastCssWidth || cssHeight !== lastCssHeight || dpr !== lastDpr) {\n    lastCssWidth = cssWidth;\n    lastCssHeight = cssHeight;\n    lastDpr = dpr;\n    canvas.width = Math.max(1, Math.floor(cssWidth * dpr));\n    canvas.height = Math.max(1, Math.floor(cssHeight * dpr));\n  }\n\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return null;\n\n  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  return { ctx, cssWidth, cssHeight };\n}\n\n/**\n * Clear the canvas.\n */\nfunction clearCanvas(ctx: CanvasRenderingContext2D, cssWidth: number, cssHeight: number): void {\n  ctx.clearRect(0, 0, cssWidth, cssHeight);\n}\n\n/**\n * Set native caret visibility.\n */\nfunction setNativeCaretVisible(textarea: HTMLTextAreaElement, visible: boolean): void {\n  textarea.style.caretColor = visible ? '' : 'transparent';\n}\n\n// =============================================================================\n// Draw Frame\n// =============================================================================\n\n/**\n * Draw a single animation frame.\n */\nfunction drawFrame(): void {\n  try {\n    const textarea = props.textareaRef;\n    const caretEl = caretRef.value;\n    if (!textarea || !caretEl) return;\n\n    const show = fakeCaret.showFakeCaret.value;\n\n    // If not showing, restore native caret and clear canvas\n    if (!show) {\n      setNativeCaretVisible(textarea, true);\n      caretEl.style.opacity = '0';\n\n      const synced = syncCanvas(textarea);\n      if (synced) clearCanvas(synced.ctx, synced.cssWidth, synced.cssHeight);\n      return;\n    }\n\n    // Hide native caret\n    setNativeCaretVisible(textarea, false);\n\n    // Position fake caret (use cached values for performance)\n    const lineHeight = getLineHeightPx();\n    const x = fakeCaret.caretX.value;\n    const y = fakeCaret.caretY.value;\n\n    caretEl.style.height = `${lineHeight}px`;\n    caretEl.style.transform = `translate3d(${x}px, ${y}px, 0)`;\n    caretEl.style.opacity = '1';\n\n    // Draw comet trail on canvas\n    const synced = syncCanvas(textarea);\n    if (!synced) return;\n\n    const { ctx, cssWidth, cssHeight } = synced;\n    clearCanvas(ctx, cssWidth, cssHeight);\n\n    const points = fakeCaret.trail.value;\n    if (points.length <= 0) return;\n\n    const accent = getAccentColor();\n    const centerY = lineHeight * 0.55;\n\n    ctx.save();\n    ctx.globalCompositeOperation = 'lighter';\n    ctx.lineCap = 'round';\n    ctx.lineJoin = 'round';\n    ctx.strokeStyle = accent;\n    ctx.fillStyle = accent;\n\n    // Draw trail segments\n    for (let i = 1; i < points.length; i += 1) {\n      const prev = points[i - 1];\n      const curr = points[i];\n      const alpha = Math.min(1, Math.max(0, curr.alpha)) * 0.28;\n\n      ctx.globalAlpha = alpha;\n      ctx.lineWidth = 0.75 + 2.25 * curr.alpha;\n\n      ctx.beginPath();\n      ctx.moveTo(prev.x + 1, prev.y + centerY);\n      ctx.lineTo(curr.x + 1, curr.y + centerY);\n      ctx.stroke();\n    }\n\n    // Draw head glow\n    const head = points[points.length - 1];\n    ctx.globalAlpha = 0.35;\n    ctx.shadowColor = accent;\n    ctx.shadowBlur = 10;\n\n    ctx.beginPath();\n    ctx.arc(head.x + 1, head.y + centerY, 2.25, 0, Math.PI * 2);\n    ctx.fill();\n\n    ctx.restore();\n  } catch (error) {\n    // On error, restore native caret\n    const textarea = props.textareaRef;\n    if (textarea) setNativeCaretVisible(textarea, true);\n  }\n}\n\n/**\n * Schedule a draw frame using requestAnimationFrame.\n */\nfunction scheduleDraw(): void {\n  if (scheduled) return;\n  scheduled = true;\n\n  if (typeof requestAnimationFrame !== 'function') {\n    scheduled = false;\n    drawFrame();\n    return;\n  }\n\n  requestAnimationFrame(() => {\n    scheduled = false;\n    drawFrame();\n  });\n}\n\n// =============================================================================\n// Event Handlers\n// =============================================================================\n\nfunction handleResize(): void {\n  // Refresh cached styles on resize (cheaper than reading every frame)\n  const textarea = props.textareaRef;\n  if (textarea) {\n    refreshCachedStyles(textarea);\n  }\n  fakeCaret.updatePosition();\n  scheduleDraw();\n}\n\nfunction reset(): void {\n  const textarea = props.textareaRef;\n  if (textarea) setNativeCaretVisible(textarea, true);\n}\n\n// =============================================================================\n// Watchers\n// =============================================================================\n\n// Watch enabled state\nwatch(\n  () => props.enabled,\n  (enabled) => {\n    if (!enabled) {\n      reset();\n    } else {\n      // Initialize cached styles when enabled\n      const textarea = props.textareaRef;\n      if (textarea) {\n        refreshCachedStyles(textarea);\n      }\n    }\n    fakeCaret.updatePosition();\n    scheduleDraw();\n  },\n  { immediate: true },\n);\n\n// Watch textareaRef changes (null -> element) to initialize cached styles\nwatch(\n  () => props.textareaRef,\n  (textarea) => {\n    if (textarea && props.enabled) {\n      refreshCachedStyles(textarea);\n    }\n  },\n);\n\n// Watch value changes\nwatch(\n  () => props.value,\n  async () => {\n    await nextTick();\n    fakeCaret.updatePosition();\n    scheduleDraw();\n  },\n  { flush: 'post' },\n);\n\n// Watch showFakeCaret state\nwatch(\n  fakeCaret.showFakeCaret,\n  (show, prevShow) => {\n    const textarea = props.textareaRef;\n    if (textarea) {\n      setNativeCaretVisible(textarea, !show);\n      // Refresh cached styles when becoming visible (handles theme changes)\n      if (show && !prevShow) {\n        refreshCachedStyles(textarea);\n      }\n    }\n    scheduleDraw();\n  },\n  { immediate: true },\n);\n\n// Watch position and trail changes\nwatch([fakeCaret.caretX, fakeCaret.caretY, fakeCaret.trail], scheduleDraw);\n\n// =============================================================================\n// Lifecycle\n// =============================================================================\n\n// Window resize handler\nif (typeof window !== 'undefined') {\n  window.addEventListener('resize', handleResize, { passive: true });\n}\n\nonBeforeUnmount(() => {\n  if (typeof window !== 'undefined') {\n    window.removeEventListener('resize', handleResize);\n  }\n  reset();\n});\n</script>\n\n<style scoped>\n.ac-fake-caret-overlay {\n  contain: paint;\n}\n\n.ac-fake-caret-canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n}\n\n.ac-fake-caret-caret {\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: 2px;\n  border-radius: 999px;\n  background: var(--ac-accent);\n  box-shadow:\n    0 0 0 1px var(--ac-accent-subtle, rgba(217, 119, 87, 0.2)),\n    0 0 14px var(--ac-accent-subtle, rgba(217, 119, 87, 0.3));\n  will-change: transform;\n  opacity: 0;\n  animation: ac-fake-caret-blink 1.15s step-end infinite;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ac-fake-caret-caret {\n    animation: none;\n  }\n}\n\n@keyframes ac-fake-caret-blink {\n  0%,\n  45% {\n    opacity: 1;\n  }\n  46%,\n  100% {\n    opacity: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/SelectionChip.vue",
    "content": "<template>\n  <div\n    ref=\"chipRef\"\n    class=\"relative inline-flex items-center gap-1.5 text-[11px] leading-none flex-shrink-0 select-none\"\n    :style=\"chipStyle\"\n    @mouseenter=\"handleMouseEnter\"\n    @mouseleave=\"handleMouseLeave\"\n  >\n    <!-- Selection Icon -->\n    <span class=\"inline-flex items-center justify-center w-3.5 h-3.5\" :style=\"iconStyle\">\n      <svg\n        class=\"w-3.5 h-3.5\"\n        fill=\"none\"\n        viewBox=\"0 0 24 24\"\n        stroke=\"currentColor\"\n        aria-hidden=\"true\"\n      >\n        <path\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          stroke-width=\"2\"\n          d=\"M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122\"\n        />\n      </svg>\n    </span>\n\n    <!-- Element Label (tagName only) -->\n    <span class=\"truncate max-w-[140px] px-1 py-0.5\" :style=\"labelStyle\">\n      {{ chipTagName }}\n    </span>\n\n    <!-- \"Selected\" Indicator -->\n    <span class=\"px-1 py-0.5 text-[9px] uppercase tracking-wider\" :style=\"pillStyle\"> sel </span>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, onUnmounted } from 'vue';\nimport type { SelectedElementSummary } from '@/common/web-editor-types';\n\n// =============================================================================\n// Props & Emits\n// =============================================================================\n\nconst props = defineProps<{\n  /** Selected element summary to display */\n  selected: SelectedElementSummary;\n}>();\n\nconst emit = defineEmits<{\n  /** Mouse enter - start highlight */\n  'hover:start': [selected: SelectedElementSummary];\n  /** Mouse leave - clear highlight */\n  'hover:end': [selected: SelectedElementSummary];\n}>();\n\n// =============================================================================\n// Local State\n// =============================================================================\n\nconst chipRef = ref<HTMLDivElement | null>(null);\nconst isHovering = ref(false);\n\n// =============================================================================\n// Computed: UI State\n// =============================================================================\n\n/**\n * Use tagName for compact chip display.\n * Falls back to extracting from label if tagName is not available.\n */\nconst chipTagName = computed(() => {\n  // First try explicit tagName\n  if (props.selected.tagName) {\n    return props.selected.tagName.toLowerCase();\n  }\n  // Fallback: extract from label\n  const label = (props.selected.label || '').trim();\n  const match = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);\n  return match?.[1]?.toLowerCase() || 'element';\n});\n\n// =============================================================================\n// Computed: Styles\n// =============================================================================\n\nconst chipStyle = computed(() => ({\n  backgroundColor: isHovering.value ? 'var(--ac-hover-bg)' : 'var(--ac-surface)',\n  border: `var(--ac-border-width) solid ${isHovering.value ? 'var(--ac-accent)' : 'var(--ac-border)'}`,\n  borderRadius: 'var(--ac-radius-button)',\n  boxShadow: isHovering.value ? 'var(--ac-shadow-card)' : 'none',\n  color: 'var(--ac-text)',\n  cursor: 'default',\n}));\n\nconst iconStyle = computed(() => ({\n  color: 'var(--ac-accent)',\n}));\n\nconst labelStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst pillStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent)',\n  color: 'var(--ac-accent-contrast)',\n  borderRadius: 'var(--ac-radius-button)',\n  fontFamily: 'var(--ac-font-mono)',\n  fontWeight: '600',\n}));\n\n// =============================================================================\n// Event Handlers\n// =============================================================================\n\nfunction handleMouseEnter(): void {\n  isHovering.value = true;\n  emit('hover:start', props.selected);\n}\n\nfunction handleMouseLeave(): void {\n  isHovering.value = false;\n  emit('hover:end', props.selected);\n}\n\n// =============================================================================\n// Lifecycle\n// =============================================================================\n\nonUnmounted(() => {\n  // Clear any active highlight when chip is unmounted\n  // (e.g., when selection changes or element appears in edits)\n  if (isHovering.value) {\n    emit('hover:end', props.selected);\n  }\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/WebEditorChanges.vue",
    "content": "<template>\n  <Transition name=\"changes-slide\">\n    <div v-if=\"showSection\" class=\"mb-2\">\n      <!-- Header Row -->\n      <div class=\"flex items-center justify-between px-1 mb-1.5 gap-2\">\n        <!-- Left: Label + Summary -->\n        <div class=\"flex items-center gap-2 min-w-0\">\n          <span\n            class=\"text-[11px] font-bold uppercase tracking-wider flex-shrink-0\"\n            :style=\"headerLabelStyle\"\n          >\n            {{ headerLabel }}\n          </span>\n          <span class=\"text-[10px] truncate\" :style=\"headerMetaStyle\">\n            {{ summaryText }}\n          </span>\n        </div>\n\n        <!-- Right: View Toggle (only show when there are edits) -->\n        <div\n          v-if=\"hasElements\"\n          class=\"flex items-center gap-0.5 p-0.5 flex-shrink-0\"\n          :style=\"toggleGroupStyle\"\n        >\n          <button\n            type=\"button\"\n            class=\"px-2 py-0.5 text-[10px] transition-colors cursor-pointer\"\n            :style=\"includeButtonStyle\"\n            :aria-pressed=\"viewMode === 'include'\"\n            @click=\"viewMode = 'include'\"\n          >\n            Include ({{ includedCount }})\n          </button>\n          <button\n            type=\"button\"\n            class=\"px-2 py-0.5 text-[10px] transition-colors cursor-pointer\"\n            :style=\"excludeButtonStyle\"\n            :aria-pressed=\"viewMode === 'exclude'\"\n            @click=\"viewMode = 'exclude'\"\n          >\n            Exclude ({{ excludedCount }})\n          </button>\n        </div>\n      </div>\n\n      <!-- Chips Container -->\n      <div class=\"flex gap-1.5 overflow-x-auto ac-scroll-hidden px-1 pb-1\">\n        <!-- Selection-only chip (when selected element is not in edits) -->\n        <SelectionChip\n          v-if=\"showSelectionChip\"\n          :selected=\"tx.selectedElement.value!\"\n          @hover:start=\"handleSelectionHoverStart\"\n          @hover:end=\"handleSelectionHoverEnd\"\n        />\n\n        <!-- Edit chips -->\n        <ElementChip\n          v-for=\"element in visibleElements\"\n          :key=\"element.elementKey\"\n          :element=\"element\"\n          :excluded=\"isExcluded(element.elementKey)\"\n          :selected=\"isSelectedElement(element.elementKey)\"\n          @toggle:exclude=\"handleToggleExclude\"\n          @revert=\"handleRevert\"\n          @hover:start=\"handleHoverStart\"\n          @hover:end=\"handleHoverEnd\"\n        />\n\n        <!-- Empty State (only when no edits and no selection) -->\n        <div\n          v-if=\"visibleElements.length === 0 && !showSelectionChip\"\n          class=\"px-2 py-1 text-[11px] italic\"\n          :style=\"emptyStateStyle\"\n        >\n          {{ emptyStateText }}\n        </div>\n      </div>\n    </div>\n  </Transition>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref, watch, provide, inject, onMounted, onUnmounted, type Ref } from 'vue';\nimport { WEB_EDITOR_TX_STATE_INJECTION_KEY, type WebEditorTxStateReturn } from '../../composables';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport type {\n  ElementChangeSummary,\n  ElementLocator,\n  SelectedElementSummary,\n  WebEditorElementKey,\n  WebEditorHighlightElementPayload,\n  WebEditorRevertElementPayload,\n  WebEditorRevertElementResponse,\n} from '@/common/web-editor-types';\nimport ElementChip from './ElementChip.vue';\nimport SelectionChip from './SelectionChip.vue';\n\n// =============================================================================\n// Inject TX State from Parent (AgentChat.vue)\n// =============================================================================\n\n/**\n * Inject the WebEditorTxState from AgentChat.vue parent.\n * This pattern prevents duplicate listener registration that would occur\n * if each component called useWebEditorTxState() independently.\n *\n * We use a helper function to ensure TypeScript understands tx is non-null after the check.\n */\nfunction injectTxStateOrThrow(): WebEditorTxStateReturn {\n  const injected = inject<WebEditorTxStateReturn>(WEB_EDITOR_TX_STATE_INJECTION_KEY);\n  if (!injected) {\n    throw new Error(\n      '[WebEditorChanges] WebEditorTxState must be provided by parent component. ' +\n        'Ensure AgentChat.vue calls useWebEditorTxState() and provides it via WEB_EDITOR_TX_STATE_INJECTION_KEY.',\n    );\n  }\n  return injected;\n}\n\nconst tx = injectTxStateOrThrow();\n\n// =============================================================================\n// Local State\n// =============================================================================\n\n/** Current view mode: show included or excluded elements */\nconst viewMode = ref<'include' | 'exclude'>('include');\n\n/**\n * Scroll/resize trigger - incremented when scroll or resize events occur.\n * Provided to ElementChip children for tooltip position updates.\n */\nconst scrollResizeTrigger = ref(0);\nprovide<Ref<number>>('scrollResizeTrigger', scrollResizeTrigger);\n\n// =============================================================================\n// Computed: Counts & Elements\n// =============================================================================\n\nconst hasElements = computed(() => tx.allElements.value.length > 0);\nconst includedCount = computed(() => tx.applicableElements.value.length);\nconst excludedCount = computed(() => tx.excludedElements.value.length);\n\n/** Whether to show the section (has edits OR has selection) */\nconst showSection = computed(() => tx.hasContent.value);\n\n/** Show selection-only chip when there's a selection that's not in edits */\nconst showSelectionChip = computed(() => tx.hasSelection.value && !tx.isSelectionInEdits.value);\n\n/** Elements visible based on current view mode */\nconst visibleElements = computed(() =>\n  viewMode.value === 'exclude' ? tx.excludedElements.value : tx.applicableElements.value,\n);\n\n/** Excluded keys as a Set for O(1) lookup */\nconst excludedKeySet = computed(() => new Set(tx.excludedKeys.value));\n\n/** Selected element key for highlighting in edit chips */\nconst selectedKey = computed(() => tx.selectedElement.value?.elementKey ?? null);\n\n// =============================================================================\n// Computed: UI Text\n// =============================================================================\n\n/** Header label - changes based on whether we have edits or just selection */\nconst headerLabel = computed(() => {\n  if (hasElements.value) {\n    return 'Web Edits';\n  }\n  return 'Selected';\n});\n\n/**\n * Extract tagName from selection for compact display.\n */\nfunction getSelectionTagName(sel: typeof tx.selectedElement.value): string {\n  if (!sel) return '';\n  if (sel.tagName) return sel.tagName.toLowerCase();\n  const label = (sel.label || '').trim();\n  const match = label.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);\n  return match?.[1]?.toLowerCase() || 'element';\n}\n\nconst summaryText = computed(() => {\n  const sel = tx.selectedElement.value;\n  const inc = includedCount.value;\n  const exc = excludedCount.value;\n  const selTag = getSelectionTagName(sel);\n\n  // Selection-only mode\n  if (!hasElements.value && sel) {\n    return selTag;\n  }\n\n  // Edits mode with selection\n  if (sel && !tx.isSelectionInEdits.value) {\n    const parts = [`${selTag} selected`];\n    if (inc > 0 || exc > 0) {\n      parts.push(`${inc} edit${inc !== 1 ? 's' : ''}`);\n    }\n    return parts.join(' · ');\n  }\n\n  // Edits only\n  if (exc > 0) {\n    return `${inc} included · ${exc} excluded`;\n  }\n  return `${inc} element${inc !== 1 ? 's' : ''}`;\n});\n\nconst emptyStateText = computed(() => {\n  if (viewMode.value === 'exclude') {\n    return 'No excluded elements.';\n  }\n  if (excludedCount.value > 0) {\n    return 'All changes are excluded.';\n  }\n  return 'No changes yet.';\n});\n\n// =============================================================================\n// Computed: Styles\n// =============================================================================\n\nconst headerLabelStyle = computed(() => ({\n  color: 'var(--ac-text-subtle)',\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst headerMetaStyle = computed(() => ({\n  color: 'var(--ac-text-subtle)',\n  fontFamily: 'var(--ac-font-mono)',\n}));\n\nconst toggleGroupStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst includeButtonStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n  borderRadius: 'var(--ac-radius-button)',\n  backgroundColor: viewMode.value === 'include' ? 'var(--ac-hover-bg)' : 'transparent',\n  color: viewMode.value === 'include' ? 'var(--ac-text)' : 'var(--ac-text-subtle)',\n}));\n\nconst excludeButtonStyle = computed(() => ({\n  fontFamily: 'var(--ac-font-mono)',\n  borderRadius: 'var(--ac-radius-button)',\n  backgroundColor: viewMode.value === 'exclude' ? 'var(--ac-hover-bg)' : 'transparent',\n  color: viewMode.value === 'exclude' ? 'var(--ac-text)' : 'var(--ac-text-subtle)',\n}));\n\nconst emptyStateStyle = computed(() => ({\n  color: 'var(--ac-text-subtle)',\n}));\n\n// =============================================================================\n// Watchers\n// =============================================================================\n\n/**\n * Auto-switch view mode when current view becomes empty.\n * - If viewing included but all are excluded, switch to exclude view\n * - If viewing excluded but none are excluded, switch to include view\n */\nwatch([includedCount, excludedCount], ([inc, exc]) => {\n  if (viewMode.value === 'include' && inc === 0 && exc > 0) {\n    viewMode.value = 'exclude';\n  } else if (viewMode.value === 'exclude' && exc === 0 && inc > 0) {\n    viewMode.value = 'include';\n  }\n});\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isExcluded(key: WebEditorElementKey): boolean {\n  return excludedKeySet.value.has(key);\n}\n\nfunction isSelectedElement(key: WebEditorElementKey): boolean {\n  return selectedKey.value === key;\n}\n\n/**\n * Extract the best selector for element highlighting.\n * Handles frame chain for cross-frame elements.\n */\nfunction extractHighlightSelector(locator: ElementLocator): string | null {\n  const selectors = locator.selectors;\n  if (!selectors || selectors.length === 0) return null;\n\n  const primary = selectors.find((s) => typeof s === 'string' && s.trim())?.trim();\n  if (!primary) return null;\n\n  const frameChain = (locator.frameChain ?? []).map((s) => String(s ?? '').trim()).filter(Boolean);\n\n  if (frameChain.length > 0) {\n    return `${frameChain.join(' |> ')} |> ${primary}`;\n  }\n\n  return primary;\n}\n\n// =============================================================================\n// Highlight Logic\n// =============================================================================\n\n/** Response shape from highlight request */\ninterface HighlightResponse {\n  success: boolean;\n  error?: string;\n  response?: { success: boolean; error?: string };\n}\n\n/**\n * Send highlight request via web-editor channel.\n * Returns true only if the highlight was actually successful (element found and highlighted).\n */\nasync function highlightViaWebEditor(\n  element: ElementChangeSummary,\n  mode: WebEditorHighlightElementPayload['mode'],\n): Promise<boolean> {\n  const tabId = tx.tabId.value;\n  if (!tabId) return false;\n\n  try {\n    const payload: WebEditorHighlightElementPayload = {\n      tabId,\n      elementKey: element.elementKey,\n      locator: element.locator,\n      mode,\n    };\n\n    const result = (await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT,\n      payload,\n    })) as HighlightResponse | undefined;\n\n    // Check both background success and content script success\n    if (!result?.success) return false;\n    if (result.response && !result.response.success) return false;\n\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if element-marker script is already injected.\n */\nasync function isMarkerInjected(tabId: number): Promise<boolean> {\n  try {\n    const response = await Promise.race([\n      chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),\n      new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),\n    ]);\n    return (response as Record<string, unknown>)?.status === 'pong';\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Inject element-marker script if not already present.\n */\nasync function ensureMarkerInjected(tabId: number): Promise<void> {\n  try {\n    if (await isMarkerInjected(tabId)) return;\n\n    await chrome.scripting.executeScript({\n      target: { tabId, allFrames: true },\n      files: ['inject-scripts/element-marker.js'],\n      world: 'ISOLATED',\n    });\n  } catch {\n    // Tab might not support content scripts\n  }\n}\n\n/**\n * Fallback: Highlight element via element-marker script.\n */\nasync function highlightViaElementMarker(element: ElementChangeSummary): Promise<void> {\n  const tabId = tx.tabId.value;\n  if (!tabId) return;\n\n  const selector = extractHighlightSelector(element.locator);\n  if (!selector) return;\n\n  await ensureMarkerInjected(tabId);\n\n  await chrome.tabs.sendMessage(tabId, {\n    action: 'element_marker_highlight',\n    selector,\n    selectorType: 'css',\n    listMode: false,\n  });\n}\n\n// =============================================================================\n// Event Handlers\n// =============================================================================\n\nfunction handleToggleExclude(elementKey: WebEditorElementKey): void {\n  tx.toggleExclude(elementKey);\n}\n\n/**\n * Revert element to its original state (Phase 2 - Selective Undo).\n * Sends request to background, which relays to content script.\n */\nasync function handleRevert(elementKey: WebEditorElementKey): Promise<void> {\n  const tabId = tx.tabId.value;\n  if (!tabId) {\n    console.warn('[WebEditorChanges] Cannot revert: no active tab');\n    return;\n  }\n\n  try {\n    const payload: WebEditorRevertElementPayload = {\n      tabId,\n      elementKey,\n    };\n\n    const result = (await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_REVERT_ELEMENT,\n      payload,\n    })) as WebEditorRevertElementResponse | undefined;\n\n    if (!result?.success) {\n      console.warn('[WebEditorChanges] Revert failed:', result?.error ?? 'Unknown error');\n    }\n    // Note: The TX state will auto-update via the WEB_EDITOR_TX_CHANGED broadcast\n    // triggered by the compensating transaction in the content script.\n  } catch (err) {\n    console.error('[WebEditorChanges] Revert error:', err);\n  }\n}\n\nasync function handleHoverStart(element: ElementChangeSummary): Promise<void> {\n  try {\n    if (typeof chrome === 'undefined') return;\n\n    // Try web-editor channel first\n    const success = await highlightViaWebEditor(element, 'hover');\n    if (success) return;\n\n    // Fallback to element-marker\n    await highlightViaElementMarker(element);\n  } catch {\n    // Silently ignore - tab might not support content scripts\n  }\n}\n\nasync function handleHoverEnd(element: ElementChangeSummary): Promise<void> {\n  try {\n    if (typeof chrome === 'undefined') return;\n    await highlightViaWebEditor(element, 'clear');\n  } catch {\n    // Silently ignore\n  }\n}\n\n/**\n * Handle hover start for selection-only chip.\n */\nasync function handleSelectionHoverStart(selected: SelectedElementSummary): Promise<void> {\n  try {\n    if (typeof chrome === 'undefined') return;\n\n    const tabId = tx.tabId.value;\n    if (!tabId) return;\n\n    const payload: WebEditorHighlightElementPayload = {\n      tabId,\n      elementKey: selected.elementKey,\n      locator: selected.locator,\n      mode: 'hover',\n    };\n\n    await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT,\n      payload,\n    });\n  } catch {\n    // Silently ignore\n  }\n}\n\n/**\n * Handle hover end for selection-only chip.\n */\nasync function handleSelectionHoverEnd(selected: SelectedElementSummary): Promise<void> {\n  try {\n    if (typeof chrome === 'undefined') return;\n\n    const tabId = tx.tabId.value;\n    if (!tabId) return;\n\n    const payload: WebEditorHighlightElementPayload = {\n      tabId,\n      elementKey: selected.elementKey,\n      locator: selected.locator,\n      mode: 'clear',\n    };\n\n    await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_HIGHLIGHT_ELEMENT,\n      payload,\n    });\n  } catch {\n    // Silently ignore\n  }\n}\n\n// =============================================================================\n// Lifecycle - Global scroll/resize handlers (centralized for performance)\n// =============================================================================\n\nlet scrollResizeRAF: number | null = null;\n\nfunction handleScrollOrResize(): void {\n  if (scrollResizeRAF !== null) {\n    cancelAnimationFrame(scrollResizeRAF);\n  }\n  scrollResizeRAF = requestAnimationFrame(() => {\n    scrollResizeTrigger.value++;\n    scrollResizeRAF = null;\n  });\n}\n\nonMounted(() => {\n  window.addEventListener('scroll', handleScrollOrResize, { passive: true, capture: true });\n  window.addEventListener('resize', handleScrollOrResize, { passive: true });\n});\n\nonUnmounted(() => {\n  window.removeEventListener('scroll', handleScrollOrResize, true);\n  window.removeEventListener('resize', handleScrollOrResize);\n  if (scrollResizeRAF !== null) {\n    cancelAnimationFrame(scrollResizeRAF);\n  }\n});\n</script>\n\n<style scoped>\n.changes-slide-enter-active,\n.changes-slide-leave-active {\n  transition: all 0.2s ease;\n}\n\n.changes-slide-enter-from,\n.changes-slide-leave-to {\n  opacity: 0;\n  transform: translateY(-8px);\n}\n\n.changes-slide-enter-to,\n.changes-slide-leave-from {\n  opacity: 1;\n  transform: translateY(0);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/index.ts",
    "content": "/**\n * AgentChat Components\n * Export all components for the redesigned AgentChat UI.\n */\nexport { default as AgentChatShell } from './AgentChatShell.vue';\nexport { default as AgentTopBar } from './AgentTopBar.vue';\nexport { default as AgentComposer } from './AgentComposer.vue';\nexport { default as WebEditorChanges } from './WebEditorChanges.vue';\nexport { default as ElementChip } from './ElementChip.vue';\nexport { default as SelectionChip } from './SelectionChip.vue';\nexport { default as AgentConversation } from './AgentConversation.vue';\nexport { default as AgentRequestThread } from './AgentRequestThread.vue';\nexport { default as AgentTimeline } from './AgentTimeline.vue';\nexport { default as AgentTimelineItem } from './AgentTimelineItem.vue';\nexport { default as AgentSettingsMenu } from './AgentSettingsMenu.vue';\nexport { default as AgentProjectMenu } from './AgentProjectMenu.vue';\nexport { default as AgentSessionMenu } from './AgentSessionMenu.vue';\nexport { default as AgentSessionSettingsPanel } from './AgentSessionSettingsPanel.vue';\nexport { default as AgentSessionsView } from './AgentSessionsView.vue';\nexport { default as AgentSessionListItem } from './AgentSessionListItem.vue';\nexport { default as AgentOpenProjectMenu } from './AgentOpenProjectMenu.vue';\nexport { default as FakeCaretOverlay } from './FakeCaretOverlay.vue';\n\n// Timeline step components\nexport { default as TimelineNarrativeStep } from './timeline/TimelineNarrativeStep.vue';\nexport { default as TimelineToolCallStep } from './timeline/TimelineToolCallStep.vue';\nexport { default as TimelineToolResultCardStep } from './timeline/TimelineToolResultCardStep.vue';\nexport { default as TimelineStatusStep } from './timeline/TimelineStatusStep.vue';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/ThinkingNode.vue",
    "content": "<template>\n  <!-- Use span-based structure to avoid invalid DOM when rendered inside <p> -->\n  <span class=\"thinking-section\">\n    <button\n      type=\"button\"\n      class=\"thinking-header\"\n      :class=\"{ 'thinking-header--expandable': canExpand }\"\n      :aria-expanded=\"canExpand ? expanded : undefined\"\n      :disabled=\"!canExpand\"\n      @click=\"toggle\"\n    >\n      <svg\n        class=\"thinking-icon\"\n        width=\"14\"\n        height=\"14\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        aria-hidden=\"true\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"10\" />\n        <path d=\"M12 16v-4\" />\n        <path d=\"M12 8h.01\" />\n      </svg>\n\n      <span v-if=\"isLoading\" class=\"thinking-loading\">\n        <span class=\"thinking-pulse\" aria-hidden=\"true\" />\n        Thinking...\n      </span>\n      <template v-else>\n        <span class=\"thinking-summary\" v-html=\"formatLine(firstLine)\" />\n        <span v-if=\"canExpand\" class=\"thinking-toggle\">\n          <svg\n            :class=\"{ 'thinking-toggle--expanded': expanded }\"\n            width=\"12\"\n            height=\"12\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            aria-hidden=\"true\"\n          >\n            <polyline points=\"6 9 12 15 18 9\" />\n          </svg>\n          {{ moreCount }} more {{ moreCount === 1 ? 'line' : 'lines' }}\n        </span>\n      </template>\n    </button>\n\n    <Transition name=\"thinking-expand\">\n      <span v-if=\"expanded && !isLoading && restLines.length > 0\" class=\"thinking-content\">\n        <template v-for=\"(line, idx) in restLines\" :key=\"idx\">\n          <span v-html=\"formatLine(line)\" />\n          <br v-if=\"idx < restLines.length - 1\" />\n        </template>\n      </span>\n    </Transition>\n  </span>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\n\n/**\n * Node type from markstream-vue for custom HTML tags.\n * When customHtmlTags=['thinking'] is set, the parser produces nodes with type='thinking'.\n */\ninterface ThinkingNodeType {\n  type: 'thinking';\n  tag?: string;\n  content: string;\n  raw: string;\n  loading?: boolean;\n  autoClosed?: boolean;\n  attrs?: Array<[string, string]>;\n}\n\nconst props = defineProps<{\n  node: ThinkingNodeType;\n  loading?: boolean;\n  indexKey?: string;\n  customId?: string;\n  isDark?: boolean;\n  typewriter?: boolean;\n}>();\n\nconst expanded = ref(false);\n\n/** Whether the node is still loading (streaming, tag not closed yet) */\nconst isLoading = computed(() => props.loading ?? props.node.loading ?? false);\n\n/**\n * Extract inner text from the thinking node.\n * Prefer node.raw over node.content as content may lose line breaks in some cases.\n */\nconst innerText = computed(() => {\n  // Try raw first (more reliable for preserving line breaks)\n  const rawSrc = String(props.node.raw ?? '');\n  if (rawSrc) {\n    const rawMatch = rawSrc.match(/<thinking\\b[^>]*>([\\s\\S]*?)<\\/thinking>/i);\n    if (rawMatch) {\n      return rawMatch[1].trim();\n    }\n  }\n\n  // Fallback to content\n  const src = String(props.node.content ?? '');\n  const match = src.match(/<thinking\\b[^>]*>([\\s\\S]*?)<\\/thinking>/i);\n  if (match) {\n    return match[1].trim();\n  }\n\n  // Strip opening/closing tags if present\n  return src\n    .replace(/^<thinking\\b[^>]*>/i, '')\n    .replace(/<\\/thinking>\\s*$/i, '')\n    .trim();\n});\n\n/** Split content into lines, filtering empty ones */\nconst lines = computed(() => {\n  return innerText.value.split('\\n').filter((line) => line.trim());\n});\n\n/** First line shown as summary */\nconst firstLine = computed(() => {\n  const line = lines.value[0] ?? '';\n  // Strip leading/trailing ** for cleaner display\n  return line.replace(/^\\*\\*/, '').replace(/\\*\\*$/, '');\n});\n\n/** Remaining lines for expanded view */\nconst restLines = computed(() => lines.value.slice(1));\n\n/** Number of additional lines */\nconst moreCount = computed(() => restLines.value.length);\n\n/** Whether the section can be expanded */\nconst canExpand = computed(() => !isLoading.value && moreCount.value > 0);\n\nfunction toggle(): void {\n  if (canExpand.value) {\n    expanded.value = !expanded.value;\n  }\n}\n\n/**\n * Format a line for display, converting **text** to <strong> tags.\n * Used with v-html for both summary and expanded content.\n */\nfunction formatLine(text: string): string {\n  // Escape HTML entities first\n  const escaped = text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n  // Convert **text** to <strong>text</strong>\n  return escaped.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n}\n</script>\n\n<style scoped>\n.thinking-section {\n  display: block;\n  margin: 8px 0;\n  padding-left: 12px;\n  background: var(--ac-surface-muted);\n  border-radius: var(--ac-radius-inner);\n}\n\n.thinking-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  padding: 8px;\n  border: none;\n  background: transparent;\n  color: var(--ac-text-muted);\n  font-size: 13px;\n  font-style: italic;\n  font-family: inherit;\n  text-align: left;\n  cursor: default;\n}\n\n.thinking-header--expandable {\n  cursor: pointer;\n  transition: color 0.15s ease;\n}\n\n.thinking-header--expandable:hover {\n  color: var(--ac-text);\n}\n\n.thinking-header--expandable:focus-visible {\n  outline: 2px solid var(--ac-accent);\n  outline-offset: -2px;\n  border-radius: var(--ac-radius-inner);\n}\n\n.thinking-icon {\n  flex-shrink: 0;\n  opacity: 0.7;\n}\n\n.thinking-loading {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.thinking-pulse {\n  display: inline-block;\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: var(--ac-accent);\n  animation: pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 0.4;\n    transform: scale(0.8);\n  }\n  50% {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.thinking-summary {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.thinking-summary :deep(strong) {\n  font-weight: 600;\n  color: var(--ac-text-muted);\n}\n\n.thinking-toggle {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  flex-shrink: 0;\n  font-size: 11px;\n  color: var(--ac-text-subtle);\n}\n\n.thinking-toggle svg {\n  transition: transform 0.2s ease;\n}\n\n.thinking-toggle--expanded {\n  transform: rotate(180deg);\n}\n\n.thinking-content {\n  display: block;\n  padding: 0 8px 8px;\n  color: var(--ac-text-subtle);\n  font-size: 13px;\n  font-style: italic;\n  line-height: 1.6;\n}\n\n.thinking-content :deep(strong) {\n  font-weight: 600;\n  color: var(--ac-text-muted);\n}\n\n/* Expand animation */\n.thinking-expand-enter-active,\n.thinking-expand-leave-active {\n  transition:\n    opacity 0.2s ease,\n    max-height 0.2s ease;\n  overflow: hidden;\n}\n\n.thinking-expand-enter-from,\n.thinking-expand-leave-to {\n  opacity: 0;\n  max-height: 0;\n}\n\n.thinking-expand-enter-to,\n.thinking-expand-leave-from {\n  opacity: 1;\n  max-height: 500px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineNarrativeStep.vue",
    "content": "<template>\n  <div class=\"py-1\">\n    <div\n      class=\"text-sm leading-relaxed markdown-content\"\n      :style=\"{\n        color: 'var(--ac-text)',\n        fontFamily: 'var(--ac-font-body)',\n      }\"\n    >\n      <MarkdownRender\n        :content=\"item.text ?? ''\"\n        :custom-id=\"AGENTCHAT_MD_SCOPE\"\n        :custom-html-tags=\"CUSTOM_HTML_TAGS\"\n        :max-live-nodes=\"0\"\n        :render-batch-size=\"16\"\n        :render-batch-delay=\"8\"\n      />\n    </div>\n    <span\n      v-if=\"item.isStreaming\"\n      class=\"inline-block w-1.5 h-4 ml-0.5 ac-pulse\"\n      :style=\"{ backgroundColor: 'var(--ac-accent)' }\"\n    />\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport type { TimelineItem } from '../../../composables/useAgentThreads';\nimport MarkdownRender from 'markstream-vue';\nimport 'markstream-vue/index.css';\n// Import to register custom components (side-effect)\nimport { AGENTCHAT_MD_SCOPE } from './markstream-thinking';\n\n/** Custom HTML tags to be rendered by registered custom components */\nconst CUSTOM_HTML_TAGS = ['thinking'] as const;\n\ndefineProps<{\n  item: Extract<TimelineItem, { kind: 'assistant_text' }>;\n}>();\n</script>\n\n<style scoped>\n.markdown-content :deep(pre) {\n  background-color: var(--ac-code-bg);\n  border: var(--ac-border-width) solid var(--ac-code-border);\n  border-radius: var(--ac-radius-inner);\n  padding: 12px;\n  overflow-x: auto;\n}\n\n.markdown-content :deep(code) {\n  font-family: var(--ac-font-mono);\n  font-size: 0.875em;\n  color: var(--ac-code-text);\n}\n\n.markdown-content :deep(p) {\n  margin: 0.5em 0;\n}\n\n.markdown-content :deep(p:first-child) {\n  margin-top: 0;\n}\n\n.markdown-content :deep(p:last-child) {\n  margin-bottom: 0;\n}\n\n.markdown-content :deep(ul),\n.markdown-content :deep(ol) {\n  margin: 0.5em 0;\n  padding-left: 1.5em;\n}\n\n.markdown-content :deep(h1),\n.markdown-content :deep(h2),\n.markdown-content :deep(h3),\n.markdown-content :deep(h4) {\n  margin: 0.75em 0 0.5em;\n  font-weight: 600;\n}\n\n.markdown-content :deep(h1:first-child),\n.markdown-content :deep(h2:first-child),\n.markdown-content :deep(h3:first-child),\n.markdown-content :deep(h4:first-child) {\n  margin-top: 0;\n}\n\n.markdown-content :deep(blockquote) {\n  border-left: var(--ac-border-width-strong) solid var(--ac-border);\n  padding-left: 1em;\n  margin: 0.5em 0;\n  color: var(--ac-text-muted);\n}\n\n.markdown-content :deep(a) {\n  color: var(--ac-link);\n  text-decoration: underline;\n}\n\n.markdown-content :deep(a:hover) {\n  color: var(--ac-link-hover);\n}\n\n.markdown-content :deep(table) {\n  border-collapse: collapse;\n  margin: 0.5em 0;\n  width: 100%;\n}\n\n.markdown-content :deep(th),\n.markdown-content :deep(td) {\n  border: var(--ac-border-width) solid var(--ac-border);\n  padding: 0.5em;\n  text-align: left;\n}\n\n.markdown-content :deep(th) {\n  background-color: var(--ac-surface-muted);\n}\n\n.markdown-content :deep(hr) {\n  border: none;\n  border-top: var(--ac-border-width) solid var(--ac-border);\n  margin: 1em 0;\n}\n\n.markdown-content :deep(img) {\n  max-width: 100%;\n  height: auto;\n  border-radius: var(--ac-radius-inner);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineStatusStep.vue",
    "content": "<template>\n  <div class=\"flex items-center gap-2\">\n    <!-- 螺旋动画图标（仅 running/starting 状态显示，且未被父组件隐藏时） -->\n    <svg\n      v-if=\"isRunning && !hideIcon\"\n      class=\"loading-scribble w-4 h-4 flex-shrink-0\"\n      viewBox=\"0 0 100 100\"\n      fill=\"none\"\n    >\n      <path\n        d=\"M50 50 C50 48, 52 46, 54 46 C58 46, 60 50, 60 54 C60 60, 54 64, 48 64 C40 64, 36 56, 36 48 C36 38, 44 32, 54 32 C66 32, 74 42, 74 54 C74 68, 62 78, 48 78 C32 78, 22 64, 22 48 C22 30, 36 18, 54 18 C74 18, 88 34, 88 54 C88 76, 72 92, 50 92\"\n        stroke=\"var(--ac-accent, #D97757)\"\n        stroke-width=\"3\"\n        stroke-linecap=\"round\"\n      />\n    </svg>\n\n    <!-- shimmer 文案（running 状态）或普通文案 -->\n    <span\n      class=\"text-xs italic\"\n      :class=\"{ 'text-shimmer': isRunning }\"\n      :style=\"{ color: isRunning ? undefined : 'var(--ac-text-muted)' }\"\n    >\n      {{ displayText }}\n    </span>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, onMounted, onUnmounted, watch } from 'vue';\nimport type { TimelineItem } from '../../../composables/useAgentThreads';\nimport { getRandomLoadingText } from '../../../utils/loading-texts';\n\nconst props = defineProps<{\n  item: Extract<TimelineItem, { kind: 'status' }>;\n  /** Hide the loading icon (when parent component displays it in timeline node position) */\n  hideIcon?: boolean;\n}>();\n\n// 是否处于运行状态\nconst isRunning = computed(\n  () => props.item.status === 'running' || props.item.status === 'starting',\n);\n\n// 随机文案（仅 running 状态使用）\nconst randomText = ref(getRandomLoadingText());\n\n// 定时更新文案的 timeout ID\nlet timeoutId: ReturnType<typeof setTimeout> | null = null;\n\n// 记录上一次的运行状态，用于判断状态变化\nlet wasRunning = false;\n\n// 启动定时器\nfunction startInterval(): void {\n  if (timeoutId) return;\n  // 5-8 秒随机间隔更新文案\n  const scheduleNext = () => {\n    timeoutId = setTimeout(\n      () => {\n        randomText.value = getRandomLoadingText();\n        scheduleNext();\n      },\n      5000 + Math.random() * 3000,\n    );\n  };\n  scheduleNext();\n}\n\n// 停止定时器\nfunction stopInterval(): void {\n  if (timeoutId) {\n    clearTimeout(timeoutId);\n    timeoutId = null;\n  }\n}\n\n// 监听运行状态变化 - 只在状态真正变化时才处理\nwatch(isRunning, (running) => {\n  // 只在从非运行变为运行时，才重新生成文案并启动定时器\n  if (running && !wasRunning) {\n    randomText.value = getRandomLoadingText();\n    startInterval();\n  } else if (!running && wasRunning) {\n    stopInterval();\n  }\n  wasRunning = running;\n});\n\n// 初始化\nonMounted(() => {\n  wasRunning = isRunning.value;\n  if (isRunning.value) {\n    startInterval();\n  }\n});\n\nonUnmounted(() => {\n  stopInterval();\n});\n\n// 非运行状态的默认文案\nconst defaultText = computed(() => {\n  switch (props.item.status) {\n    case 'completed':\n      return 'Done';\n    case 'error':\n      return 'Error';\n    case 'cancelled':\n      return 'Cancelled';\n    default:\n      return 'Ready';\n  }\n});\n\n// 最终显示的文案\nconst displayText = computed(() => {\n  if (isRunning.value) {\n    return randomText.value;\n  }\n  return props.item.text || defaultText.value;\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolCallStep.vue",
    "content": "<template>\n  <div class=\"space-y-1\">\n    <div class=\"flex items-baseline gap-2 flex-wrap\">\n      <!-- Label -->\n      <span\n        class=\"text-[11px] font-bold uppercase tracking-wider flex-shrink-0\"\n        :style=\"{\n          color: labelColor,\n        }\"\n      >\n        {{ item.tool.label }}\n      </span>\n\n      <!-- Content based on tool kind -->\n      <code\n        v-if=\"item.tool.kind === 'grep' || item.tool.kind === 'read'\"\n        class=\"text-xs px-1.5 py-0.5 cursor-pointer ac-chip-hover\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-mono)',\n          backgroundColor: 'var(--ac-chip-bg)',\n          color: 'var(--ac-chip-text)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n        :title=\"item.tool.filePath || item.tool.pattern\"\n      >\n        {{ item.tool.title }}\n      </code>\n\n      <span\n        v-else\n        class=\"text-xs\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-mono)',\n          color: 'var(--ac-text-muted)',\n        }\"\n        :title=\"item.tool.filePath || item.tool.command\"\n      >\n        {{ item.tool.title }}\n      </span>\n\n      <!-- Diff Stats Preview (for edit) -->\n      <span\n        v-if=\"hasDiffStats\"\n        class=\"text-[10px] px-1.5 py-0.5\"\n        :style=\"{\n          backgroundColor: 'var(--ac-chip-bg)',\n          color: 'var(--ac-text-muted)',\n          fontFamily: 'var(--ac-font-mono)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n      >\n        <span v-if=\"item.tool.diffStats?.addedLines\" class=\"text-green-600 dark:text-green-400\">\n          +{{ item.tool.diffStats.addedLines }}\n        </span>\n        <span v-if=\"item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines\">/</span>\n        <span v-if=\"item.tool.diffStats?.deletedLines\" class=\"text-red-600 dark:text-red-400\">\n          -{{ item.tool.diffStats.deletedLines }}\n        </span>\n      </span>\n\n      <!-- Streaming indicator -->\n      <span\n        v-if=\"item.isStreaming\"\n        class=\"text-xs italic\"\n        :style=\"{ color: 'var(--ac-text-subtle)' }\"\n      >\n        ...\n      </span>\n    </div>\n\n    <!-- Subtitle (command description or search path) -->\n    <div\n      v-if=\"subtitle\"\n      class=\"text-[10px] pl-10 truncate\"\n      :style=\"{ color: 'var(--ac-text-subtle)' }\"\n      :title=\"subtitleFull\"\n    >\n      {{ subtitle }}\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed } from 'vue';\nimport type { TimelineItem } from '../../../composables/useAgentThreads';\n\nconst props = defineProps<{\n  item: Extract<TimelineItem, { kind: 'tool_use' }>;\n}>();\n\nconst labelColor = computed(() => {\n  if (props.item.tool.kind === 'edit') {\n    return 'var(--ac-accent)';\n  }\n  return 'var(--ac-text-subtle)';\n});\n\nconst hasDiffStats = computed(() => {\n  const stats = props.item.tool.diffStats;\n  if (!stats) return false;\n  return stats.addedLines !== undefined || stats.deletedLines !== undefined;\n});\n\nconst subtitle = computed(() => {\n  const tool = props.item.tool;\n\n  // For commands: show the actual command if title is description\n  if (tool.kind === 'run' && tool.commandDescription && tool.command) {\n    return tool.command.length > 60 ? tool.command.slice(0, 57) + '...' : tool.command;\n  }\n\n  // For file operations: show full path if title is just filename\n  if ((tool.kind === 'edit' || tool.kind === 'read') && tool.filePath) {\n    if (tool.filePath !== tool.title && !tool.title.includes('/')) {\n      return tool.filePath;\n    }\n  }\n\n  // For search: show search path if provided\n  if (tool.kind === 'grep' && tool.searchPath) {\n    return `in ${tool.searchPath}`;\n  }\n\n  return undefined;\n});\n\nconst subtitleFull = computed(() => {\n  const tool = props.item.tool;\n  if (tool.kind === 'run' && tool.command) return tool.command;\n  if (tool.filePath) return tool.filePath;\n  if (tool.searchPath) return tool.searchPath;\n  return undefined;\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineToolResultCardStep.vue",
    "content": "<template>\n  <div class=\"space-y-2\">\n    <!-- Label + Title + Diff Stats -->\n    <div class=\"flex items-baseline gap-2 flex-wrap\">\n      <span\n        class=\"text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0\"\n        :style=\"{ color: labelColor }\"\n      >\n        {{ item.tool.label }}\n      </span>\n      <code\n        class=\"text-xs font-semibold\"\n        :style=\"{\n          fontFamily: 'var(--ac-font-mono)',\n          color: 'var(--ac-text)',\n        }\"\n        :title=\"item.tool.filePath\"\n      >\n        {{ item.tool.title }}\n      </code>\n      <!-- Diff Stats Badge -->\n      <span\n        v-if=\"hasDiffStats\"\n        class=\"text-[10px] px-1.5 py-0.5\"\n        :style=\"{\n          backgroundColor: 'var(--ac-chip-bg)',\n          color: 'var(--ac-text-muted)',\n          fontFamily: 'var(--ac-font-mono)',\n          borderRadius: 'var(--ac-radius-button)',\n        }\"\n      >\n        <span v-if=\"item.tool.diffStats?.addedLines\" class=\"text-green-600 dark:text-green-400\">\n          +{{ item.tool.diffStats.addedLines }}\n        </span>\n        <span v-if=\"item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines\">/</span>\n        <span v-if=\"item.tool.diffStats?.deletedLines\" class=\"text-red-600 dark:text-red-400\">\n          -{{ item.tool.diffStats.deletedLines }}\n        </span>\n        <span\n          v-if=\"\n            !item.tool.diffStats?.addedLines &&\n            !item.tool.diffStats?.deletedLines &&\n            item.tool.diffStats?.totalLines\n          \"\n        >\n          {{ item.tool.diffStats.totalLines }} lines\n        </span>\n      </span>\n    </div>\n\n    <!-- File Path (if different from title) -->\n    <div\n      v-if=\"showFilePath\"\n      class=\"text-[10px] pl-10 truncate\"\n      :style=\"{ color: 'var(--ac-text-subtle)' }\"\n      :title=\"item.tool.filePath\"\n    >\n      {{ item.tool.filePath }}\n    </div>\n\n    <!-- Result Card -->\n    <div\n      v-if=\"showCard\"\n      class=\"overflow-hidden text-xs leading-5\"\n      :style=\"{\n        fontFamily: 'var(--ac-font-mono)',\n        border: 'var(--ac-border-width) solid var(--ac-code-border)',\n        boxShadow: 'var(--ac-shadow-card)',\n        borderRadius: 'var(--ac-radius-inner)',\n      }\"\n    >\n      <!-- File list for edit -->\n      <template v-if=\"item.tool.kind === 'edit' && item.tool.files?.length\">\n        <div\n          v-for=\"(file, idx) in item.tool.files.slice(0, 5)\"\n          :key=\"file\"\n          class=\"px-3 py-1\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface)',\n            borderBottom:\n              idx === Math.min(item.tool.files.length, 5) - 1\n                ? 'none'\n                : 'var(--ac-border-width) solid var(--ac-border)',\n            color: 'var(--ac-text-muted)',\n          }\"\n        >\n          {{ file }}\n        </div>\n        <div\n          v-if=\"item.tool.files.length > 5\"\n          class=\"px-3 py-1 text-[10px]\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted)',\n            color: 'var(--ac-text-subtle)',\n          }\"\n        >\n          +{{ item.tool.files.length - 5 }} more files\n        </div>\n      </template>\n\n      <!-- Command output -->\n      <template v-else-if=\"item.tool.kind === 'run' && item.tool.details\">\n        <div\n          class=\"px-3 py-2 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto ac-scroll\"\n          :style=\"{\n            backgroundColor: 'var(--ac-code-bg)',\n            color: 'var(--ac-code-text)',\n          }\"\n        >\n          {{ truncatedDetails }}\n        </div>\n        <button\n          v-if=\"isDetailsTruncated\"\n          class=\"w-full px-3 py-1 text-[10px] text-left cursor-pointer\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted)',\n            color: 'var(--ac-link)',\n          }\"\n          @click=\"expanded = !expanded\"\n        >\n          {{ expanded ? 'Show less' : 'Show more...' }}\n        </button>\n      </template>\n\n      <!-- Generic details -->\n      <template v-else-if=\"item.tool.details\">\n        <div\n          class=\"px-3 py-2 whitespace-pre-wrap break-words max-h-[150px] overflow-y-auto ac-scroll\"\n          :style=\"{\n            backgroundColor: 'var(--ac-code-bg)',\n            color: 'var(--ac-code-text)',\n          }\"\n        >\n          {{ truncatedDetails }}\n        </div>\n        <button\n          v-if=\"isDetailsTruncated\"\n          class=\"w-full px-3 py-1 text-[10px] text-left cursor-pointer\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface-muted)',\n            color: 'var(--ac-link)',\n          }\"\n          @click=\"expanded = !expanded\"\n        >\n          {{ expanded ? 'Show less' : 'Show more...' }}\n        </button>\n      </template>\n    </div>\n\n    <!-- Error indicator -->\n    <div v-if=\"item.isError\" class=\"text-[11px]\" :style=\"{ color: 'var(--ac-danger)' }\">\n      Error occurred\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed } from 'vue';\nimport type { TimelineItem } from '../../../composables/useAgentThreads';\n\nconst props = defineProps<{\n  item: Extract<TimelineItem, { kind: 'tool_result' }>;\n}>();\n\nconst expanded = ref(false);\nconst MAX_LINES = 10;\nconst MAX_CHARS = 500;\n\nconst labelColor = computed(() => {\n  if (props.item.isError) {\n    return 'var(--ac-danger)';\n  }\n  if (props.item.tool.kind === 'edit') {\n    return 'var(--ac-accent)';\n  }\n  return 'var(--ac-success)';\n});\n\nconst hasDiffStats = computed(() => {\n  const stats = props.item.tool.diffStats;\n  if (!stats) return false;\n  return (\n    stats.addedLines !== undefined ||\n    stats.deletedLines !== undefined ||\n    stats.totalLines !== undefined\n  );\n});\n\nconst showFilePath = computed(() => {\n  const tool = props.item.tool;\n  // Show full path if title is just the filename\n  if (!tool.filePath) return false;\n  return tool.filePath !== tool.title && !tool.title.includes('/');\n});\n\nconst showCard = computed(() => {\n  const tool = props.item.tool;\n  return (\n    (tool.kind === 'edit' && tool.files?.length) ||\n    (tool.kind === 'run' && tool.details) ||\n    tool.details\n  );\n});\n\nconst isDetailsTruncated = computed(() => {\n  const details = props.item.tool.details ?? '';\n  const lines = details.split('\\n');\n  return lines.length > MAX_LINES || details.length > MAX_CHARS;\n});\n\nconst truncatedDetails = computed(() => {\n  const details = props.item.tool.details ?? '';\n  if (expanded.value) {\n    return details;\n  }\n\n  const lines = details.split('\\n');\n  if (lines.length > MAX_LINES) {\n    return lines.slice(0, MAX_LINES).join('\\n');\n  }\n  if (details.length > MAX_CHARS) {\n    return details.slice(0, MAX_CHARS);\n  }\n  return details;\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/TimelineUserPromptStep.vue",
    "content": "<template>\n  <div ref=\"rootRef\" class=\"py-1 space-y-2\">\n    <!-- Text content -->\n    <div\n      v-if=\"hasText\"\n      class=\"text-sm leading-relaxed markdown-content\"\n      :style=\"{\n        color: 'var(--ac-text)',\n        fontFamily: 'var(--ac-font-body)',\n      }\"\n    >\n      <MarkdownRender\n        :content=\"item.text\"\n        :max-live-nodes=\"0\"\n        :render-batch-size=\"16\"\n        :render-batch-delay=\"8\"\n      />\n    </div>\n\n    <!-- Image-only message fallback text -->\n    <span\n      v-else-if=\"item.attachments.length > 0\"\n      class=\"text-xs italic\"\n      :style=\"{ color: 'var(--ac-text-subtle)' }\"\n    >\n      Sent {{ item.attachments.length }} image{{ item.attachments.length === 1 ? '' : 's' }}\n    </span>\n\n    <!-- Attachment thumbnails -->\n    <div v-if=\"item.attachments.length > 0\" class=\"flex flex-wrap gap-2 mt-2\">\n      <button\n        v-for=\"attachment in item.attachments\"\n        :key=\"`${attachment.messageId}:${attachment.index}`\"\n        type=\"button\"\n        class=\"relative group w-16 h-16 rounded-lg overflow-hidden transition-opacity hover:opacity-90 cursor-pointer\"\n        :style=\"{\n          backgroundColor: 'var(--ac-surface-muted)',\n          border: 'var(--ac-border-width) solid var(--ac-border)',\n        }\"\n        :title=\"attachment.originalName\"\n        @click=\"openViewer(attachment)\"\n      >\n        <img\n          v-if=\"getAttachmentUrl(attachment)\"\n          :src=\"getAttachmentUrl(attachment)!\"\n          :alt=\"attachment.originalName\"\n          class=\"w-full h-full object-cover\"\n          loading=\"lazy\"\n        />\n        <!-- Fallback placeholder when server not ready -->\n        <div\n          v-else\n          class=\"w-full h-full flex items-center justify-center\"\n          :style=\"{ color: 'var(--ac-text-subtle)' }\"\n          title=\"Server not ready\"\n        >\n          <svg class=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n            />\n          </svg>\n        </div>\n\n        <!-- Filename overlay on hover -->\n        <div\n          class=\"absolute bottom-0 left-0 right-0 px-0.5 py-0.5 text-[8px] truncate opacity-0 group-hover:opacity-100 transition-opacity\"\n          :style=\"{\n            backgroundColor: 'rgba(0,0,0,0.6)',\n            color: 'white',\n          }\"\n        >\n          {{ attachment.originalName }}\n        </div>\n      </button>\n    </div>\n\n    <!-- Image Viewer Modal (teleported to avoid stacking context issues) -->\n    <Teleport :to=\"overlayTarget\" :disabled=\"!overlayTarget\">\n      <div\n        v-if=\"viewerAttachment\"\n        class=\"fixed inset-0 z-50 flex items-center justify-center\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Image preview\"\n      >\n        <!-- Backdrop -->\n        <div class=\"absolute inset-0 bg-black/60\" @click=\"closeViewer\" />\n\n        <!-- Image container -->\n        <div\n          class=\"relative max-w-[92vw] max-h-[92vh] overflow-hidden\"\n          :style=\"{\n            backgroundColor: 'var(--ac-surface, #ffffff)',\n            border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',\n            borderRadius: 'var(--ac-radius-card, 12px)',\n            boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.2))',\n          }\"\n        >\n          <!-- Close button -->\n          <button\n            type=\"button\"\n            class=\"absolute top-2 right-2 p-1 rounded-full transition-colors hover:bg-black/20 cursor-pointer\"\n            :style=\"{ color: 'white' }\"\n            aria-label=\"Close image preview\"\n            @click=\"closeViewer\"\n          >\n            <svg class=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M6 18L18 6M6 6l12 12\"\n              />\n            </svg>\n          </button>\n\n          <!-- Full-size image -->\n          <img\n            v-if=\"viewerUrl\"\n            :src=\"viewerUrl\"\n            :alt=\"viewerAttachment.originalName\"\n            class=\"block max-w-[92vw] max-h-[92vh] object-contain\"\n          />\n          <div v-else class=\"p-6 text-sm\" :style=\"{ color: 'var(--ac-text-muted, #6e6e6e)' }\">\n            Agent server not ready (missing server port).\n          </div>\n        </div>\n      </div>\n    </Teleport>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, inject, onMounted, ref, watch } from 'vue';\nimport MarkdownRender from 'markstream-vue';\nimport 'markstream-vue/index.css';\nimport { AGENT_SERVER_PORT_KEY, type TimelineItem } from '../../../composables';\n\nconst props = defineProps<{\n  item: Extract<TimelineItem, { kind: 'user_prompt' }>;\n}>();\n\ntype UserPromptItem = Extract<TimelineItem, { kind: 'user_prompt' }>;\ntype UserPromptAttachment = UserPromptItem['attachments'][number];\n\n// Inject server port from parent\nconst serverPort = inject(AGENT_SERVER_PORT_KEY, ref<number | null>(null));\n\n// Compute base URL for attachment requests\nconst baseUrl = computed(() => {\n  const port = serverPort.value;\n  if (!Number.isInteger(port) || port === null || port <= 0) return null;\n  return `http://127.0.0.1:${port}`;\n});\n\n/**\n * Build full URL for an attachment.\n * Ensures urlPath starts with / for proper concatenation.\n */\nfunction getAttachmentUrl(attachment: UserPromptAttachment): string | null {\n  const base = baseUrl.value;\n  if (!base) return null;\n  const path = attachment.urlPath.startsWith('/') ? attachment.urlPath : `/${attachment.urlPath}`;\n  return `${base}${path}`;\n}\n\n// Check if message has text content\nconst hasText = computed(() => (props.item.text || '').trim().length > 0);\n\n// Teleport target for modal overlay\nconst rootRef = ref<HTMLElement | null>(null);\nconst overlayTarget = ref<Element | null>(null);\n\n// Image viewer state\nconst viewerAttachment = ref<UserPromptAttachment | null>(null);\nconst viewerUrl = computed(() => {\n  if (!viewerAttachment.value) return null;\n  return getAttachmentUrl(viewerAttachment.value);\n});\n\nfunction openViewer(attachment: UserPromptAttachment): void {\n  viewerAttachment.value = attachment;\n}\n\nfunction closeViewer(): void {\n  viewerAttachment.value = null;\n}\n\n// Handle Escape key to close viewer\nfunction handleKeydown(e: KeyboardEvent): void {\n  if (e.key === 'Escape' && viewerAttachment.value) {\n    closeViewer();\n  }\n}\n\n// Register/unregister keyboard listener only when viewer is open\nwatch(\n  () => viewerAttachment.value,\n  (current, _prev, onCleanup) => {\n    if (!current) return;\n    document.addEventListener('keydown', handleKeydown);\n    onCleanup(() => document.removeEventListener('keydown', handleKeydown));\n  },\n);\n\nonMounted(() => {\n  // Find teleport target (agent-theme container or body)\n  overlayTarget.value =\n    rootRef.value?.closest('.agent-theme') ?? rootRef.value?.ownerDocument?.body ?? null;\n});\n</script>\n\n<style scoped>\n.markdown-content :deep(pre) {\n  background-color: var(--ac-code-bg);\n  border: var(--ac-border-width) solid var(--ac-code-border);\n  border-radius: var(--ac-radius-inner);\n  padding: 12px;\n  overflow-x: auto;\n}\n\n.markdown-content :deep(code) {\n  font-family: var(--ac-font-mono);\n  font-size: 0.875em;\n  color: var(--ac-code-text);\n}\n\n.markdown-content :deep(p) {\n  margin: 0.5em 0;\n}\n\n.markdown-content :deep(p:first-child) {\n  margin-top: 0;\n}\n\n.markdown-content :deep(p:last-child) {\n  margin-bottom: 0;\n}\n\n.markdown-content :deep(ul),\n.markdown-content :deep(ol) {\n  margin: 0.5em 0;\n  padding-left: 1.5em;\n}\n\n.markdown-content :deep(h1),\n.markdown-content :deep(h2),\n.markdown-content :deep(h3),\n.markdown-content :deep(h4) {\n  margin: 0.75em 0 0.5em;\n  font-weight: 600;\n}\n\n.markdown-content :deep(blockquote) {\n  border-left: var(--ac-border-width-strong) solid var(--ac-border);\n  padding-left: 1em;\n  margin: 0.5em 0;\n  color: var(--ac-text-muted);\n}\n\n.markdown-content :deep(a) {\n  color: var(--ac-link);\n  text-decoration: underline;\n}\n\n.markdown-content :deep(a:hover) {\n  color: var(--ac-link-hover);\n}\n\n.markdown-content :deep(table) {\n  border-collapse: collapse;\n  margin: 0.5em 0;\n  width: 100%;\n}\n\n.markdown-content :deep(th),\n.markdown-content :deep(td) {\n  border: var(--ac-border-width) solid var(--ac-border);\n  padding: 0.5em;\n  text-align: left;\n}\n\n.markdown-content :deep(th) {\n  background-color: var(--ac-surface-muted);\n}\n\n.markdown-content :deep(hr) {\n  border: none;\n  border-top: var(--ac-border-width) solid var(--ac-border);\n  margin: 1em 0;\n}\n\n.markdown-content :deep(img) {\n  max-width: 100%;\n  height: auto;\n  border-radius: var(--ac-radius-inner);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/agent-chat/timeline/markstream-thinking.ts",
    "content": "/**\n * Markstream-vue custom component registration for <thinking> tag.\n *\n * This module registers a custom renderer for <thinking> tags in markdown content.\n * When markstream-vue encounters <thinking>...</thinking>, it will use ThinkingNode.vue\n * to render a collapsible thinking section instead of raw HTML.\n *\n * Usage:\n * 1. Import this module once (side-effect import) to register the component\n * 2. Add `custom-id=\"agentchat\"` and `:custom-html-tags=\"['thinking']\"` to MarkdownRender\n */\nimport { setCustomComponents } from 'markstream-vue';\nimport ThinkingNode from './ThinkingNode.vue';\n\n/** Scope ID for AgentChat markdown rendering */\nexport const AGENTCHAT_MD_SCOPE = 'agentchat';\n\n// Register the thinking node component\nsetCustomComponents(AGENTCHAT_MD_SCOPE, {\n  thinking: ThinkingNode,\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/rr-v3/DebuggerPanel.vue",
    "content": "<template>\n  <div class=\"px-4 py-4 space-y-3\">\n    <!-- Connection Status -->\n    <div\n      class=\"bg-white rounded-lg border border-slate-200 p-3 flex items-center justify-between gap-3\"\n    >\n      <div class=\"flex items-center gap-2 text-sm text-slate-700 min-w-0\">\n        <span :class=\"['inline-flex h-2 w-2 rounded-full shrink-0', connectionDotClass]\" />\n        <span class=\"font-semibold shrink-0\">RR V3 Debugger</span>\n        <span class=\"text-slate-400 shrink-0\">·</span>\n        <span class=\"text-slate-600 truncate\">{{ connectionText }}</span>\n      </div>\n\n      <button\n        class=\"shrink-0 inline-flex items-center justify-center rounded-lg px-3 py-1.5 text-xs font-medium transition border bg-white text-slate-700 border-slate-200 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed\"\n        :disabled=\"rpc.connecting.value || rpc.reconnecting.value\"\n        @click=\"handleReconnect\"\n      >\n        {{ rpc.connecting.value || rpc.reconnecting.value ? 'Reconnecting…' : 'Reconnect' }}\n      </button>\n    </div>\n\n    <!-- Debugger State -->\n    <div class=\"bg-white rounded-lg border border-slate-200 p-4 space-y-3\">\n      <div class=\"flex items-center justify-between gap-3\">\n        <div class=\"text-slate-800 font-semibold\">State</div>\n        <div class=\"text-xs text-slate-400\">\n          <span v-if=\"debuggerClient.busy.value\">Working…</span>\n          <span v-else-if=\"rpc.pendingCount.value > 0\">Pending: {{ rpc.pendingCount.value }}</span>\n        </div>\n      </div>\n\n      <div class=\"grid grid-cols-2 gap-x-4 gap-y-2 text-sm\">\n        <div class=\"text-slate-500\">runId</div>\n        <div class=\"font-mono text-xs text-slate-800 break-all\">\n          {{ runIdDisplay }}\n        </div>\n\n        <div class=\"text-slate-500\">status</div>\n        <div class=\"text-slate-800\">\n          <span\n            :class=\"[\n              'inline-flex items-center px-2 py-0.5 rounded text-xs',\n              debuggerState?.status === 'attached'\n                ? 'bg-emerald-50 text-emerald-700'\n                : 'bg-slate-100 text-slate-600',\n            ]\"\n          >\n            {{ debuggerState?.status ?? '—' }}\n          </span>\n        </div>\n\n        <div class=\"text-slate-500\">execution</div>\n        <div class=\"text-slate-800\">\n          <span\n            :class=\"[\n              'inline-flex items-center px-2 py-0.5 rounded text-xs',\n              debuggerState?.execution === 'paused'\n                ? 'bg-amber-50 text-amber-700'\n                : debuggerState?.execution === 'running'\n                  ? 'bg-blue-50 text-blue-700'\n                  : 'bg-slate-100 text-slate-600',\n            ]\"\n          >\n            {{ debuggerState?.execution ?? '—' }}\n          </span>\n        </div>\n\n        <div class=\"text-slate-500\">currentNodeId</div>\n        <div class=\"font-mono text-xs text-slate-800 break-all\">\n          {{ debuggerState?.currentNodeId ?? '—' }}\n        </div>\n\n        <div class=\"text-slate-500\">pauseReason</div>\n        <div class=\"text-xs text-slate-800\">\n          {{ pauseReasonDisplay }}\n        </div>\n      </div>\n\n      <!-- Control Buttons -->\n      <div class=\"pt-3 border-t border-slate-100 flex flex-wrap gap-2\">\n        <button\n          :class=\"[\n            'inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition border',\n            canAttach\n              ? 'bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600'\n              : 'bg-white text-slate-400 border-slate-200 cursor-not-allowed',\n          ]\"\n          :disabled=\"!canAttach\"\n          @click=\"handleAttach\"\n        >\n          Attach\n        </button>\n        <button\n          :class=\"[\n            'inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition border',\n            canDetach\n              ? 'bg-white text-slate-800 border-slate-200 hover:bg-slate-50'\n              : 'bg-white text-slate-400 border-slate-200 cursor-not-allowed',\n          ]\"\n          :disabled=\"!canDetach\"\n          @click=\"handleDetach\"\n        >\n          Detach\n        </button>\n        <button\n          :class=\"[\n            'inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition border',\n            canPause\n              ? 'bg-amber-500 text-white border-amber-500 hover:bg-amber-600'\n              : 'bg-white text-slate-400 border-slate-200 cursor-not-allowed',\n          ]\"\n          :disabled=\"!canPause\"\n          @click=\"handlePause\"\n        >\n          Pause\n        </button>\n        <button\n          :class=\"[\n            'inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition border',\n            canResume\n              ? 'bg-blue-500 text-white border-blue-500 hover:bg-blue-600'\n              : 'bg-white text-slate-400 border-slate-200 cursor-not-allowed',\n          ]\"\n          :disabled=\"!canResume\"\n          @click=\"handleResume\"\n        >\n          Resume\n        </button>\n        <button\n          :class=\"[\n            'inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition border',\n            canStepOver\n              ? 'bg-white text-slate-800 border-slate-200 hover:bg-slate-50'\n              : 'bg-white text-slate-400 border-slate-200 cursor-not-allowed',\n          ]\"\n          :disabled=\"!canStepOver\"\n          @click=\"handleStepOver\"\n        >\n          Step Over\n        </button>\n      </div>\n\n      <!-- Error Display -->\n      <div v-if=\"errorText\" class=\"text-sm text-red-600 bg-red-50 rounded px-3 py-2\">\n        {{ errorText }}\n      </div>\n    </div>\n\n    <!-- Breakpoints -->\n    <div class=\"bg-white rounded-lg border border-slate-200 p-4\">\n      <div class=\"flex items-center justify-between gap-3 mb-2\">\n        <div class=\"text-slate-800 font-semibold\">Breakpoints</div>\n        <div class=\"text-xs text-slate-400\">{{ breakpoints.length }} total</div>\n      </div>\n\n      <div v-if=\"breakpoints.length === 0\" class=\"text-sm text-slate-500\">No breakpoints set.</div>\n\n      <ul v-else class=\"divide-y divide-slate-100\">\n        <li\n          v-for=\"bp in breakpoints\"\n          :key=\"bp.nodeId\"\n          class=\"py-2 flex items-start justify-between\"\n        >\n          <div class=\"min-w-0\">\n            <div class=\"font-mono text-xs text-slate-800 break-all\">{{ bp.nodeId }}</div>\n          </div>\n          <span\n            class=\"ml-3 inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] whitespace-nowrap\"\n            :class=\"\n              bp.enabled\n                ? 'bg-emerald-50 text-emerald-700 border-emerald-200'\n                : 'bg-slate-50 text-slate-600 border-slate-200'\n            \"\n          >\n            {{ bp.enabled ? 'enabled' : 'disabled' }}\n          </span>\n        </li>\n      </ul>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, onUnmounted, watch } from 'vue';\n\nimport type { DebuggerState } from '@/entrypoints/background/record-replay-v3/domain/debug';\nimport type { PauseReason } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\n\nimport { useRRV3Debugger, useRRV3Rpc } from '../../composables';\n\n// ==================== Props ====================\n\nconst props = defineProps<{\n  runId: RunId;\n}>();\n\n// ==================== Composables ====================\n\nconst normalizedRunId = computed<RunId>(() => String(props.runId ?? '').trim() as RunId);\nconst hasRunId = computed(() => normalizedRunId.value.length > 0);\n\nconst rpc = useRRV3Rpc({ autoConnect: true });\nconst debuggerClient = useRRV3Debugger({\n  rpc,\n  getRunId: () => (hasRunId.value ? normalizedRunId.value : null),\n  autoRefreshOnEvents: true,\n});\n\n// ==================== Computed ====================\n\nconst debuggerState = computed<DebuggerState | null>(() => debuggerClient.state.value);\nconst breakpoints = computed(() => debuggerState.value?.breakpoints ?? []);\nconst runIdDisplay = computed(() => (hasRunId.value ? normalizedRunId.value : '—'));\nconst errorText = computed(() => debuggerClient.lastError.value || rpc.lastError.value);\n\n/**\n * Format PauseReason for display\n */\nfunction formatPauseReason(reason: PauseReason | undefined): string {\n  if (!reason) return '—';\n  switch (reason.kind) {\n    case 'breakpoint':\n      return `Breakpoint at ${reason.nodeId}`;\n    case 'step':\n      return `Step at ${reason.nodeId}`;\n    case 'command':\n      return 'Manual pause';\n    case 'policy':\n      return `Policy: ${reason.reason} at ${reason.nodeId}`;\n    default:\n      return '—';\n  }\n}\n\nconst pauseReasonDisplay = computed(() => formatPauseReason(debuggerState.value?.pauseReason));\n\nconst connectionText = computed(() => {\n  if (rpc.connected.value) return 'Connected';\n  if (rpc.connecting.value) return 'Connecting…';\n  if (rpc.reconnecting.value) return `Reconnecting (attempt ${rpc.reconnectAttempts.value})…`;\n  return 'Disconnected';\n});\n\nconst connectionDotClass = computed(() => {\n  if (rpc.connected.value) return 'bg-emerald-500';\n  if (rpc.connecting.value || rpc.reconnecting.value) return 'bg-amber-500';\n  return 'bg-slate-400';\n});\n\n// Button state - require connection for all actions\nconst isConnected = computed(() => rpc.connected.value);\nconst canAttach = computed(\n  () =>\n    isConnected.value &&\n    hasRunId.value &&\n    !debuggerClient.busy.value &&\n    !debuggerClient.isAttached.value,\n);\nconst canDetach = computed(\n  () =>\n    isConnected.value &&\n    hasRunId.value &&\n    !debuggerClient.busy.value &&\n    debuggerClient.isAttached.value,\n);\nconst canPause = computed(\n  () =>\n    isConnected.value &&\n    hasRunId.value &&\n    !debuggerClient.busy.value &&\n    debuggerClient.isAttached.value &&\n    !debuggerClient.isPaused.value,\n);\nconst canResume = computed(\n  () =>\n    isConnected.value &&\n    hasRunId.value &&\n    !debuggerClient.busy.value &&\n    debuggerClient.isAttached.value &&\n    debuggerClient.isPaused.value,\n);\nconst canStepOver = computed(\n  () =>\n    isConnected.value &&\n    hasRunId.value &&\n    !debuggerClient.busy.value &&\n    debuggerClient.isAttached.value &&\n    debuggerClient.isPaused.value,\n);\n\n// ==================== Handlers ====================\n\nasync function handleReconnect(): Promise<void> {\n  rpc.disconnect('Manual reconnect');\n  const connected = await rpc.connect();\n  if (!connected) return; // Connection failed, error already displayed\n\n  if (hasRunId.value) {\n    await rpc.subscribe(normalizedRunId.value);\n    await debuggerClient.attach();\n  }\n}\n\nasync function handleAttach(): Promise<void> {\n  const response = await debuggerClient.attach();\n  if (response.ok && hasRunId.value) {\n    // Subscribe to events for this run\n    await rpc.subscribe(normalizedRunId.value);\n  }\n}\n\nasync function handleDetach(): Promise<void> {\n  if (hasRunId.value) {\n    // Unsubscribe from events\n    await rpc.unsubscribe(normalizedRunId.value);\n  }\n  await debuggerClient.detach();\n}\n\nasync function handlePause(): Promise<void> {\n  await debuggerClient.pause();\n}\n\nasync function handleResume(): Promise<void> {\n  await debuggerClient.resume();\n}\n\nasync function handleStepOver(): Promise<void> {\n  await debuggerClient.stepOver();\n}\n\n// ==================== Auto-attach ====================\n\n// Track current subscribed runId for cleanup\nlet currentSubscribedRunId: RunId | null = null;\nlet attachToken = 0;\n\nwatch(\n  normalizedRunId,\n  async (next, prev) => {\n    const nextId = String(next ?? '').trim();\n    if (!nextId) return;\n\n    const token = ++attachToken;\n\n    // Cleanup previous subscription and detach\n    const prevId = String(prev ?? '').trim();\n    if (prevId && prevId !== nextId) {\n      if (currentSubscribedRunId === prevId) {\n        await rpc.unsubscribe(prevId as RunId);\n        currentSubscribedRunId = null;\n      }\n      await debuggerClient.detach(prevId as RunId);\n      if (token !== attachToken) return; // Cancelled\n    }\n\n    // Attach and subscribe to new run\n    const response = await debuggerClient.attach(nextId as RunId);\n    if (token !== attachToken) return; // Cancelled\n\n    if (response.ok) {\n      await rpc.subscribe(nextId as RunId);\n      currentSubscribedRunId = nextId as RunId;\n    }\n  },\n  { immediate: true },\n);\n\n// Cleanup on unmount\nonUnmounted(async () => {\n  if (currentSubscribedRunId) {\n    await rpc.unsubscribe(currentSubscribedRunId);\n  }\n});\n</script>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowListItem.vue",
    "content": "<template>\n  <div\n    class=\"workflow-item\"\n    :style=\"itemStyle\"\n    @mouseenter=\"showActions = true\"\n    @mouseleave=\"showActions = false\"\n  >\n    <div class=\"workflow-content\">\n      <!-- Title and description -->\n      <div class=\"workflow-info\">\n        <div class=\"workflow-name\" :style=\"nameStyle\">{{ flow.name || 'Untitled' }}</div>\n        <div class=\"workflow-desc\" :style=\"descStyle\">{{\n          flow.description || 'No description'\n        }}</div>\n        <!-- Tags -->\n        <div v-if=\"hasTags\" class=\"workflow-tags\">\n          <span v-if=\"flow.meta?.domain\" class=\"workflow-tag\" :style=\"tagDomainStyle\">\n            {{ flow.meta.domain }}\n          </span>\n          <span\n            v-for=\"tag in flow.meta?.tags || []\"\n            :key=\"tag\"\n            class=\"workflow-tag\"\n            :style=\"tagStyle\"\n          >\n            {{ tag }}\n          </span>\n        </div>\n      </div>\n\n      <!-- Actions -->\n      <div class=\"workflow-actions\" :class=\"{ 'workflow-actions-visible': showActions }\">\n        <button\n          class=\"workflow-action workflow-action-primary\"\n          :style=\"actionPrimaryStyle\"\n          @click.stop=\"$emit('run', flow.id)\"\n          title=\"Run workflow\"\n        >\n          <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"currentColor\">\n            <path d=\"M8 5v14l11-7z\" />\n          </svg>\n        </button>\n        <button\n          class=\"workflow-action\"\n          :style=\"actionStyle\"\n          @click.stop=\"$emit('edit', flow.id)\"\n          title=\"Edit workflow\"\n        >\n          <svg\n            viewBox=\"0 0 24 24\"\n            width=\"16\"\n            height=\"16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\"\n            />\n          </svg>\n        </button>\n        <button\n          class=\"workflow-action workflow-action-more\"\n          :style=\"actionStyle\"\n          @click.stop=\"toggleMoreMenu\"\n          title=\"More actions\"\n        >\n          <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"currentColor\">\n            <circle cx=\"12\" cy=\"5\" r=\"2\" />\n            <circle cx=\"12\" cy=\"12\" r=\"2\" />\n            <circle cx=\"12\" cy=\"19\" r=\"2\" />\n          </svg>\n        </button>\n\n        <!-- More menu dropdown -->\n        <Transition name=\"menu-fade\">\n          <div v-if=\"showMoreMenu\" class=\"workflow-more-menu\" :style=\"menuStyle\" @click.stop>\n            <button class=\"workflow-menu-item\" :style=\"menuItemStyle\" @click=\"handleExport\">\n              <svg\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12\"\n                />\n              </svg>\n              <span>Export</span>\n            </button>\n            <button\n              class=\"workflow-menu-item workflow-menu-item-danger\"\n              :style=\"menuItemDangerStyle\"\n              @click=\"handleDelete\"\n            >\n              <svg\n                viewBox=\"0 0 24 24\"\n                width=\"16\"\n                height=\"16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n                />\n              </svg>\n              <span>Delete</span>\n            </button>\n          </div>\n        </Transition>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed, onMounted, onUnmounted } from 'vue';\n\ninterface FlowLite {\n  id: string;\n  name: string;\n  description?: string;\n  meta?: {\n    domain?: string;\n    tags?: string[];\n    bindings?: any[];\n  };\n}\n\nconst props = defineProps<{\n  flow: FlowLite;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'run', id: string): void;\n  (e: 'edit', id: string): void;\n  (e: 'delete', id: string): void;\n  (e: 'export', id: string): void;\n}>();\n\nconst showActions = ref(false);\nconst showMoreMenu = ref(false);\n\nconst hasTags = computed(() => {\n  return props.flow.meta?.domain || (props.flow.meta?.tags?.length ?? 0) > 0;\n});\n\n// Close menu when clicking outside\nfunction handleClickOutside(e: MouseEvent) {\n  if (showMoreMenu.value) {\n    showMoreMenu.value = false;\n  }\n}\n\nonMounted(() => {\n  document.addEventListener('click', handleClickOutside);\n});\n\nonUnmounted(() => {\n  document.removeEventListener('click', handleClickOutside);\n});\n\nfunction toggleMoreMenu() {\n  showMoreMenu.value = !showMoreMenu.value;\n}\n\nfunction handleDelete() {\n  showMoreMenu.value = false;\n  emit('delete', props.flow.id);\n}\n\nfunction handleExport() {\n  showMoreMenu.value = false;\n  emit('export', props.flow.id);\n}\n\n// Computed styles using CSS variables\nconst itemStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  borderRadius: 'var(--ac-radius-card, 12px)',\n  border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4)',\n  transition: 'all var(--ac-motion-fast, 120ms) ease',\n}));\n\nconst nameStyle = computed(() => ({\n  color: 'var(--ac-text, #1a1a1a)',\n}));\n\nconst descStyle = computed(() => ({\n  color: 'var(--ac-text-muted, #6e6e6e)',\n}));\n\nconst tagDomainStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent-subtle, rgba(217, 119, 87, 0.12))',\n  color: 'var(--ac-accent, #d97757)',\n}));\n\nconst tagStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',\n  color: 'var(--ac-text-muted, #6e6e6e)',\n}));\n\nconst actionStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',\n  color: 'var(--ac-text-muted, #6e6e6e)',\n  borderRadius: 'var(--ac-radius-button, 8px)',\n}));\n\nconst actionPrimaryStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent, #d97757)',\n  color: 'var(--ac-accent-contrast, #ffffff)',\n  borderRadius: 'var(--ac-radius-button, 8px)',\n}));\n\nconst menuStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface, #ffffff)',\n  border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4)',\n  borderRadius: 'var(--ac-radius-inner, 8px)',\n  boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.1))',\n}));\n\nconst menuItemStyle = computed(() => ({\n  color: 'var(--ac-text, #1a1a1a)',\n}));\n\nconst menuItemDangerStyle = computed(() => ({\n  color: 'var(--ac-danger, #ef4444)',\n}));\n</script>\n\n<style scoped>\n.workflow-item {\n  padding: 16px;\n  cursor: pointer;\n}\n\n.workflow-item:hover {\n  background-color: var(--ac-hover-bg, #f5f5f4) !important;\n  box-shadow: var(--ac-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));\n}\n\n.workflow-content {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.workflow-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.workflow-name {\n  font-size: 14px;\n  font-weight: 600;\n  line-height: 1.4;\n  margin-bottom: 2px;\n  word-break: break-word;\n}\n\n.workflow-desc {\n  font-size: 13px;\n  line-height: 1.4;\n  margin-bottom: 8px;\n  word-break: break-word;\n}\n\n.workflow-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.workflow-tag {\n  padding: 2px 8px;\n  font-size: 11px;\n  font-weight: 500;\n  border-radius: 4px;\n  white-space: nowrap;\n}\n\n.workflow-actions {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  opacity: 0;\n  transition: opacity var(--ac-motion-fast, 120ms) ease;\n  position: relative;\n}\n\n.workflow-actions-visible {\n  opacity: 1;\n}\n\n.workflow-action {\n  width: 32px;\n  height: 32px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.workflow-action:hover {\n  transform: translateY(-1px);\n  box-shadow: var(--ac-shadow-float, 0 4px 20px -2px rgba(0, 0, 0, 0.05));\n}\n\n.workflow-action-primary:hover {\n  background-color: var(--ac-accent-hover, #c4664a) !important;\n}\n\n.workflow-more-menu {\n  position: absolute;\n  top: 100%;\n  right: 0;\n  margin-top: 4px;\n  min-width: 140px;\n  padding: 4px;\n  z-index: 100;\n}\n\n.workflow-menu-item {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 12px;\n  font-size: 13px;\n  background: transparent;\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  cursor: pointer;\n  transition: background-color var(--ac-motion-fast, 120ms) ease;\n  text-align: left;\n}\n\n.workflow-menu-item:hover {\n  background-color: var(--ac-hover-bg, #f5f5f4);\n}\n\n.workflow-menu-item-danger:hover {\n  background-color: rgba(239, 68, 68, 0.1);\n}\n\n/* Menu fade transition */\n.menu-fade-enter-active,\n.menu-fade-leave-active {\n  transition:\n    opacity var(--ac-motion-fast, 120ms) ease,\n    transform var(--ac-motion-fast, 120ms) ease;\n}\n\n.menu-fade-enter-from,\n.menu-fade-leave-to {\n  opacity: 0;\n  transform: translateY(-4px);\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/workflows/WorkflowsView.vue",
    "content": "<template>\n  <div class=\"h-full flex flex-col\" :style=\"containerStyle\">\n    <!-- Fixed Header: Search + Actions -->\n    <div class=\"flex-shrink-0 px-4 py-3 border-b\" :style=\"headerStyle\">\n      <div class=\"flex items-center gap-2\">\n        <!-- Search Input -->\n        <div class=\"flex-1 relative\">\n          <svg\n            class=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"2\"\n              d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"\n            />\n          </svg>\n          <input\n            v-model=\"searchQuery\"\n            type=\"text\"\n            placeholder=\"Search workflows...\"\n            class=\"w-full pl-9 pr-3 py-2 text-sm\"\n            :style=\"inputStyle\"\n          />\n        </div>\n\n        <!-- Refresh Button -->\n        <button\n          class=\"flex-shrink-0 p-2\"\n          :style=\"refreshButtonStyle\"\n          @click=\"$emit('refresh')\"\n          title=\"Refresh\"\n        >\n          <svg\n            class=\"w-4 h-4\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n            />\n          </svg>\n        </button>\n\n        <!-- New Workflow Button -->\n        <button\n          class=\"flex-shrink-0 px-3 py-2 text-sm font-medium\"\n          :style=\"newButtonStyle\"\n          @click=\"$emit('create')\"\n        >\n          <span class=\"flex items-center gap-1\">\n            <svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                stroke-width=\"2\"\n                d=\"M12 4v16m8-8H4\"\n              />\n            </svg>\n            New\n          </span>\n        </button>\n      </div>\n\n      <!-- Filter Bar -->\n      <div class=\"flex items-center justify-between mt-3\">\n        <label\n          class=\"flex items-center gap-2 text-sm cursor-pointer\"\n          :style=\"{ color: 'var(--ac-text-muted)' }\"\n        >\n          <input\n            type=\"checkbox\"\n            :checked=\"onlyBound\"\n            @change=\"$emit('update:onlyBound', ($event.target as HTMLInputElement).checked)\"\n            class=\"workflow-checkbox\"\n          />\n          <span>Current page only</span>\n        </label>\n        <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text-subtle)' }\">\n          {{ filteredFlows.length }} workflow{{ filteredFlows.length !== 1 ? 's' : '' }}\n        </span>\n      </div>\n    </div>\n\n    <!-- Scrollable Content -->\n    <div class=\"flex-1 overflow-y-auto ac-scroll\">\n      <!-- Empty State -->\n      <div\n        v-if=\"filteredFlows.length === 0\"\n        class=\"flex flex-col items-center justify-center py-12 px-4\"\n      >\n        <div\n          class=\"w-16 h-16 rounded-full flex items-center justify-center mb-4\"\n          :style=\"{ backgroundColor: 'var(--ac-surface-muted)' }\"\n        >\n          <svg\n            class=\"w-8 h-8\"\n            :style=\"{ color: 'var(--ac-text-subtle)' }\"\n            fill=\"none\"\n            viewBox=\"0 0 24 24\"\n            stroke=\"currentColor\"\n          >\n            <path\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"1.5\"\n              d=\"M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z\"\n            />\n          </svg>\n        </div>\n        <div class=\"text-sm font-medium mb-1\" :style=\"{ color: 'var(--ac-text)' }\">\n          {{ searchQuery ? 'No matching workflows' : 'No workflows yet' }}\n        </div>\n        <div class=\"text-xs text-center mb-4\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n          {{\n            searchQuery ? 'Try a different search term' : 'Record your first automation workflow'\n          }}\n        </div>\n        <button\n          v-if=\"!searchQuery\"\n          class=\"px-4 py-2 text-sm font-medium\"\n          :style=\"newButtonStyle\"\n          @click=\"$emit('create')\"\n        >\n          Create Workflow\n        </button>\n      </div>\n\n      <!-- Workflow List -->\n      <div v-else class=\"px-4 py-3 space-y-3\">\n        <WorkflowListItem\n          v-for=\"flow in filteredFlows\"\n          :key=\"flow.id\"\n          :flow=\"flow\"\n          @run=\"$emit('run', $event)\"\n          @edit=\"$emit('edit', $event)\"\n          @delete=\"$emit('delete', $event)\"\n          @export=\"$emit('export', $event)\"\n        />\n      </div>\n\n      <!-- Advanced Settings (Collapsible) -->\n      <div class=\"px-4 pb-4\">\n        <div class=\"advanced-divider\" :style=\"dividerStyle\">\n          <span\n            :style=\"{\n              backgroundColor: 'var(--ac-surface)',\n              padding: '0 12px',\n              color: 'var(--ac-text-subtle)',\n            }\"\n          >\n            Advanced\n          </span>\n        </div>\n\n        <!-- Run History Section -->\n        <div class=\"advanced-section\" :style=\"sectionStyle\">\n          <button\n            class=\"advanced-section-header\"\n            :style=\"sectionHeaderStyle\"\n            @click=\"toggleSection('runs')\"\n          >\n            <div class=\"flex items-center gap-2\">\n              <svg\n                class=\"w-4 h-4 transition-transform\"\n                :class=\"{ 'rotate-90': expandedSections.has('runs') }\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n              <span>Run History</span>\n            </div>\n            <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text-subtle)' }\">{{\n              runs.length\n            }}</span>\n          </button>\n\n          <Transition name=\"section-expand\">\n            <div v-if=\"expandedSections.has('runs')\" class=\"advanced-section-content\">\n              <div\n                v-if=\"runs.length === 0\"\n                class=\"text-sm py-3\"\n                :style=\"{ color: 'var(--ac-text-muted)' }\"\n              >\n                No run history yet\n              </div>\n              <div v-else class=\"space-y-2 py-2\">\n                <div\n                  v-for=\"run in runs.slice(0, 5)\"\n                  :key=\"run.id\"\n                  class=\"run-item\"\n                  :style=\"runItemStyle\"\n                  @click=\"$emit('toggleRun', run.id)\"\n                >\n                  <div class=\"flex items-center justify-between\">\n                    <div class=\"flex items-center gap-2\">\n                      <span\n                        class=\"w-2 h-2 rounded-full\"\n                        :class=\"{ 'animate-pulse': run.isInProgress }\"\n                        :style=\"{ backgroundColor: getRunStatusColor(run) }\"\n                      ></span>\n                      <span class=\"text-sm\" :style=\"{ color: 'var(--ac-text)' }\">{{\n                        getFlowName(run.flowId)\n                      }}</span>\n                      <span\n                        v-if=\"run.status\"\n                        class=\"text-xs px-1.5 py-0.5 rounded\"\n                        :style=\"{\n                          backgroundColor: run.isInProgress\n                            ? 'var(--ac-primary-light, #dbeafe)'\n                            : run.success\n                              ? 'var(--ac-success-light, #dcfce7)'\n                              : 'var(--ac-danger-light, #fee2e2)',\n                          color: getRunStatusColor(run),\n                        }\"\n                      >\n                        {{ getRunStatusText(run) }}\n                      </span>\n                    </div>\n                    <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text-subtle)' }\">\n                      {{ formatTime(run.startedAt) }}\n                    </span>\n                  </div>\n                  <!-- Run details (if expanded) -->\n                  <div\n                    v-if=\"openRunId === run.id\"\n                    class=\"mt-2 pt-2 border-t\"\n                    :style=\"{ borderColor: 'var(--ac-border)' }\"\n                  >\n                    <!-- V3: Show status info when no entries -->\n                    <div\n                      v-if=\"run.entries.length === 0 && run.status\"\n                      class=\"text-xs py-1\"\n                      :style=\"{ color: 'var(--ac-text-muted)' }\"\n                    >\n                      <div class=\"flex items-center gap-2\">\n                        <span>状态: {{ getRunStatusText(run) }}</span>\n                        <span v-if=\"run.finishedAt\"\n                          >• 耗时:\n                          {{\n                            Math.round(\n                              (new Date(run.finishedAt).getTime() -\n                                new Date(run.startedAt).getTime()) /\n                                1000,\n                            )\n                          }}s</span\n                        >\n                      </div>\n                    </div>\n                    <!-- V2: Show entries -->\n                    <div\n                      v-for=\"(entry, idx) in run.entries\"\n                      :key=\"idx\"\n                      class=\"text-xs py-1\"\n                      :style=\"{\n                        color:\n                          entry.status === 'failed' ? 'var(--ac-danger)' : 'var(--ac-text-muted)',\n                      }\"\n                    >\n                      #{{ idx + 1 }} {{ entry.status }} - step={{ entry.stepId }}\n                      <span v-if=\"entry.tookMs\" class=\"ml-2\">{{ entry.tookMs }}ms</span>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </Transition>\n        </div>\n\n        <!-- Triggers Section -->\n        <div class=\"advanced-section\" :style=\"sectionStyle\">\n          <button\n            class=\"advanced-section-header\"\n            :style=\"sectionHeaderStyle\"\n            @click=\"toggleSection('triggers')\"\n          >\n            <div class=\"flex items-center gap-2\">\n              <svg\n                class=\"w-4 h-4 transition-transform\"\n                :class=\"{ 'rotate-90': expandedSections.has('triggers') }\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n              >\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\" />\n              </svg>\n              <span>Triggers</span>\n            </div>\n            <div class=\"flex items-center gap-2\">\n              <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text-subtle)' }\">{{\n                triggers.length\n              }}</span>\n              <button\n                class=\"trigger-add-btn\"\n                :style=\"triggerAddStyle\"\n                @click.stop=\"$emit('createTrigger')\"\n                title=\"Add trigger\"\n              >\n                <svg\n                  class=\"w-3 h-3\"\n                  fill=\"none\"\n                  viewBox=\"0 0 24 24\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                >\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M12 4v16m8-8H4\" />\n                </svg>\n              </button>\n            </div>\n          </button>\n\n          <Transition name=\"section-expand\">\n            <div v-if=\"expandedSections.has('triggers')\" class=\"advanced-section-content\">\n              <div\n                v-if=\"triggers.length === 0\"\n                class=\"text-sm py-3\"\n                :style=\"{ color: 'var(--ac-text-muted)' }\"\n              >\n                No triggers configured\n              </div>\n              <div v-else class=\"space-y-2 py-2\">\n                <div\n                  v-for=\"trigger in triggers\"\n                  :key=\"trigger.id\"\n                  class=\"trigger-item\"\n                  :style=\"triggerItemStyle\"\n                >\n                  <div class=\"flex items-center justify-between\">\n                    <div class=\"flex items-center gap-2\">\n                      <span\n                        class=\"w-2 h-2 rounded-full\"\n                        :style=\"{\n                          backgroundColor:\n                            trigger.enabled !== false\n                              ? 'var(--ac-success)'\n                              : 'var(--ac-text-subtle)',\n                        }\"\n                      ></span>\n                      <span class=\"text-sm font-medium\" :style=\"{ color: 'var(--ac-text)' }\">{{\n                        trigger.type\n                      }}</span>\n                      <span class=\"text-xs\" :style=\"{ color: 'var(--ac-text-muted)' }\">\n                        {{ getFlowName(trigger.flowId) }}\n                      </span>\n                    </div>\n                    <div class=\"flex items-center gap-1\">\n                      <button\n                        class=\"trigger-action\"\n                        :style=\"triggerActionStyle\"\n                        @click=\"$emit('editTrigger', trigger.id)\"\n                        title=\"Edit\"\n                      >\n                        <svg\n                          class=\"w-3.5 h-3.5\"\n                          fill=\"none\"\n                          viewBox=\"0 0 24 24\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"2\"\n                        >\n                          <path\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                            d=\"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\"\n                          />\n                        </svg>\n                      </button>\n                      <button\n                        class=\"trigger-action trigger-action-danger\"\n                        :style=\"triggerActionDangerStyle\"\n                        @click=\"$emit('removeTrigger', trigger.id)\"\n                        title=\"Delete\"\n                      >\n                        <svg\n                          class=\"w-3.5 h-3.5\"\n                          fill=\"none\"\n                          viewBox=\"0 0 24 24\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"2\"\n                        >\n                          <path\n                            stroke-linecap=\"round\"\n                            stroke-linejoin=\"round\"\n                            d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\"\n                          />\n                        </svg>\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </Transition>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { ref, computed } from 'vue';\nimport WorkflowListItem from './WorkflowListItem.vue';\n\ninterface FlowLite {\n  id: string;\n  name: string;\n  description?: string;\n  meta?: {\n    domain?: string;\n    tags?: string[];\n    bindings?: any[];\n  };\n}\n\ninterface RunLite {\n  id: string;\n  flowId: string;\n  startedAt: string;\n  finishedAt?: string;\n  success?: boolean;\n  /** Whether the run is still in progress (queued/running/paused) */\n  isInProgress?: boolean;\n  /** V3 run status */\n  status?: 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'canceled';\n  entries: any[];\n}\n\ninterface Trigger {\n  id: string;\n  type: string;\n  flowId: string;\n  enabled?: boolean;\n  [key: string]: any;\n}\n\nconst props = defineProps<{\n  flows: FlowLite[];\n  runs: RunLite[];\n  triggers: Trigger[];\n  onlyBound: boolean;\n  openRunId: string | null;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'refresh'): void;\n  (e: 'create'): void;\n  (e: 'run', id: string): void;\n  (e: 'edit', id: string): void;\n  (e: 'delete', id: string): void;\n  (e: 'export', id: string): void;\n  (e: 'update:onlyBound', value: boolean): void;\n  (e: 'toggleRun', id: string): void;\n  (e: 'createTrigger'): void;\n  (e: 'editTrigger', id: string): void;\n  (e: 'removeTrigger', id: string): void;\n}>();\n\n// Local state\nconst searchQuery = ref('');\nconst expandedSections = ref<Set<string>>(new Set());\n\n// Filtered flows based on search\nconst filteredFlows = computed(() => {\n  const query = searchQuery.value.trim().toLowerCase();\n  if (!query) return props.flows;\n\n  return props.flows.filter((flow) => {\n    const name = (flow.name || '').toLowerCase();\n    const desc = (flow.description || '').toLowerCase();\n    const domain = (flow.meta?.domain || '').toLowerCase();\n    const tags = (flow.meta?.tags || []).join(' ').toLowerCase();\n\n    return (\n      name.includes(query) || desc.includes(query) || domain.includes(query) || tags.includes(query)\n    );\n  });\n});\n\n// Helper functions\nfunction getFlowName(flowId: string): string {\n  const flow = props.flows.find((f) => f.id === flowId);\n  return flow?.name || flowId;\n}\n\n/**\n * Get the status color for a run\n * - In progress (queued/running/paused): blue/primary\n * - Succeeded: green/success\n * - Failed/canceled: red/danger\n */\nfunction getRunStatusColor(run: RunLite): string {\n  // V3 style: check isInProgress first\n  if (run.isInProgress) {\n    return 'var(--ac-primary, #3b82f6)';\n  }\n  // V3 style: check status\n  if (run.status) {\n    if (run.status === 'succeeded') return 'var(--ac-success, #22c55e)';\n    if (run.status === 'failed' || run.status === 'canceled') return 'var(--ac-danger, #ef4444)';\n    // queued/running/paused - should be caught by isInProgress but just in case\n    return 'var(--ac-primary, #3b82f6)';\n  }\n  // V2 fallback: use success boolean\n  return run.success ? 'var(--ac-success, #22c55e)' : 'var(--ac-danger, #ef4444)';\n}\n\n/**\n * Get the status text for a run\n */\nfunction getRunStatusText(run: RunLite): string {\n  if (run.status) {\n    const statusMap: Record<string, string> = {\n      queued: '排队中',\n      running: '运行中',\n      paused: '已暂停',\n      succeeded: '成功',\n      failed: '失败',\n      canceled: '已取消',\n    };\n    return statusMap[run.status] || run.status;\n  }\n  // V2 fallback\n  return run.success ? '成功' : '失败';\n}\n\nfunction formatTime(dateStr: string): string {\n  const date = new Date(dateStr);\n  return date.toLocaleString();\n}\n\nfunction toggleSection(section: string) {\n  if (expandedSections.value.has(section)) {\n    expandedSections.value.delete(section);\n  } else {\n    expandedSections.value.add(section);\n  }\n  expandedSections.value = new Set(expandedSections.value);\n}\n\n// Computed styles\nconst containerStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n}));\n\nconst headerStyle = computed(() => ({\n  borderColor: 'var(--ac-border)',\n  backgroundColor: 'var(--ac-surface)',\n}));\n\nconst inputStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-button)',\n  color: 'var(--ac-text)',\n  outline: 'none',\n}));\n\nconst refreshButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  color: 'var(--ac-text-muted)',\n  borderRadius: 'var(--ac-radius-button)',\n  border: 'none',\n}));\n\nconst newButtonStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent)',\n  color: 'var(--ac-accent-contrast)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst dividerStyle = computed(() => ({\n  borderColor: 'var(--ac-border)',\n}));\n\nconst sectionStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface)',\n  border: 'var(--ac-border-width) solid var(--ac-border)',\n  borderRadius: 'var(--ac-radius-inner)',\n}));\n\nconst sectionHeaderStyle = computed(() => ({\n  color: 'var(--ac-text)',\n}));\n\nconst runItemStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst triggerItemStyle = computed(() => ({\n  backgroundColor: 'var(--ac-surface-muted)',\n  borderRadius: 'var(--ac-radius-button)',\n}));\n\nconst triggerAddStyle = computed(() => ({\n  backgroundColor: 'var(--ac-accent-subtle)',\n  color: 'var(--ac-accent)',\n  borderRadius: '50%',\n}));\n\nconst triggerActionStyle = computed(() => ({\n  color: 'var(--ac-text-muted)',\n}));\n\nconst triggerActionDangerStyle = computed(() => ({\n  color: 'var(--ac-danger)',\n}));\n</script>\n\n<style scoped>\n.workflow-checkbox {\n  width: 16px;\n  height: 16px;\n  border-radius: 4px;\n  border: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n  appearance: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.workflow-checkbox:checked {\n  background-color: var(--ac-accent, #d97757);\n  border-color: var(--ac-accent, #d97757);\n  background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e\");\n}\n\n.advanced-divider {\n  display: flex;\n  align-items: center;\n  text-align: center;\n  margin: 20px 0 16px;\n  font-size: 12px;\n  font-weight: 500;\n}\n\n.advanced-divider::before,\n.advanced-divider::after {\n  content: '';\n  flex: 1;\n  border-bottom: var(--ac-border-width, 1px) solid var(--ac-border, #e7e5e4);\n}\n\n.advanced-section {\n  margin-bottom: 8px;\n  overflow: hidden;\n}\n\n.advanced-section-header {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 12px;\n  font-size: 13px;\n  font-weight: 500;\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  transition: background-color var(--ac-motion-fast, 120ms) ease;\n}\n\n.advanced-section-header:hover {\n  background-color: var(--ac-hover-bg, #f5f5f4);\n}\n\n.advanced-section-content {\n  padding: 0 12px 12px;\n}\n\n.run-item,\n.trigger-item {\n  padding: 10px 12px;\n  cursor: pointer;\n  transition: background-color var(--ac-motion-fast, 120ms) ease;\n}\n\n.run-item:hover,\n.trigger-item:hover {\n  background-color: var(--ac-hover-bg, #f5f5f4) !important;\n}\n\n.trigger-add-btn {\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.trigger-add-btn:hover {\n  transform: scale(1.1);\n}\n\n.trigger-action {\n  width: 24px;\n  height: 24px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  border-radius: var(--ac-radius-button, 8px);\n  cursor: pointer;\n  transition: all var(--ac-motion-fast, 120ms) ease;\n}\n\n.trigger-action:hover {\n  background-color: var(--ac-hover-bg, #f5f5f4);\n}\n\n.trigger-action-danger:hover {\n  background-color: rgba(239, 68, 68, 0.1);\n}\n\n/* Section expand transition */\n.section-expand-enter-active,\n.section-expand-leave-active {\n  transition: all var(--ac-motion-normal, 180ms) ease;\n  overflow: hidden;\n}\n\n.section-expand-enter-from,\n.section-expand-leave-to {\n  opacity: 0;\n  max-height: 0;\n}\n\n.section-expand-enter-to,\n.section-expand-leave-from {\n  opacity: 1;\n  max-height: 500px;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/components/workflows/index.ts",
    "content": "export { default as WorkflowsView } from './WorkflowsView.vue';\nexport { default as WorkflowListItem } from './WorkflowListItem.vue';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/index.ts",
    "content": "/**\n * Agent Chat Composables\n * Export all composables for agent chat functionality.\n */\nexport { useAgentServer } from './useAgentServer';\nexport { useAgentChat } from './useAgentChat';\nexport { useAgentProjects } from './useAgentProjects';\nexport { useAgentSessions } from './useAgentSessions';\nexport { useAttachments, type AttachmentWithPreview } from './useAttachments';\nexport { useAgentTheme, preloadAgentTheme, THEME_LABELS } from './useAgentTheme';\nexport { useAgentThreads, AGENT_SERVER_PORT_KEY } from './useAgentThreads';\nexport { useWebEditorTxState, WEB_EDITOR_TX_STATE_INJECTION_KEY } from './useWebEditorTxState';\nexport { useAgentChatViewRoute } from './useAgentChatViewRoute';\n\nexport type { UseAgentServerOptions } from './useAgentServer';\nexport type { UseAgentChatOptions } from './useAgentChat';\nexport type { UseAgentProjectsOptions } from './useAgentProjects';\nexport type { UseAgentSessionsOptions } from './useAgentSessions';\nexport type { AgentThemeId, UseAgentTheme } from './useAgentTheme';\nexport type {\n  AgentThread,\n  TimelineItem,\n  ToolPresentation,\n  ToolKind,\n  ToolSeverity,\n  AgentThreadState,\n  UseAgentThreadsOptions,\n  ThreadHeader,\n  WebEditorApplyMeta,\n} from './useAgentThreads';\nexport type { UseWebEditorTxStateOptions, WebEditorTxStateReturn } from './useWebEditorTxState';\nexport type {\n  AgentChatView,\n  AgentChatRouteState,\n  UseAgentChatViewRouteOptions,\n  UseAgentChatViewRoute,\n} from './useAgentChatViewRoute';\n\n// RR V3 Composables\nexport { useRRV3Rpc } from './useRRV3Rpc';\nexport { useRRV3Debugger } from './useRRV3Debugger';\nexport type { UseRRV3Rpc, UseRRV3RpcOptions, RpcRequestOptions } from './useRRV3Rpc';\nexport type { UseRRV3Debugger, UseRRV3DebuggerOptions } from './useRRV3Debugger';\n\n// Textarea Auto-Resize\nexport { useTextareaAutoResize } from './useTextareaAutoResize';\nexport type {\n  UseTextareaAutoResizeOptions,\n  UseTextareaAutoResizeReturn,\n} from './useTextareaAutoResize';\n\n// Fake Caret (comet tail animation)\nexport { useFakeCaret } from './useFakeCaret';\nexport type { UseFakeCaretOptions, UseFakeCaretReturn, FakeCaretTrailPoint } from './useFakeCaret';\n\n// Open Project Preference\nexport { useOpenProjectPreference } from './useOpenProjectPreference';\nexport type {\n  UseOpenProjectPreferenceOptions,\n  UseOpenProjectPreference,\n} from './useOpenProjectPreference';\n\n// Agent Input Preferences (fake caret, etc.)\nexport { useAgentInputPreferences } from './useAgentInputPreferences';\nexport type { UseAgentInputPreferences } from './useAgentInputPreferences';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentChat.ts",
    "content": "/**\n * Composable for managing Agent Chat state and messages.\n * Handles message sending, receiving, and cancellation.\n */\nimport { ref, computed } from 'vue';\nimport type {\n  AgentMessage,\n  AgentActRequest,\n  AgentActRequestClientMeta,\n  AgentAttachment,\n  RealtimeEvent,\n  AgentStatusEvent,\n  AgentCliPreference,\n  AgentUsageStats,\n} from 'chrome-mcp-shared';\n\n/**\n * Request lifecycle state.\n * - 'idle': No active request\n * - 'starting': Request accepted, waiting for engine initialization\n * - 'ready': Engine initialized, preparing to run\n * - 'running': Engine actively processing (may emit tool_use/tool_result)\n * - 'completed': Request finished successfully\n * - 'cancelled': Request was cancelled by user\n * - 'error': Request failed with error\n */\nexport type RequestState = 'idle' | AgentStatusEvent['status'];\n\nexport interface UseAgentChatOptions {\n  getServerPort: () => number | null;\n  getSessionId: () => string;\n  ensureServer: () => Promise<boolean>;\n  openEventSource: () => void;\n}\n\nexport function useAgentChat(options: UseAgentChatOptions) {\n  // State\n  const messages = ref<AgentMessage[]>([]);\n  const input = ref('');\n  const sending = ref(false);\n  /**\n   * Message-level streaming state.\n   * True when receiving delta updates for assistant/tool messages.\n   * Note: This is separate from requestState - a request can be 'running'\n   * even when isStreaming is false (e.g., during tool execution).\n   */\n  const isStreaming = ref(false);\n  /**\n   * Request lifecycle state driven by status events.\n   * Use this (via isRequestActive) for UI elements like stop button,\n   * loading indicators, and running badges.\n   */\n  const requestState = ref<RequestState>('idle');\n  const errorMessage = ref<string | null>(null);\n  const currentRequestId = ref<string | null>(null);\n  const cancelling = ref(false);\n  const attachments = ref<AgentAttachment[]>([]);\n  const lastUsage = ref<AgentUsageStats | null>(null);\n\n  // Computed\n  const canSend = computed(() => {\n    return input.value.trim().length > 0 && !sending.value;\n  });\n\n  /**\n   * Whether there is an active request in progress.\n   * Use this for UI elements like stop button, loading indicators, and running badges.\n   */\n  const isRequestActive = computed(() => {\n    return (\n      requestState.value === 'starting' ||\n      requestState.value === 'ready' ||\n      requestState.value === 'running'\n    );\n  });\n\n  /**\n   * Check if an incoming event belongs to a different active request.\n   * Used to filter out stale events from previous requests.\n   */\n  function isDifferentActiveRequest(incomingRequestId?: string): boolean {\n    const incoming = incomingRequestId?.trim();\n    const current = currentRequestId.value?.trim();\n    // No incoming ID or no current ID means we can't determine - don't filter\n    if (!incoming || !current) return false;\n    // Same request ID - don't filter\n    if (incoming === current) return false;\n    // Different request ID while we have an active request - filter it out\n    return isRequestActive.value;\n  }\n\n  /**\n   * Handle incoming realtime events.\n   * Events are filtered by sessionId to prevent cross-session state pollution\n   * when user switches sessions while SSE connection is still active.\n   */\n  function handleRealtimeEvent(event: RealtimeEvent): void {\n    const currentSessionId = options.getSessionId();\n\n    switch (event.type) {\n      case 'message':\n        // Guard: only handle messages for the current session\n        if (event.data.sessionId !== currentSessionId) {\n          return;\n        }\n        handleMessageEvent(event.data);\n        break;\n      case 'status':\n        // Guard: only handle status for the current session\n        if (event.data.sessionId !== currentSessionId) {\n          return;\n        }\n        handleStatusEvent(event.data);\n        break;\n      case 'error':\n        // Error events may not have sessionId, but if they do, filter\n        if (event.data?.sessionId && event.data.sessionId !== currentSessionId) {\n          return;\n        }\n        // Filter out errors from different active requests\n        if (isDifferentActiveRequest(event.data?.requestId)) {\n          return;\n        }\n        errorMessage.value = event.error;\n        isStreaming.value = false;\n        requestState.value = 'error';\n        // Clear requestId if it matches the error event's requestId (or unconditionally if no requestId in error)\n        if (!event.data?.requestId || event.data.requestId === currentRequestId.value) {\n          currentRequestId.value = null;\n        }\n        break;\n      case 'connected':\n        console.log('[AgentChat] Connected to session:', event.data.sessionId);\n        break;\n      case 'heartbeat':\n        // Heartbeat received, connection is alive\n        break;\n      case 'usage':\n        // Guard: only accept usage for the current session\n        if (event.data?.sessionId && event.data.sessionId !== currentSessionId) {\n          return;\n        }\n        lastUsage.value = event.data;\n        break;\n    }\n  }\n\n  // Handle message events\n  function handleMessageEvent(msg: AgentMessage): void {\n    // For user messages from server, replace local optimistic message\n    // Server echoes user message with real id/metadata, but we want to keep our display text\n    // (which doesn't include injected context like web editor selection)\n    if (msg.role === 'user' && msg.requestId) {\n      const optimisticIndex = messages.value.findIndex(\n        (m) => m.role === 'user' && m.requestId === msg.requestId && m.id.startsWith('temp-'),\n      );\n      if (optimisticIndex >= 0) {\n        // Replace optimistic message: keep display content, update id and metadata\n        const optimistic = messages.value[optimisticIndex];\n        messages.value[optimisticIndex] = {\n          ...msg,\n          // Preserve the display content (user's raw input without injected context)\n          content: optimistic.content,\n          // Prefer server metadata, fallback to optimistic metadata (for chip rendering)\n          metadata: msg.metadata ?? optimistic.metadata,\n        };\n        return;\n      }\n    }\n\n    // Check if this message belongs to a different active request\n    // Note: We still save the message to messages array (for auditing/replay),\n    // but skip state updates if it's from a stale request\n    const msgRequestId = msg.requestId?.trim() || undefined;\n    const isStaleForState = isDifferentActiveRequest(msgRequestId);\n\n    const existingIndex = messages.value.findIndex((m) => m.id === msg.id);\n\n    if (existingIndex >= 0) {\n      // Update existing message (streaming update)\n      messages.value[existingIndex] = msg;\n    } else {\n      // Add new message - always save, even if stale for state\n      messages.value.push(msg);\n    }\n\n    // Skip state updates for messages from different active requests\n    if (isStaleForState) {\n      return;\n    }\n\n    // Track requestId from messages (handles cases where status events were missed)\n    if (msgRequestId && msgRequestId !== currentRequestId.value) {\n      currentRequestId.value = msgRequestId;\n    }\n\n    // Update message-level streaming state (delta updates)\n    // Note: This does NOT affect requestState - tool_use with isStreaming=false\n    // should not stop the overall request, only indicate this message is complete\n    if (msg.role === 'assistant' || msg.role === 'tool') {\n      isStreaming.value = msg.isStreaming === true && !msg.isFinal;\n\n      // If we're receiving model/tool output but requestState hasn't progressed to 'running',\n      // update it. This handles:\n      // 1. Edge case where status events were missed due to SSE timing\n      // 2. User enters AgentChat mid-request (e.g., from Quick Panel/toolbar trigger)\n      // 3. SSE reconnection after temporary disconnect\n      if (\n        requestState.value === 'idle' ||\n        requestState.value === 'starting' ||\n        requestState.value === 'ready'\n      ) {\n        requestState.value = 'running';\n      }\n    }\n  }\n\n  // Handle status events\n  function handleStatusEvent(status: AgentStatusEvent): void {\n    const statusRequestId = status.requestId?.trim() || undefined;\n\n    // Filter out status events from different active requests\n    if (isDifferentActiveRequest(statusRequestId)) {\n      return;\n    }\n\n    // Track requestId from status events\n    if (statusRequestId && statusRequestId !== currentRequestId.value) {\n      currentRequestId.value = statusRequestId;\n    }\n\n    // Update request lifecycle state (driven by status events only)\n    requestState.value = status.status;\n\n    switch (status.status) {\n      case 'starting':\n      case 'ready':\n      case 'running':\n        // Request is active - no additional state changes needed\n        break;\n      case 'completed':\n      case 'error':\n      case 'cancelled':\n        // Request finished - clear message streaming and requestId\n        isStreaming.value = false;\n        // Reset cancelling state (in case we were waiting for SSE confirmation)\n        cancelling.value = false;\n        if (!statusRequestId || statusRequestId === currentRequestId.value) {\n          currentRequestId.value = null;\n        }\n        break;\n    }\n  }\n\n  // Send message\n  async function send(\n    chatOptions: {\n      cliPreference?: string;\n      model?: string;\n      projectId?: string;\n      projectRoot?: string;\n      dbSessionId?: string;\n      /**\n       * Optional instruction to send instead of input.value.\n       * When provided, this is used as the actual instruction sent to the server,\n       * while input.value is still used for UI display in the optimistic message.\n       * This is useful for injecting context (e.g., web editor selection) into the prompt\n       * without showing it in the chat UI.\n       */\n      instruction?: string;\n      /**\n       * Optional compact display text stored in the user message metadata.\n       * When provided, the UI can render a special header (e.g., a chip) instead\n       * of the raw prompt content.\n       */\n      displayText?: string;\n      /**\n       * Optional client metadata to persist with the user message.\n       * Used for special UI rendering (e.g., web editor apply/selection chips).\n       */\n      clientMeta?: AgentActRequestClientMeta;\n    } = {},\n  ): Promise<void> {\n    // User-visible content is always the user's raw input\n    const userText = input.value.trim();\n    // Actual instruction sent to server can be overridden (e.g., with context prepended)\n    const instructionText = chatOptions.instruction?.trim() || userText;\n\n    if (!userText) return;\n\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    const sessionId = options.getSessionId();\n\n    if (!ready || !serverPort) {\n      errorMessage.value = 'Agent server is not available.';\n      return;\n    }\n\n    // Ensure SSE is connected before sending\n    options.openEventSource();\n\n    // Generate requestId on client side for optimistic message matching\n    // Server will use this requestId when echoing user message via SSE\n    const requestId = crypto.randomUUID();\n\n    // Create optimistic user message for immediate feedback\n    // Note: Use userText for UI, not instructionText (which may contain injected context)\n    const tempMessageId = `temp-${Date.now()}`;\n    const optimisticMessage: AgentMessage = {\n      id: tempMessageId,\n      sessionId: sessionId,\n      role: 'user',\n      content: userText,\n      messageType: 'chat',\n      requestId, // Include requestId so we can match with server-echoed message\n      createdAt: new Date().toISOString(),\n      // Include metadata for immediate chip rendering (before server echo)\n      metadata:\n        chatOptions.displayText || chatOptions.clientMeta\n          ? {\n              displayText: chatOptions.displayText?.trim(),\n              clientMeta: chatOptions.clientMeta,\n            }\n          : undefined,\n    };\n\n    // Add user message immediately\n    messages.value.push(optimisticMessage);\n\n    const payload: AgentActRequest = {\n      // Use instructionText which may include injected context (e.g., web editor selection)\n      instruction: instructionText,\n      requestId, // Send requestId to server so it can be used in SSE events\n      // Optional metadata for special UI rendering (stored with the user message)\n      displayText: chatOptions.displayText?.trim() || undefined,\n      clientMeta: chatOptions.clientMeta,\n      cliPreference: chatOptions.cliPreference\n        ? (chatOptions.cliPreference as AgentCliPreference)\n        : undefined,\n      model: chatOptions.model?.trim() || undefined,\n      projectId: chatOptions.projectId || undefined,\n      projectRoot: chatOptions.projectRoot?.trim() || undefined,\n      dbSessionId: chatOptions.dbSessionId || undefined,\n      attachments: attachments.value.length > 0 ? attachments.value : undefined,\n    };\n\n    sending.value = true;\n    // Initialize request lifecycle state - request begins once we dispatch /act\n    requestState.value = 'starting';\n    currentRequestId.value = requestId;\n    // Reset message-level streaming; it will be driven by message.isStreaming deltas\n    isStreaming.value = false;\n    errorMessage.value = null;\n\n    // Clear input immediately for better UX\n    const savedInput = input.value;\n    input.value = '';\n    const savedAttachments = [...attachments.value];\n    attachments.value = [];\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/act`;\n\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(payload),\n      });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const result = await response.json().catch(() => ({}));\n\n      // Guard: only update state if we're still on the same session\n      // This prevents cross-session state pollution when user switches during request\n      const currentSessionId = options.getSessionId();\n      if (currentSessionId !== sessionId) {\n        // Session changed during request - discard result silently\n        // The optimistic message will be cleared when messages are reloaded\n        isStreaming.value = false;\n        requestState.value = 'idle';\n        currentRequestId.value = null;\n        return;\n      }\n\n      // Update currentRequestId from response (should match our client-generated one)\n      // This is used for cancel functionality\n      if (result.requestId) {\n        currentRequestId.value = result.requestId;\n      } else {\n        // Fallback: use our client-generated requestId\n        currentRequestId.value = requestId;\n      }\n    } catch (error: unknown) {\n      // Guard: only handle error if still on same session\n      const currentSessionId = options.getSessionId();\n      if (currentSessionId !== sessionId) {\n        isStreaming.value = false;\n        requestState.value = 'idle';\n        currentRequestId.value = null;\n        return;\n      }\n\n      console.error('Failed to send agent act request:', error);\n      errorMessage.value =\n        error instanceof Error ? error.message : 'Failed to send request to agent server.';\n      // Restore input on error\n      input.value = savedInput;\n      attachments.value = savedAttachments;\n      // Remove optimistic message on error\n      const msgIndex = messages.value.findIndex((m) => m.id === tempMessageId);\n      if (msgIndex >= 0) {\n        messages.value.splice(msgIndex, 1);\n      }\n      isStreaming.value = false;\n      requestState.value = 'idle';\n      currentRequestId.value = null;\n    } finally {\n      sending.value = false;\n    }\n  }\n\n  // Cancel current request\n  async function cancelCurrentRequest(): Promise<void> {\n    if (!currentRequestId.value) return;\n\n    const serverPort = options.getServerPort();\n    const sessionId = options.getSessionId();\n\n    if (!serverPort) return;\n\n    cancelling.value = true;\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(currentRequestId.value)}`;\n\n      const response = await fetch(url, { method: 'DELETE' });\n      const data = await response.json().catch(() => null);\n\n      // Check if cancel was successful\n      // Backend returns { success: boolean, message?: string }\n      const isSuccess = response.ok && data?.success !== false;\n\n      if (!isSuccess) {\n        // Cancel failed - show error but keep request state intact\n        // so user can try again or wait for natural completion\n        const errorMsg = data?.message || `Failed to cancel request (HTTP ${response.status})`;\n        console.error('Cancel request failed:', errorMsg);\n        errorMessage.value = errorMsg;\n        return;\n      }\n\n      // Cancel request sent successfully\n      // Note: We intentionally do NOT clear currentRequestId/requestState here\n      // The actual state cleanup will happen when we receive the 'cancelled' status event via SSE\n      // This ensures UI stays consistent with backend state and avoids race conditions\n      // Keep cancelling=true so UI shows \"Stopping...\" until SSE confirms\n      // cancelling will be reset when handleStatusEvent receives 'cancelled' status\n    } catch (error) {\n      console.error('Failed to cancel request:', error);\n      errorMessage.value = error instanceof Error ? error.message : 'Failed to cancel request';\n      // Only reset cancelling on error, not on success\n      cancelling.value = false;\n    }\n  }\n\n  // Clear messages\n  function clearMessages(): void {\n    messages.value = [];\n  }\n\n  // Set messages (for loading history)\n  function setMessages(newMessages: AgentMessage[]): void {\n    messages.value = newMessages;\n  }\n\n  return {\n    // State\n    messages,\n    input,\n    sending,\n    isStreaming,\n    requestState,\n    errorMessage,\n    currentRequestId,\n    cancelling,\n    attachments,\n    lastUsage,\n\n    // Computed\n    canSend,\n    isRequestActive,\n\n    // Methods\n    handleRealtimeEvent,\n    send,\n    cancelCurrentRequest,\n    clearMessages,\n    setMessages,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentChatViewRoute.ts",
    "content": "/**\n * Composable for managing AgentChat view routing.\n *\n * Handles navigation between 'sessions' (list) and 'chat' (conversation) views\n * without requiring vue-router. Supports URL parameters for deep linking.\n *\n * URL Parameters:\n * - `view`: 'sessions' | 'chat' (default: 'sessions')\n * - `sessionId`: Session ID to open directly in chat view\n *\n * Example URLs:\n * - `sidepanel.html?tab=agent-chat` → sessions list\n * - `sidepanel.html?tab=agent-chat&view=chat&sessionId=xxx` → direct to chat\n */\nimport { ref, computed } from 'vue';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Available view modes */\nexport type AgentChatView = 'sessions' | 'chat';\n\n/** Route state */\nexport interface AgentChatRouteState {\n  view: AgentChatView;\n  sessionId: string | null;\n}\n\n/** Options for useAgentChatViewRoute */\nexport interface UseAgentChatViewRouteOptions {\n  /**\n   * Callback when route changes.\n   * Called after internal state is updated.\n   */\n  onRouteChange?: (state: AgentChatRouteState) => void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_VIEW: AgentChatView = 'sessions';\nconst URL_PARAM_VIEW = 'view';\nconst URL_PARAM_SESSION_ID = 'sessionId';\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Parse view from URL parameter.\n * Returns default if invalid.\n */\nfunction parseView(value: string | null): AgentChatView {\n  if (value === 'sessions' || value === 'chat') {\n    return value;\n  }\n  return DEFAULT_VIEW;\n}\n\n/**\n * Update URL parameters without page reload.\n * Preserves existing parameters (like `tab`).\n */\nfunction updateUrlParams(view: AgentChatView, sessionId: string | null): void {\n  try {\n    const url = new URL(window.location.href);\n\n    // Update view param\n    if (view === DEFAULT_VIEW) {\n      url.searchParams.delete(URL_PARAM_VIEW);\n    } else {\n      url.searchParams.set(URL_PARAM_VIEW, view);\n    }\n\n    // Update sessionId param\n    if (sessionId) {\n      url.searchParams.set(URL_PARAM_SESSION_ID, sessionId);\n    } else {\n      url.searchParams.delete(URL_PARAM_SESSION_ID);\n    }\n\n    // Update URL without reload\n    window.history.replaceState({}, '', url.toString());\n  } catch {\n    // Ignore URL update errors (e.g., in non-browser environment)\n  }\n}\n\n// =============================================================================\n// Composable\n// =============================================================================\n\nexport function useAgentChatViewRoute(options: UseAgentChatViewRouteOptions = {}) {\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  const currentView = ref<AgentChatView>(DEFAULT_VIEW);\n  const currentSessionId = ref<string | null>(null);\n\n  // ==========================================================================\n  // Computed\n  // ==========================================================================\n\n  /** Whether currently showing sessions list */\n  const isSessionsView = computed(() => currentView.value === 'sessions');\n\n  /** Whether currently showing chat conversation */\n  const isChatView = computed(() => currentView.value === 'chat');\n\n  /** Current route state */\n  const routeState = computed<AgentChatRouteState>(() => ({\n    view: currentView.value,\n    sessionId: currentSessionId.value,\n  }));\n\n  // ==========================================================================\n  // Actions\n  // ==========================================================================\n\n  /**\n   * Navigate to sessions list view.\n   * Clears sessionId from URL.\n   */\n  function goToSessions(): void {\n    currentView.value = 'sessions';\n    // Don't clear sessionId internally - it's used to highlight selected session\n    updateUrlParams('sessions', null);\n    options.onRouteChange?.(routeState.value);\n  }\n\n  /**\n   * Navigate to chat view for a specific session.\n   * @param sessionId - Session ID to open\n   */\n  function goToChat(sessionId: string): void {\n    if (!sessionId) {\n      console.warn('[useAgentChatViewRoute] goToChat called without sessionId');\n      return;\n    }\n\n    currentView.value = 'chat';\n    currentSessionId.value = sessionId;\n    updateUrlParams('chat', sessionId);\n    options.onRouteChange?.(routeState.value);\n  }\n\n  /**\n   * Initialize route from URL parameters.\n   * Should be called on mount.\n   * @returns Initial route state\n   */\n  function initFromUrl(): AgentChatRouteState {\n    try {\n      const params = new URLSearchParams(window.location.search);\n      const viewParam = params.get(URL_PARAM_VIEW);\n      const sessionIdParam = params.get(URL_PARAM_SESSION_ID);\n\n      const view = parseView(viewParam);\n      const sessionId = sessionIdParam?.trim() || null;\n\n      // If view=chat but no sessionId, fall back to sessions\n      if (view === 'chat' && !sessionId) {\n        currentView.value = 'sessions';\n        currentSessionId.value = null;\n      } else {\n        currentView.value = view;\n        currentSessionId.value = sessionId;\n      }\n    } catch {\n      // Use defaults on error\n      currentView.value = DEFAULT_VIEW;\n      currentSessionId.value = null;\n    }\n\n    return routeState.value;\n  }\n\n  /**\n   * Update session ID without changing view.\n   * Updates URL based on current view and sessionId:\n   * - In chat view: always update URL with sessionId\n   * - In sessions view with null sessionId: clear sessionId from URL (cleanup)\n   */\n  function setSessionId(sessionId: string | null): void {\n    currentSessionId.value = sessionId;\n\n    if (currentView.value === 'chat') {\n      // In chat view, always sync URL with current sessionId\n      updateUrlParams('chat', sessionId);\n    } else if (sessionId === null) {\n      // In sessions view, clear any stale sessionId from URL\n      // This handles edge cases like deleting the last session\n      updateUrlParams('sessions', null);\n    }\n  }\n\n  // ==========================================================================\n  // Lifecycle\n  // ==========================================================================\n\n  // Note: We don't call initFromUrl() here because AgentChat.vue needs to\n  // call it after loading sessions (to verify sessionId exists).\n  // Caller is responsible for calling initFromUrl() at the right time.\n\n  // ==========================================================================\n  // Return\n  // ==========================================================================\n\n  return {\n    // State\n    currentView,\n    currentSessionId,\n\n    // Computed\n    isSessionsView,\n    isChatView,\n    routeState,\n\n    // Actions\n    goToSessions,\n    goToChat,\n    initFromUrl,\n    setSessionId,\n  };\n}\n\n// =============================================================================\n// Type Export\n// =============================================================================\n\nexport type UseAgentChatViewRoute = ReturnType<typeof useAgentChatViewRoute>;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentInputPreferences.ts",
    "content": "/**\n * Composable for user-facing input preferences in AgentChat.\n * Preferences are persisted in chrome.storage.local.\n */\nimport { ref, type Ref } from 'vue';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst STORAGE_KEY_FAKE_CARET = 'agent-chat-fake-caret-enabled';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface UseAgentInputPreferences {\n  /** Whether the fake caret + comet trail is enabled (opt-in). Default: false */\n  fakeCaretEnabled: Ref<boolean>;\n  /** Whether preferences have been loaded from storage */\n  ready: Ref<boolean>;\n  /** Load preferences from chrome.storage.local (call on mount) */\n  init: () => Promise<void>;\n  /** Persist and update fake caret preference */\n  setFakeCaretEnabled: (enabled: boolean) => Promise<void>;\n}\n\n// =============================================================================\n// Composable\n// =============================================================================\n\n/**\n * Composable for managing user input preferences.\n *\n * Features:\n * - Fake caret toggle (opt-in, default off)\n * - Persistence via chrome.storage.local\n * - Graceful fallback when storage is unavailable\n */\nexport function useAgentInputPreferences(): UseAgentInputPreferences {\n  const fakeCaretEnabled = ref(false);\n  const ready = ref(false);\n\n  /**\n   * Load preferences from chrome.storage.local.\n   * Should be called during component mount.\n   */\n  async function init(): Promise<void> {\n    try {\n      if (typeof chrome === 'undefined' || !chrome.storage?.local) {\n        ready.value = true;\n        return;\n      }\n\n      const result = await chrome.storage.local.get(STORAGE_KEY_FAKE_CARET);\n      const stored = result[STORAGE_KEY_FAKE_CARET];\n\n      if (typeof stored === 'boolean') {\n        fakeCaretEnabled.value = stored;\n      }\n    } catch (error) {\n      console.error('[useAgentInputPreferences] Failed to load preferences:', error);\n    } finally {\n      ready.value = true;\n    }\n  }\n\n  /**\n   * Update and persist the fake caret preference.\n   */\n  async function setFakeCaretEnabled(enabled: boolean): Promise<void> {\n    fakeCaretEnabled.value = enabled;\n\n    try {\n      if (typeof chrome === 'undefined' || !chrome.storage?.local) return;\n      await chrome.storage.local.set({ [STORAGE_KEY_FAKE_CARET]: enabled });\n    } catch (error) {\n      console.error('[useAgentInputPreferences] Failed to save fake caret preference:', error);\n    }\n  }\n\n  return {\n    fakeCaretEnabled,\n    ready,\n    init,\n    setFakeCaretEnabled,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentProjects.ts",
    "content": "/**\n * Composable for managing Agent Projects.\n * Handles project CRUD, selection, and persistence.\n */\nimport { ref, computed, watch } from 'vue';\nimport type { AgentProject, AgentStoredMessage } from 'chrome-mcp-shared';\n\nconst STORAGE_KEY_SELECTED_PROJECT = 'agent-selected-project-id';\n\ninterface PathValidationResult {\n  valid: boolean;\n  absolute: string;\n  exists: boolean;\n  needsCreation: boolean;\n  error?: string;\n}\n\n/**\n * Normalize path for comparison (handle trailing slashes and separators).\n */\nfunction normalizePathForComparison(path: string): string {\n  // Remove trailing slashes and normalize separators\n  return path\n    .trim()\n    .replace(/[/\\\\]+$/, '')\n    .replace(/\\\\/g, '/')\n    .toLowerCase();\n}\n\nexport interface UseAgentProjectsOptions {\n  getServerPort: () => number | null;\n  ensureServer: () => Promise<boolean>;\n  onHistoryLoaded?: (messages: AgentStoredMessage[]) => void;\n}\n\nexport function useAgentProjects(options: UseAgentProjectsOptions) {\n  // State\n  const projects = ref<AgentProject[]>([]);\n  const selectedProjectId = ref<string>('');\n  const isLoadingProjects = ref(false);\n  const showCreateProject = ref(false);\n  const newProjectName = ref('');\n  const newProjectRootPath = ref('');\n  const isCreatingProject = ref(false);\n  const projectError = ref<string | null>(null);\n\n  // Computed\n  const selectedProject = computed(() => {\n    return projects.value.find((p) => p.id === selectedProjectId.value) || null;\n  });\n\n  const canCreateProject = computed(() => {\n    return newProjectName.value.trim().length > 0 && newProjectRootPath.value.trim().length > 0;\n  });\n\n  // Load selected project from storage\n  async function loadSelectedProjectId(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_PROJECT);\n      if (result[STORAGE_KEY_SELECTED_PROJECT]) {\n        selectedProjectId.value = result[STORAGE_KEY_SELECTED_PROJECT];\n      }\n    } catch (error) {\n      console.error('Failed to load selected project ID:', error);\n    }\n  }\n\n  // Save selected project to storage\n  async function saveSelectedProjectId(): Promise<void> {\n    try {\n      await chrome.storage.local.set({\n        [STORAGE_KEY_SELECTED_PROJECT]: selectedProjectId.value,\n      });\n    } catch (error) {\n      console.error('Failed to save selected project ID:', error);\n    }\n  }\n\n  // Fetch projects from server\n  async function fetchProjects(): Promise<void> {\n    const serverPort = options.getServerPort();\n    if (!serverPort) return;\n\n    isLoadingProjects.value = true;\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects`;\n      const response = await fetch(url);\n      if (response.ok) {\n        const data = await response.json();\n        projects.value = data.projects || [];\n      }\n    } catch (error) {\n      console.error('Failed to fetch projects:', error);\n    } finally {\n      isLoadingProjects.value = false;\n    }\n  }\n\n  // Refresh projects\n  async function refreshProjects(): Promise<void> {\n    const ready = await options.ensureServer();\n    if (!ready) return;\n    await fetchProjects();\n  }\n\n  // Track pending history load with nonce to prevent A→B→A race conditions\n  let historyLoadNonce = 0;\n\n  /**\n   * Load chat history for a project with race-condition protection.\n   * Uses a nonce to handle A→B→A scenarios.\n   */\n  async function loadChatHistory(projectId: string): Promise<void> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !projectId) return;\n\n    // Increment nonce - any subsequent load will invalidate this one\n    const myNonce = ++historyLoadNonce;\n\n    const isStillValid = (): boolean => {\n      return myNonce === historyLoadNonce && selectedProjectId.value === projectId;\n    };\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(projectId)}/messages?limit=100`;\n      const response = await fetch(url);\n\n      if (!isStillValid()) return;\n\n      if (response.ok) {\n        const result = await response.json();\n\n        if (!isStillValid()) return;\n\n        // Server returns { success, data: messages[], totalCount, pagination }\n        const stored = result.data || [];\n        options.onHistoryLoaded?.(stored);\n      }\n    } catch (error) {\n      console.error('Failed to load chat history:', error);\n    }\n  }\n\n  // Validate path before creating project\n  async function validatePath(rootPath: string): Promise<PathValidationResult | null> {\n    const serverPort = options.getServerPort();\n    if (!serverPort) return null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects/validate-path`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ rootPath }),\n      });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `Validation failed: HTTP ${response.status}`);\n      }\n\n      return await response.json();\n    } catch (error) {\n      console.error('Failed to validate path:', error);\n      return null;\n    }\n  }\n\n  // Create project\n  async function createProject(): Promise<AgentProject | null> {\n    const name = newProjectName.value.trim();\n    const rootPath = newProjectRootPath.value.trim();\n    if (!name || !rootPath) return null;\n\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort) {\n      projectError.value = 'Agent server is not available.';\n      return null;\n    }\n\n    isCreatingProject.value = true;\n    projectError.value = null;\n\n    try {\n      // Step 1: Validate the path\n      const validation = await validatePath(rootPath);\n      if (!validation) {\n        projectError.value = 'Failed to validate path';\n        return null;\n      }\n\n      if (!validation.valid) {\n        projectError.value = validation.error || 'Invalid path';\n        return null;\n      }\n\n      // Step 2: If directory doesn't exist, ask user for confirmation\n      let allowCreate = false;\n      if (validation.needsCreation) {\n        const confirmed = confirm(\n          `目录 \"${validation.absolute}\" 不存在，是否创建？\\n\\nThe directory \"${validation.absolute}\" does not exist. Create it?`,\n        );\n        if (!confirmed) {\n          return null;\n        }\n        allowCreate = true;\n      }\n\n      // Step 3: Create the project\n      const url = `http://127.0.0.1:${serverPort}/agent/projects`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ name, rootPath, allowCreate }),\n      });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const payload = await response.json();\n      const project = payload?.project as AgentProject | undefined;\n\n      if (project?.id) {\n        // Update local state\n        const others = projects.value.filter((p) => p.id !== project.id);\n        projects.value = [...others, project];\n        selectedProjectId.value = project.id;\n        await saveSelectedProjectId();\n        await loadChatHistory(project.id);\n\n        // Clear form\n        newProjectName.value = '';\n        newProjectRootPath.value = '';\n        showCreateProject.value = false;\n\n        return project;\n      } else {\n        projectError.value = 'Project created but response is invalid.';\n        return null;\n      }\n    } catch (error: unknown) {\n      console.error('Failed to create project:', error);\n      projectError.value = error instanceof Error ? error.message : 'Failed to create project.';\n      return null;\n    } finally {\n      isCreatingProject.value = false;\n    }\n  }\n\n  // Toggle create project form\n  function toggleCreateProject(): void {\n    showCreateProject.value = !showCreateProject.value;\n    if (!showCreateProject.value) {\n      newProjectName.value = '';\n      newProjectRootPath.value = '';\n      projectError.value = null;\n    }\n  }\n\n  // Get default project root path for a project name\n  async function getDefaultProjectRoot(projectName: string): Promise<string | null> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !projectName.trim()) return null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects/default-root`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ projectName: projectName.trim() }),\n      });\n      if (response.ok) {\n        const data = await response.json();\n        return data.path || null;\n      }\n      return null;\n    } catch (error) {\n      console.error('Failed to get default project root:', error);\n      return null;\n    }\n  }\n\n  // Open directory picker dialog\n  async function pickDirectory(): Promise<string | null> {\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort) {\n      projectError.value = 'Server not available';\n      return null;\n    }\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects/pick-directory`;\n      const response = await fetch(url, { method: 'POST' });\n\n      // Handle HTTP errors (e.g., 404 means server version mismatch)\n      if (!response.ok) {\n        if (response.status === 404) {\n          projectError.value =\n            'Directory picker not available. Please rebuild and restart the native server.';\n        } else {\n          projectError.value = `Server error: HTTP ${response.status}`;\n        }\n        return null;\n      }\n\n      const data = await response.json();\n\n      if (data.success && data.path) {\n        return data.path;\n      } else if (data.cancelled) {\n        return null; // User cancelled, not an error\n      } else {\n        projectError.value = data.error || 'Failed to open directory picker';\n        return null;\n      }\n    } catch (error) {\n      console.error('Failed to open directory picker:', error);\n      projectError.value = 'Failed to open directory picker';\n      return null;\n    }\n  }\n\n  // Ensure default project exists (auto-create if no projects)\n  async function ensureDefaultProject(): Promise<AgentProject | null> {\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort) return null;\n\n    try {\n      // First fetch current projects\n      await fetchProjects();\n\n      // If there are already projects, no need to create default\n      if (projects.value.length > 0) {\n        return null;\n      }\n\n      // Get default workspace directory from server\n      const defaultRootUrl = `http://127.0.0.1:${serverPort}/agent/projects/default-root`;\n      const defaultRootResponse = await fetch(defaultRootUrl, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ projectName: 'default' }),\n      });\n      const defaultRootData = await defaultRootResponse.json();\n      const defaultRoot = defaultRootData.path;\n\n      if (!defaultRoot) {\n        console.error('Failed to get default project root');\n        return null;\n      }\n\n      // Create default project\n      const createUrl = `http://127.0.0.1:${serverPort}/agent/projects`;\n      const createResponse = await fetch(createUrl, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          name: 'Default',\n          rootPath: defaultRoot,\n          allowCreate: true,\n        }),\n      });\n\n      if (!createResponse.ok) {\n        const text = await createResponse.text().catch(() => '');\n        console.error('Failed to create default project:', text);\n        return null;\n      }\n\n      const payload = await createResponse.json();\n      const project = payload?.project as AgentProject | undefined;\n\n      if (project?.id) {\n        projects.value = [project];\n        selectedProjectId.value = project.id;\n        await saveSelectedProjectId();\n        return project;\n      }\n\n      return null;\n    } catch (error) {\n      console.error('Failed to ensure default project:', error);\n      return null;\n    }\n  }\n\n  // Create project from a directory path (used when user picks a directory)\n  async function createProjectFromPath(\n    rootPath: string,\n    name: string,\n  ): Promise<AgentProject | null> {\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort) {\n      projectError.value = 'Agent server is not available.';\n      return null;\n    }\n\n    projectError.value = null;\n\n    try {\n      // Validate the path first\n      const validation = await validatePath(rootPath);\n      if (!validation) {\n        projectError.value = 'Failed to validate path';\n        return null;\n      }\n\n      if (!validation.valid) {\n        projectError.value = validation.error || 'Invalid path';\n        return null;\n      }\n\n      // Check if project with same path already exists\n      const normalizedPath = normalizePathForComparison(validation.absolute);\n      const existingProject = projects.value.find(\n        (p) => normalizePathForComparison(p.rootPath) === normalizedPath,\n      );\n\n      if (existingProject) {\n        // Project already exists - select it instead of creating a new one\n        const shouldSwitch = confirm(\n          `目录 \"${validation.absolute}\" 已存在对应的项目：${existingProject.name}\\n\\n` +\n            `是否切换到该项目？\\n\\n` +\n            `A project already exists for \"${validation.absolute}\": ${existingProject.name}\\n` +\n            `Switch to that project?`,\n        );\n        if (shouldSwitch) {\n          selectedProjectId.value = existingProject.id;\n          await saveSelectedProjectId();\n          await loadChatHistory(existingProject.id);\n          return existingProject;\n        }\n        // User declined to switch, return null to indicate no action taken\n        return null;\n      }\n\n      // If directory doesn't exist, ask user for confirmation\n      let allowCreate = false;\n      if (validation.needsCreation) {\n        const confirmed = confirm(\n          `目录 \"${validation.absolute}\" 不存在，是否创建？\\n\\nThe directory \"${validation.absolute}\" does not exist. Create it?`,\n        );\n        if (!confirmed) {\n          return null;\n        }\n        allowCreate = true;\n      }\n\n      // Create the project\n      const url = `http://127.0.0.1:${serverPort}/agent/projects`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ name, rootPath, allowCreate }),\n      });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const payload = await response.json();\n      const project = payload?.project as AgentProject | undefined;\n\n      if (project?.id) {\n        // Update local state\n        const others = projects.value.filter((p) => p.id !== project.id);\n        projects.value = [...others, project];\n        selectedProjectId.value = project.id;\n        await saveSelectedProjectId();\n        await loadChatHistory(project.id);\n\n        return project;\n      } else {\n        projectError.value = 'Project created but response is invalid.';\n        return null;\n      }\n    } catch (error: unknown) {\n      console.error('Failed to create project from path:', error);\n      projectError.value = error instanceof Error ? error.message : 'Failed to create project.';\n      return null;\n    }\n  }\n\n  // Handle project change\n  async function handleProjectChanged(): Promise<void> {\n    await saveSelectedProjectId();\n    if (selectedProjectId.value) {\n      await loadChatHistory(selectedProjectId.value);\n    }\n  }\n\n  // Save project preference (CLI, model, useCcr, enableChromeMcp)\n  async function saveProjectPreference(\n    cli?: string,\n    model?: string,\n    useCcr?: boolean,\n    enableChromeMcp?: boolean,\n  ): Promise<void> {\n    const project = selectedProject.value;\n    const serverPort = options.getServerPort();\n    if (!project || !serverPort) return;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          id: project.id,\n          name: project.name,\n          rootPath: project.rootPath,\n          // Normalize and allow empty string (means \"Auto/Default\")\n          preferredCli: cli?.trim() ?? project.preferredCli,\n          selectedModel: model?.trim() ?? project.selectedModel,\n          useCcr: useCcr ?? project.useCcr,\n          enableChromeMcp: enableChromeMcp ?? project.enableChromeMcp,\n        }),\n      });\n\n      // Update local project state if successful\n      if (response.ok) {\n        const payload = await response.json();\n        const updatedProject = payload?.project as AgentProject | undefined;\n        if (updatedProject?.id) {\n          const index = projects.value.findIndex((p) => p.id === updatedProject.id);\n          if (index !== -1) {\n            projects.value[index] = updatedProject;\n          }\n        }\n      }\n    } catch (error) {\n      console.error('Failed to save project preference:', error);\n    }\n  }\n\n  return {\n    // State\n    projects,\n    selectedProjectId,\n    isLoadingProjects,\n    showCreateProject,\n    newProjectName,\n    newProjectRootPath,\n    isCreatingProject,\n    projectError,\n\n    // Computed\n    selectedProject,\n    canCreateProject,\n\n    // Methods\n    loadSelectedProjectId,\n    saveSelectedProjectId,\n    fetchProjects,\n    refreshProjects,\n    loadChatHistory,\n    createProject,\n    toggleCreateProject,\n    handleProjectChanged,\n    saveProjectPreference,\n    getDefaultProjectRoot,\n    pickDirectory,\n    ensureDefaultProject,\n    createProjectFromPath,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentServer.ts",
    "content": "/**\n * Composable for managing Agent Server connection state.\n * Handles native host connection, server status, and SSE stream.\n */\nimport { ref, computed, onUnmounted } from 'vue';\nimport { NativeMessageType } from 'chrome-mcp-shared';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport type { AgentEngineInfo, RealtimeEvent } from 'chrome-mcp-shared';\n\ninterface ServerStatus {\n  isRunning: boolean;\n  port?: number;\n  lastUpdated: number;\n}\n\nexport interface UseAgentServerOptions {\n  /**\n   * Get the session ID for SSE routing.\n   * Must be provided by caller (typically DB session ID).\n   */\n  getSessionId?: () => string;\n  onMessage?: (event: RealtimeEvent) => void;\n  onError?: (error: string) => void;\n}\n\nexport function useAgentServer(options: UseAgentServerOptions = {}) {\n  // State\n  const serverPort = ref<number | null>(null);\n  const nativeConnected = ref(false);\n  const serverStatus = ref<ServerStatus | null>(null);\n  const connecting = ref(false);\n  const engines = ref<AgentEngineInfo[]>([]);\n  const eventSource = ref<EventSource | null>(null);\n\n  // Reconnection state\n  let reconnectAttempts = 0;\n  const MAX_RECONNECT_ATTEMPTS = 5;\n  const BASE_RECONNECT_DELAY = 1000;\n\n  // Track which sessionId the current SSE connection is subscribed to\n  let currentStreamSessionId: string | null = null;\n\n  // Computed\n  const isServerReady = computed(() => {\n    return nativeConnected.value && serverStatus.value?.isRunning && serverPort.value !== null;\n  });\n\n  // Check native host connection using existing message type\n  async function checkNativeHost(): Promise<boolean> {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: NativeMessageType.PING_NATIVE,\n      });\n      nativeConnected.value = response?.connected ?? false;\n      return nativeConnected.value;\n    } catch (error) {\n      console.error('Failed to check native host:', error);\n      nativeConnected.value = false;\n      return false;\n    }\n  }\n\n  /**\n   * Start native host connection.\n   * @param forceConnect - If true, use CONNECT_NATIVE (re-enables auto-connect).\n   *                       If false, use ENSURE_NATIVE (respects current auto-connect setting).\n   */\n  async function startNativeHost(forceConnect = false): Promise<boolean> {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: forceConnect ? NativeMessageType.CONNECT_NATIVE : NativeMessageType.ENSURE_NATIVE,\n      });\n      // Handle both response formats: { connected: boolean } and { success: boolean }\n      nativeConnected.value =\n        typeof response?.connected === 'boolean'\n          ? response.connected\n          : (response?.success ?? false);\n      return nativeConnected.value;\n    } catch (error) {\n      console.error('Failed to start native host:', error);\n      nativeConnected.value = false;\n      return false;\n    }\n  }\n\n  // Get server status using existing message type\n  async function getServerStatus(): Promise<ServerStatus | null> {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS,\n      });\n      if (response?.serverStatus) {\n        serverStatus.value = response.serverStatus;\n        if (response.serverStatus.port) {\n          serverPort.value = response.serverStatus.port;\n        }\n        // Also update native connected status from response\n        if (typeof response.connected === 'boolean') {\n          nativeConnected.value = response.connected;\n        }\n        return response.serverStatus;\n      }\n      return null;\n    } catch (error) {\n      console.error('Failed to get server status:', error);\n      return null;\n    }\n  }\n\n  interface EnsureNativeServerOptions {\n    /** If true, use CONNECT_NATIVE to re-enable auto-connect */\n    forceConnect?: boolean;\n  }\n\n  // Ensure native server is ready\n  async function ensureNativeServer(opts: EnsureNativeServerOptions = {}): Promise<boolean> {\n    const { forceConnect = false } = opts;\n    connecting.value = true;\n    try {\n      // Step 1: Check native host connection\n      let connected = await checkNativeHost();\n      if (!connected) {\n        // Try to start native host\n        connected = await startNativeHost(forceConnect);\n        if (!connected) {\n          console.error('Failed to connect to native host');\n          return false;\n        }\n        // Wait for connection to stabilize\n        await new Promise((resolve) => setTimeout(resolve, 500));\n      }\n\n      // Step 2: Get server status\n      const status = await getServerStatus();\n      if (!status?.isRunning || !status.port) {\n        console.error('Server not running or port not available', status);\n        return false;\n      }\n\n      // Step 3: Fetch engines\n      await fetchEngines();\n\n      return true;\n    } finally {\n      connecting.value = false;\n    }\n  }\n\n  // Fetch available engines\n  async function fetchEngines(): Promise<void> {\n    if (!serverPort.value) return;\n    try {\n      const url = `http://127.0.0.1:${serverPort.value}/agent/engines`;\n      const response = await fetch(url);\n      if (response.ok) {\n        const data = await response.json();\n        engines.value = data.engines || [];\n      }\n    } catch (error) {\n      console.error('Failed to fetch engines:', error);\n    }\n  }\n\n  // Check if SSE is connected\n  function isEventSourceConnected(): boolean {\n    return eventSource.value !== null && eventSource.value.readyState === EventSource.OPEN;\n  }\n\n  // Open SSE connection (skip if already connected to same session)\n  function openEventSource(): void {\n    const targetSessionId = options.getSessionId?.()?.trim() ?? '';\n    if (!serverPort.value || !targetSessionId) return;\n\n    // Skip if already connected to the same session\n    if (isEventSourceConnected() && currentStreamSessionId === targetSessionId) {\n      console.log('[AgentServer] SSE already connected to session, skipping reconnect');\n      return;\n    }\n\n    // Close existing connection before subscribing to a new session\n    closeEventSource();\n\n    currentStreamSessionId = targetSessionId;\n    const url = `http://127.0.0.1:${serverPort.value}/agent/chat/${encodeURIComponent(targetSessionId)}/stream`;\n    const es = new EventSource(url);\n\n    es.onopen = () => {\n      console.log('[AgentServer] SSE connection opened');\n      reconnectAttempts = 0;\n    };\n\n    es.onmessage = (event) => {\n      try {\n        const parsed = JSON.parse(event.data) as RealtimeEvent;\n        options.onMessage?.(parsed);\n      } catch (err) {\n        console.error('[AgentServer] Failed to parse SSE message:', err);\n      }\n    };\n\n    es.onerror = (error) => {\n      console.error('[AgentServer] SSE error:', error);\n      es.close();\n      eventSource.value = null;\n\n      // Attempt reconnection with exponential backoff\n      if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {\n        const delay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts);\n        reconnectAttempts++;\n        console.log(`[AgentServer] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);\n        setTimeout(() => {\n          if (isServerReady.value) {\n            openEventSource();\n          }\n        }, delay);\n      } else {\n        options.onError?.('SSE connection failed after multiple attempts');\n      }\n    };\n\n    eventSource.value = es;\n  }\n\n  // Close SSE connection\n  function closeEventSource(): void {\n    if (eventSource.value) {\n      eventSource.value.close();\n      eventSource.value = null;\n    }\n    currentStreamSessionId = null;\n  }\n\n  // Reconnect to server (explicit user action, re-enables auto-connect)\n  async function reconnect(): Promise<void> {\n    closeEventSource();\n    reconnectAttempts = 0;\n    // Explicit user reconnect: force connect to re-enable auto-connect in background\n    await ensureNativeServer({ forceConnect: true });\n    if (isServerReady.value) {\n      openEventSource();\n    }\n  }\n\n  // Initialize\n  async function initialize(): Promise<void> {\n    await ensureNativeServer();\n    // Note: SSE connection is now opened explicitly when session is ready\n  }\n\n  // Cleanup on unmount\n  onUnmounted(() => {\n    closeEventSource();\n  });\n\n  return {\n    // State\n    serverPort,\n    nativeConnected,\n    serverStatus,\n    connecting,\n    engines,\n    eventSource,\n\n    // Computed\n    isServerReady,\n\n    // Methods\n    ensureNativeServer,\n    fetchEngines,\n    openEventSource,\n    closeEventSource,\n    isEventSourceConnected,\n    reconnect,\n    initialize,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentSessions.ts",
    "content": "/**\n * Composable for managing Agent Sessions.\n * Sessions represent independent conversations within a project.\n * Each session has its own engine configuration, chat history, and resume state.\n */\nimport { ref, computed, watch } from 'vue';\nimport type {\n  AgentSession,\n  AgentCliPreference,\n  CreateAgentSessionInput,\n  UpdateAgentSessionInput,\n  AgentStoredMessage,\n  AgentManagementInfo,\n} from 'chrome-mcp-shared';\n\nconst STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';\n\nexport interface UseAgentSessionsOptions {\n  getServerPort: () => number | null;\n  ensureServer: () => Promise<boolean>;\n  onSessionChanged?: (sessionId: string) => void;\n  onHistoryLoaded?: (messages: AgentStoredMessage[]) => void;\n}\n\nexport function useAgentSessions(options: UseAgentSessionsOptions) {\n  // State\n  const sessions = ref<AgentSession[]>([]);\n  const allSessions = ref<AgentSession[]>([]); // All sessions across all projects\n  const selectedSessionId = ref<string>('');\n  const isLoadingSessions = ref(false);\n  const isLoadingAllSessions = ref(false);\n  const isCreatingSession = ref(false);\n  const sessionError = ref<string | null>(null);\n\n  // Computed\n  const selectedSession = computed(() => {\n    return sessions.value.find((s) => s.id === selectedSessionId.value) || null;\n  });\n\n  const hasSessions = computed(() => sessions.value.length > 0);\n\n  // Load selected session from storage\n  async function loadSelectedSessionId(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_SESSION);\n      if (result[STORAGE_KEY_SELECTED_SESSION]) {\n        selectedSessionId.value = result[STORAGE_KEY_SELECTED_SESSION];\n      }\n    } catch (error) {\n      console.error('Failed to load selected session ID:', error);\n    }\n  }\n\n  // Save selected session to storage\n  async function saveSelectedSessionId(): Promise<void> {\n    try {\n      await chrome.storage.local.set({\n        [STORAGE_KEY_SELECTED_SESSION]: selectedSessionId.value,\n      });\n    } catch (error) {\n      console.error('Failed to save selected session ID:', error);\n    }\n  }\n\n  // Track pending session fetch with nonce to prevent A→B→A race conditions\n  let fetchSessionsNonce = 0;\n\n  /**\n   * Fetch sessions for a project with race-condition protection.\n   * Uses a nonce to handle A→B→A scenarios.\n   */\n  async function fetchSessions(projectId: string): Promise<void> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !projectId) return;\n\n    // Increment nonce - any subsequent fetch will invalidate this one\n    const myNonce = ++fetchSessionsNonce;\n\n    const isStillValid = (): boolean => {\n      return myNonce === fetchSessionsNonce;\n    };\n\n    isLoadingSessions.value = true;\n    sessionError.value = null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;\n      const response = await fetch(url);\n\n      if (!isStillValid()) return;\n\n      if (response.ok) {\n        const data = await response.json();\n\n        if (!isStillValid()) return;\n\n        sessions.value = data.sessions || [];\n\n        // If we have sessions but no selection, select the most recent one\n        if (sessions.value.length > 0 && !selectedSessionId.value) {\n          selectedSessionId.value = sessions.value[0].id;\n          await saveSelectedSessionId();\n        }\n      } else {\n        const text = await response.text().catch(() => '');\n        sessionError.value = text || `HTTP ${response.status}`;\n      }\n    } catch (error) {\n      console.error('Failed to fetch sessions:', error);\n      sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions';\n    } finally {\n      isLoadingSessions.value = false;\n    }\n  }\n\n  // Track pending all sessions fetch with nonce\n  let fetchAllSessionsNonce = 0;\n\n  /**\n   * Fetch all sessions across all projects.\n   * Used for the global sessions list view.\n   */\n  async function fetchAllSessions(): Promise<void> {\n    const serverPort = options.getServerPort();\n    if (!serverPort) return;\n\n    const myNonce = ++fetchAllSessionsNonce;\n\n    const isStillValid = (): boolean => {\n      return myNonce === fetchAllSessionsNonce;\n    };\n\n    isLoadingAllSessions.value = true;\n    sessionError.value = null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions`;\n      const response = await fetch(url);\n\n      if (!isStillValid()) return;\n\n      if (response.ok) {\n        const data = await response.json();\n\n        if (!isStillValid()) return;\n\n        allSessions.value = data.sessions || [];\n      } else {\n        const text = await response.text().catch(() => '');\n        sessionError.value = text || `HTTP ${response.status}`;\n      }\n    } catch (error) {\n      console.error('Failed to fetch all sessions:', error);\n      sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions';\n    } finally {\n      isLoadingAllSessions.value = false;\n    }\n  }\n\n  // Track pending create session with nonce to prevent cross-project pollution\n  let createSessionNonce = 0;\n\n  /**\n   * Create a new session with race-condition protection.\n   * Uses a nonce to prevent cross-project state pollution when user switches\n   * projects during session creation.\n   */\n  async function createSession(\n    projectId: string,\n    input: CreateAgentSessionInput,\n  ): Promise<AgentSession | null> {\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort) {\n      sessionError.value = 'Server not available';\n      return null;\n    }\n\n    // Increment nonce - any subsequent create will invalidate this one\n    const myNonce = ++createSessionNonce;\n\n    isCreatingSession.value = true;\n    sessionError.value = null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(input),\n      });\n\n      // Guard: check if this is still the expected create operation\n      if (myNonce !== createSessionNonce) {\n        // A newer create was initiated - discard this result\n        return null;\n      }\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const data = await response.json();\n\n      // Re-check after json parsing\n      if (myNonce !== createSessionNonce) {\n        return null;\n      }\n\n      const session = data.session as AgentSession | undefined;\n\n      if (session?.id) {\n        // Add to local list and select it\n        sessions.value = [session, ...sessions.value];\n        // Also add to allSessions (at front, as it's the newest)\n        allSessions.value = [session, ...allSessions.value.filter((s) => s.id !== session.id)];\n        selectedSessionId.value = session.id;\n        await saveSelectedSessionId();\n        options.onSessionChanged?.(session.id);\n        return session;\n      }\n\n      sessionError.value = 'Session created but response is invalid';\n      return null;\n    } catch (error) {\n      // Guard: only handle error if still valid\n      if (myNonce !== createSessionNonce) {\n        return null;\n      }\n      console.error('Failed to create session:', error);\n      sessionError.value = error instanceof Error ? error.message : 'Failed to create session';\n      return null;\n    } finally {\n      isCreatingSession.value = false;\n    }\n  }\n\n  // Get a session by ID\n  async function getSession(sessionId: string): Promise<AgentSession | null> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !sessionId) return null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;\n      const response = await fetch(url);\n      if (response.ok) {\n        const data = await response.json();\n        return data.session || null;\n      }\n      return null;\n    } catch (error) {\n      console.error('Failed to get session:', error);\n      return null;\n    }\n  }\n\n  // Update a session\n  async function updateSession(\n    sessionId: string,\n    updates: UpdateAgentSessionInput,\n  ): Promise<AgentSession | null> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !sessionId) return null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;\n      const response = await fetch(url, {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(updates),\n      });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const data = await response.json();\n      const session = data.session as AgentSession | undefined;\n\n      if (session?.id) {\n        // Update local list\n        const index = sessions.value.findIndex((s) => s.id === session.id);\n        if (index !== -1) {\n          sessions.value[index] = session;\n        }\n        // Also update allSessions (in-place to preserve order)\n        const allIndex = allSessions.value.findIndex((s) => s.id === session.id);\n        if (allIndex !== -1) {\n          allSessions.value[allIndex] = session;\n        }\n        return session;\n      }\n\n      return null;\n    } catch (error) {\n      console.error('Failed to update session:', error);\n      sessionError.value = error instanceof Error ? error.message : 'Failed to update session';\n      return null;\n    }\n  }\n\n  // Delete a session\n  async function deleteSession(sessionId: string): Promise<boolean> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !sessionId) return false;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;\n      const response = await fetch(url, { method: 'DELETE' });\n\n      if (response.ok || response.status === 204) {\n        // Remove from local list\n        sessions.value = sessions.value.filter((s) => s.id !== sessionId);\n        // Also remove from allSessions\n        allSessions.value = allSessions.value.filter((s) => s.id !== sessionId);\n\n        // If deleted session was selected, select another one\n        if (selectedSessionId.value === sessionId) {\n          selectedSessionId.value = sessions.value[0]?.id || '';\n          await saveSelectedSessionId();\n          if (selectedSessionId.value) {\n            options.onSessionChanged?.(selectedSessionId.value);\n          }\n        }\n        return true;\n      }\n\n      return false;\n    } catch (error) {\n      console.error('Failed to delete session:', error);\n      return false;\n    }\n  }\n\n  // Select a session\n  async function selectSession(sessionId: string): Promise<void> {\n    if (selectedSessionId.value === sessionId) return;\n\n    selectedSessionId.value = sessionId;\n    await saveSelectedSessionId();\n    options.onSessionChanged?.(sessionId);\n  }\n\n  // Create a default session for a project if none exist\n  async function ensureDefaultSession(\n    projectId: string,\n    engineName: AgentCliPreference = 'claude',\n  ): Promise<AgentSession | null> {\n    await fetchSessions(projectId);\n\n    // If sessions exist, select the first one if none selected\n    if (sessions.value.length > 0) {\n      if (\n        !selectedSessionId.value ||\n        !sessions.value.find((s) => s.id === selectedSessionId.value)\n      ) {\n        await selectSession(sessions.value[0].id);\n      }\n      return selectedSession.value;\n    }\n\n    // Create default session\n    return createSession(projectId, {\n      engineName,\n      name: 'Default Session',\n    });\n  }\n\n  // Rename a session\n  async function renameSession(sessionId: string, name: string): Promise<boolean> {\n    const result = await updateSession(sessionId, { name });\n    return result !== null;\n  }\n\n  // Reset a session conversation (delete messages + clear engineSessionId)\n  async function resetConversation(sessionId: string): Promise<{\n    deletedMessages: number;\n    clearedEngineSessionId: boolean;\n    session: AgentSession | null;\n  } | null> {\n    const ready = await options.ensureServer();\n    const serverPort = options.getServerPort();\n    if (!ready || !serverPort || !sessionId) {\n      sessionError.value = 'Server not available';\n      return null;\n    }\n\n    sessionError.value = null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/reset`;\n      const response = await fetch(url, { method: 'POST' });\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const data = await response.json();\n      const session = data.session as AgentSession | null;\n\n      // Update local session state\n      if (session?.id) {\n        const index = sessions.value.findIndex((s) => s.id === session.id);\n        if (index !== -1) {\n          sessions.value[index] = session;\n        }\n      }\n\n      return {\n        deletedMessages: typeof data.deletedMessages === 'number' ? data.deletedMessages : 0,\n        clearedEngineSessionId: data.clearedEngineSessionId === true,\n        session,\n      };\n    } catch (error) {\n      console.error('Failed to reset conversation:', error);\n      sessionError.value = error instanceof Error ? error.message : 'Failed to reset conversation';\n      return null;\n    }\n  }\n\n  // Fetch Claude SDK management info for a session\n  async function fetchClaudeInfo(sessionId: string): Promise<{\n    managementInfo: AgentManagementInfo | null;\n    sessionId: string;\n    engineName: string;\n  } | null> {\n    const serverPort = options.getServerPort();\n    if (!serverPort || !sessionId) return null;\n\n    try {\n      const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/claude-info`;\n      const response = await fetch(url);\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => '');\n        throw new Error(text || `HTTP ${response.status}`);\n      }\n\n      const data = await response.json();\n      return {\n        managementInfo: data.managementInfo ?? null,\n        sessionId: data.sessionId ?? sessionId,\n        engineName: data.engineName ?? '',\n      };\n    } catch (error) {\n      console.error('Failed to fetch Claude info:', error);\n      return null;\n    }\n  }\n\n  // Clear sessions when project changes\n  function clearSessions(): void {\n    sessions.value = [];\n    selectedSessionId.value = '';\n  }\n\n  /**\n   * Update session preview and updatedAt locally (without server call).\n   * Used when sending a message to update the display immediately.\n   * Always updates updatedAt so the session moves to the top of the list.\n   * @param sessionId - The session to update\n   * @param preview - The preview text (user's raw input)\n   * @param previewMeta - Optional structured metadata for special rendering (e.g., web editor apply chip)\n   */\n  function updateSessionPreview(\n    sessionId: string,\n    preview: string,\n    previewMeta?: AgentSession['previewMeta'],\n  ): void {\n    // Truncate to 50 chars with ellipsis\n    const maxLen = 50;\n    const trimmed = preview.trim().replace(/\\s+/g, ' ');\n    const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed;\n\n    // Always update updatedAt to move session to top of list\n    const now = new Date().toISOString();\n\n    // Update in current project sessions\n    const index = sessions.value.findIndex((s) => s.id === sessionId);\n    if (index !== -1) {\n      sessions.value[index] = {\n        ...sessions.value[index],\n        // Only update preview if not already set\n        preview: sessions.value[index].preview || truncated,\n        previewMeta: sessions.value[index].previewMeta || previewMeta,\n        // Always update timestamp so session moves to top\n        updatedAt: now,\n      };\n    }\n\n    // Also update in allSessions for global list view\n    const allIndex = allSessions.value.findIndex((s) => s.id === sessionId);\n    if (allIndex !== -1) {\n      allSessions.value[allIndex] = {\n        ...allSessions.value[allIndex],\n        preview: allSessions.value[allIndex].preview || truncated,\n        previewMeta: allSessions.value[allIndex].previewMeta || previewMeta,\n        updatedAt: now,\n      };\n    }\n  }\n\n  return {\n    // State\n    sessions,\n    allSessions,\n    selectedSessionId,\n    isLoadingSessions,\n    isLoadingAllSessions,\n    isCreatingSession,\n    sessionError,\n\n    // Computed\n    selectedSession,\n    hasSessions,\n\n    // Methods\n    loadSelectedSessionId,\n    saveSelectedSessionId,\n    fetchSessions,\n    fetchAllSessions,\n    createSession,\n    getSession,\n    updateSession,\n    deleteSession,\n    selectSession,\n    ensureDefaultSession,\n    renameSession,\n    resetConversation,\n    fetchClaudeInfo,\n    clearSessions,\n    updateSessionPreview,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentTheme.ts",
    "content": "/**\n * Composable for managing AgentChat theme.\n * Handles theme persistence and application.\n */\nimport { ref, type Ref } from 'vue';\n\n/** Available theme identifiers */\nexport type AgentThemeId =\n  | 'warm-editorial'\n  | 'blueprint-architect'\n  | 'zen-journal'\n  | 'neo-pop'\n  | 'dark-console'\n  | 'swiss-grid';\n\n/** Storage key for persisting theme preference */\nconst STORAGE_KEY_THEME = 'agentTheme';\n\n/** Default theme when none is set */\nconst DEFAULT_THEME: AgentThemeId = 'warm-editorial';\n\n/** Valid theme IDs for validation */\nconst VALID_THEMES: AgentThemeId[] = [\n  'warm-editorial',\n  'blueprint-architect',\n  'zen-journal',\n  'neo-pop',\n  'dark-console',\n  'swiss-grid',\n];\n\n/** Theme display names for UI */\nexport const THEME_LABELS: Record<AgentThemeId, string> = {\n  'warm-editorial': 'Editorial',\n  'blueprint-architect': 'Blueprint',\n  'zen-journal': 'Zen',\n  'neo-pop': 'Neo-Pop',\n  'dark-console': 'Console',\n  'swiss-grid': 'Swiss',\n};\n\nexport interface UseAgentTheme {\n  /** Current theme ID */\n  theme: Ref<AgentThemeId>;\n  /** Whether theme has been loaded from storage */\n  ready: Ref<boolean>;\n  /** Set and persist a new theme */\n  setTheme: (id: AgentThemeId) => Promise<void>;\n  /** Load theme from storage (call on mount) */\n  initTheme: () => Promise<void>;\n  /** Apply theme to a DOM element */\n  applyTo: (el: HTMLElement) => void;\n  /** Get the preloaded theme from document (set by main.ts) */\n  getPreloadedTheme: () => AgentThemeId;\n}\n\n/**\n * Check if a string is a valid theme ID\n */\nfunction isValidTheme(value: unknown): value is AgentThemeId {\n  return typeof value === 'string' && VALID_THEMES.includes(value as AgentThemeId);\n}\n\n/**\n * Get theme from document element (preloaded by main.ts)\n */\nfunction getThemeFromDocument(): AgentThemeId {\n  const value = document.documentElement.dataset.agentTheme;\n  return isValidTheme(value) ? value : DEFAULT_THEME;\n}\n\n/**\n * Composable for managing AgentChat theme\n */\nexport function useAgentTheme(): UseAgentTheme {\n  // Initialize with preloaded theme (or default)\n  const theme = ref<AgentThemeId>(getThemeFromDocument());\n  const ready = ref(false);\n\n  /**\n   * Load theme from chrome.storage.local\n   */\n  async function initTheme(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get(STORAGE_KEY_THEME);\n      const stored = result[STORAGE_KEY_THEME];\n\n      if (isValidTheme(stored)) {\n        theme.value = stored;\n      } else {\n        // Use preloaded or default\n        theme.value = getThemeFromDocument();\n      }\n    } catch (error) {\n      console.error('[useAgentTheme] Failed to load theme:', error);\n      theme.value = getThemeFromDocument();\n    } finally {\n      ready.value = true;\n    }\n  }\n\n  /**\n   * Set and persist a new theme\n   */\n  async function setTheme(id: AgentThemeId): Promise<void> {\n    if (!isValidTheme(id)) {\n      console.warn('[useAgentTheme] Invalid theme ID:', id);\n      return;\n    }\n\n    // Update immediately for responsive UI\n    theme.value = id;\n\n    // Also update document element for consistency\n    document.documentElement.dataset.agentTheme = id;\n\n    // Persist to storage\n    try {\n      await chrome.storage.local.set({ [STORAGE_KEY_THEME]: id });\n    } catch (error) {\n      console.error('[useAgentTheme] Failed to save theme:', error);\n    }\n  }\n\n  /**\n   * Apply theme to a DOM element\n   */\n  function applyTo(el: HTMLElement): void {\n    el.dataset.agentTheme = theme.value;\n  }\n\n  /**\n   * Get the preloaded theme from document\n   */\n  function getPreloadedTheme(): AgentThemeId {\n    return getThemeFromDocument();\n  }\n\n  return {\n    theme,\n    ready,\n    setTheme,\n    initTheme,\n    applyTo,\n    getPreloadedTheme,\n  };\n}\n\n/**\n * Preload theme before Vue mounts (call in main.ts)\n * This prevents theme flashing on page load.\n */\nexport async function preloadAgentTheme(): Promise<AgentThemeId> {\n  let themeId: AgentThemeId = DEFAULT_THEME;\n\n  try {\n    const result = await chrome.storage.local.get(STORAGE_KEY_THEME);\n    const stored = result[STORAGE_KEY_THEME];\n\n    if (isValidTheme(stored)) {\n      themeId = stored;\n    }\n  } catch (error) {\n    console.error('[preloadAgentTheme] Failed to load theme:', error);\n  }\n\n  // Set on document element for immediate application\n  document.documentElement.dataset.agentTheme = themeId;\n\n  return themeId;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAgentThreads.ts",
    "content": "/**\n * Composable for grouping messages into conversation threads.\n * Transforms flat AgentMessage[] into structured AgentThread[] for UI rendering.\n */\nimport { computed, type InjectionKey, type Ref } from 'vue';\nimport type {\n  AgentMessage,\n  AgentMessageAttachmentMetadata,\n  AttachmentMetadata,\n} from 'chrome-mcp-shared';\nimport type { RequestState } from './useAgentChat';\n\n/**\n * Injection key for agent server port.\n * Provided by AgentChat.vue for child components to access attachment URLs.\n */\nexport const AGENT_SERVER_PORT_KEY: InjectionKey<Ref<number | null>> = Symbol('agentServerPort');\n\n/** Thread state */\nexport type AgentThreadState =\n  | 'idle'\n  | 'starting'\n  | 'running'\n  | 'completed'\n  | 'error'\n  | 'cancelled';\n\n/** Tool kinds for presentation */\nexport type ToolKind = 'grep' | 'read' | 'edit' | 'run' | 'plan' | 'generic';\n\n/** Tool severity for styling */\nexport type ToolSeverity = 'info' | 'success' | 'warning' | 'error';\n\n/** Diff statistics for edit operations */\nexport interface DiffStats {\n  addedLines?: number;\n  deletedLines?: number;\n  totalLines?: number;\n}\n\n/** Structured tool presentation */\nexport interface ToolPresentation {\n  kind: ToolKind;\n  label: string;\n  title: string;\n  subtitle?: string;\n  details?: string;\n  files?: string[];\n  /** File path for single-file operations */\n  filePath?: string;\n  /** Diff statistics for edit/write operations */\n  diffStats?: DiffStats;\n  command?: string;\n  /** Command description from bash tool */\n  commandDescription?: string;\n  query?: string;\n  /** Search pattern for grep/glob */\n  pattern?: string;\n  /** Search path */\n  searchPath?: string;\n  engine?: string;\n  severity: ToolSeverity;\n  phase: 'use' | 'result';\n  raw: { content: string; metadata?: Record<string, unknown> };\n}\n\n/** Timeline item types */\nexport type TimelineItem =\n  | {\n      kind: 'user_prompt';\n      id: string;\n      requestId?: string;\n      createdAt: string;\n      messageId: string;\n      text: string;\n      attachments: AttachmentMetadata[];\n    }\n  | {\n      kind: 'assistant_text';\n      id: string;\n      requestId?: string;\n      createdAt: string;\n      messageId: string;\n      text: string;\n      isStreaming: boolean;\n    }\n  | {\n      kind: 'tool_use';\n      id: string;\n      requestId?: string;\n      createdAt: string;\n      messageId: string;\n      tool: ToolPresentation;\n      isStreaming: boolean;\n    }\n  | {\n      kind: 'tool_result';\n      id: string;\n      requestId?: string;\n      createdAt: string;\n      messageId: string;\n      tool: ToolPresentation;\n      isError: boolean;\n    }\n  | {\n      kind: 'status';\n      id: string;\n      requestId?: string;\n      createdAt: string;\n      status: string;\n      text?: string;\n    };\n\n/** Client metadata for web editor apply messages */\nexport interface WebEditorApplyMeta {\n  kind: 'web_editor_apply_batch' | 'web_editor_apply_single';\n  pageUrl?: string;\n  elementCount?: number;\n  elementLabels?: string[];\n}\n\n/** Thread header data for special message types */\nexport interface ThreadHeader {\n  /** Display text (compact representation) */\n  displayText?: string;\n  /** Full prompt content for hover display */\n  fullContent: string;\n  /** Web editor apply metadata */\n  webEditorApply?: WebEditorApplyMeta;\n}\n\n/** A grouped conversation thread */\nexport interface AgentThread {\n  id: string;\n  requestId?: string;\n  title: string;\n  createdAt: string;\n  state: AgentThreadState;\n  items: TimelineItem[];\n  /** Attachments from the user prompt (for display in thread header) */\n  attachments: AttachmentMetadata[];\n  /** Thread header data for special message rendering */\n  header?: ThreadHeader;\n}\n\n/** Options for useAgentThreads */\nexport interface UseAgentThreadsOptions {\n  messages: Ref<AgentMessage[]>;\n  /** Request lifecycle state (replaces isStreaming for thread state calculation) */\n  requestState: Ref<RequestState>;\n  currentRequestId: Ref<string | null>;\n}\n\n/**\n * Normalize a string for comparison\n */\nfunction normalize(s: string | undefined): string {\n  return (s ?? '').toLowerCase().trim();\n}\n\n/**\n * Get first string from multiple candidates\n */\nfunction firstString(...args: unknown[]): string | undefined {\n  for (const arg of args) {\n    if (typeof arg === 'string' && arg.trim()) {\n      return arg.trim();\n    }\n  }\n  return undefined;\n}\n\n/**\n * Extract text after a prefix (e.g., \"Running: <command>\")\n */\nfunction extractAfterPrefix(content: string, prefix: string): string | undefined {\n  const idx = content.indexOf(prefix);\n  if (idx === -1) return undefined;\n  return content.slice(idx + prefix.length).trim();\n}\n\n/**\n * Summarize content to one line\n */\nfunction summarizeOneLine(content: string): string {\n  const line = content.split('\\n')[0]?.trim() ?? '';\n  return line.length > 60 ? line.slice(0, 57) + '...' : line;\n}\n\n/**\n * Title case a string\n */\nfunction titleCase(s: string): string {\n  return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();\n}\n\n/**\n * Extract file name from path\n */\nfunction getFileName(filePath: string): string {\n  return filePath.split('/').pop() || filePath;\n}\n\n/**\n * Build diff stats from metadata\n */\nfunction buildDiffStats(meta: Record<string, unknown>): DiffStats | undefined {\n  const addedLines = typeof meta.addedLines === 'number' ? meta.addedLines : undefined;\n  const deletedLines = typeof meta.deletedLines === 'number' ? meta.deletedLines : undefined;\n  const totalLines = typeof meta.totalLines === 'number' ? meta.totalLines : undefined;\n\n  if (addedLines !== undefined || deletedLines !== undefined || totalLines !== undefined) {\n    return { addedLines, deletedLines, totalLines };\n  }\n  return undefined;\n}\n\n/**\n * Present a tool message as ToolPresentation\n */\nfunction presentTool(msg: AgentMessage): ToolPresentation {\n  const meta = (msg.metadata ?? {}) as Record<string, unknown>;\n  const phase = msg.messageType === 'tool_use' ? 'use' : 'result';\n  const engine = msg.cliSource;\n\n  const toolName =\n    firstString(meta.toolName as string, meta.tool_name as string) ??\n    (typeof engine === 'string' ? engine : undefined) ??\n    'tool';\n\n  const isError =\n    meta.is_error === true ||\n    meta.isError === true ||\n    (typeof msg.content === 'string' && msg.content.trimStart().startsWith('Error:'));\n\n  // Extract common metadata fields\n  const filePath = firstString(meta.filePath as string);\n  const command = firstString(meta.command as string);\n  const commandDescription = firstString(meta.commandDescription as string);\n  const pattern = firstString(meta.pattern as string);\n  const searchPath = firstString(meta.searchPath as string);\n  const diffStats = buildDiffStats(meta);\n\n  // Rule 1: Plan / TodoWrite\n  if (\n    meta.planPhase ||\n    normalize(toolName) === 'plan' ||\n    normalize(toolName) === 'todo_write' ||\n    normalize(toolName) === 'todowrite'\n  ) {\n    const todoCount = typeof meta.todoCount === 'number' ? meta.todoCount : undefined;\n    return {\n      kind: 'plan',\n      label: 'Plan',\n      title: todoCount ? `${todoCount} tasks` : summarizeOneLine(msg.content) || 'Plan update',\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 2: Edit tool with file path and diff stats\n  if (\n    normalize(toolName).includes('edit') ||\n    normalize(toolName) === 'apply_patch' ||\n    normalize(toolName) === 'patch_file'\n  ) {\n    const fileName = filePath ? getFileName(filePath) : undefined;\n    return {\n      kind: 'edit',\n      label: 'Edit',\n      title: fileName || filePath || 'File',\n      filePath,\n      diffStats,\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'success',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 3: Write/Create tool\n  if (normalize(toolName).includes('write') || normalize(toolName) === 'create_file') {\n    const fileName = filePath ? getFileName(filePath) : undefined;\n    return {\n      kind: 'edit',\n      label: 'Write',\n      title: fileName || filePath || 'File',\n      filePath,\n      diffStats,\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'success',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 4: File summary (Codex file_change -> metadata.files)\n  const files = Array.isArray(meta.files)\n    ? (meta.files as string[]).filter((x) => typeof x === 'string')\n    : [];\n  if (files.length > 0) {\n    const title = files.length === 1 ? getFileName(files[0]) : `${files.length} files`;\n    return {\n      kind: 'edit',\n      label: 'Edit',\n      title,\n      subtitle: files.length > 1 ? files.slice(0, 3).map(getFileName).join(', ') : undefined,\n      files,\n      filePath: files.length === 1 ? files[0] : undefined,\n      diffStats,\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'success',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 5: Command (Bash/shell)\n  if (\n    normalize(toolName) === 'bash' ||\n    normalize(toolName).includes('shell') ||\n    typeof command === 'string' ||\n    msg.content.startsWith('Running:') ||\n    msg.content.startsWith('Ran:')\n  ) {\n    const extractedCommand =\n      command ??\n      extractAfterPrefix(msg.content, 'Running:') ??\n      extractAfterPrefix(msg.content, 'Ran:') ??\n      undefined;\n\n    const details =\n      firstString(meta.output as string) ?? (phase === 'result' ? msg.content : undefined);\n\n    return {\n      kind: 'run',\n      label: 'Run',\n      title: commandDescription || extractedCommand?.trim() || 'Command',\n      subtitle: commandDescription && extractedCommand ? extractedCommand.trim() : undefined,\n      command: extractedCommand?.trim(),\n      commandDescription,\n      details,\n      engine,\n      severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 6: Grep/Search with pattern\n  if (normalize(toolName) === 'grep' || normalize(toolName).includes('search') || pattern) {\n    const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');\n    const displayPattern = pattern || queryFromContent?.trim();\n    return {\n      kind: 'grep',\n      label: 'Grep',\n      title: displayPattern || 'Search',\n      pattern: displayPattern,\n      searchPath,\n      query: displayPattern,\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 7: Glob with pattern\n  if (normalize(toolName) === 'glob' || normalize(toolName) === 'glob_files') {\n    return {\n      kind: 'grep',\n      label: 'Glob',\n      title: pattern || 'Pattern search',\n      pattern,\n      searchPath,\n      details: phase === 'result' ? msg.content : undefined,\n      engine,\n      severity: isError ? 'error' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 8: Read tool\n  if (normalize(toolName).includes('read') || filePath) {\n    const fileName = filePath ? getFileName(filePath) : undefined;\n    return {\n      kind: 'read',\n      label: 'Read',\n      title: fileName || filePath || 'File',\n      filePath,\n      engine,\n      severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Rule 9: Read / Edit by action (fallback for content-based detection)\n  const action = firstString(meta.action as string);\n  const fileFromContent = extractAfterPrefix(msg.content, 'Operating on:')?.trim();\n  const inferredKind =\n    action === 'Read'\n      ? 'read'\n      : action === 'Edited' || action === 'Created' || action === 'Deleted'\n        ? 'edit'\n        : null;\n\n  if (fileFromContent || inferredKind) {\n    const kind: ToolKind = inferredKind ?? 'read';\n    return {\n      kind,\n      label: kind === 'read' ? 'Read' : 'Edit',\n      title: fileFromContent ? getFileName(fileFromContent) : toolName,\n      filePath: fileFromContent,\n      diffStats: kind === 'edit' ? diffStats : undefined,\n      engine,\n      severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',\n      phase,\n      raw: { content: msg.content, metadata: meta },\n    };\n  }\n\n  // Fallback: generic tool\n  return {\n    kind: 'generic',\n    label: titleCase(toolName),\n    title: summarizeOneLine(msg.content) || `Using ${toolName}`,\n    details: phase === 'result' ? msg.content : undefined,\n    engine,\n    severity: isError ? 'error' : 'info',\n    phase,\n    raw: { content: msg.content, metadata: meta },\n  };\n}\n\n/**\n * Type guard for AttachmentMetadata.\n * Validates that an unknown value conforms to the AttachmentMetadata interface.\n * Includes semantic validation (non-empty strings, valid numbers).\n */\nfunction isAttachmentMetadata(value: unknown): value is AttachmentMetadata {\n  if (!value || typeof value !== 'object') return false;\n  const v = value as Record<string, unknown>;\n  const index = v.index;\n  const sizeBytes = v.sizeBytes;\n  return (\n    v.version === 1 &&\n    v.kind === 'image' &&\n    typeof v.projectId === 'string' &&\n    (v.projectId as string).trim().length > 0 &&\n    typeof v.messageId === 'string' &&\n    (v.messageId as string).trim().length > 0 &&\n    typeof index === 'number' &&\n    Number.isInteger(index) &&\n    index >= 0 &&\n    typeof v.filename === 'string' &&\n    (v.filename as string).trim().length > 0 &&\n    typeof v.urlPath === 'string' &&\n    (v.urlPath as string).trim().length > 0 &&\n    typeof v.mimeType === 'string' &&\n    (v.mimeType as string).trim().length > 0 &&\n    typeof sizeBytes === 'number' &&\n    Number.isFinite(sizeBytes) &&\n    sizeBytes >= 0 &&\n    typeof v.originalName === 'string' &&\n    (v.originalName as string).trim().length > 0 &&\n    typeof v.createdAt === 'string' &&\n    (v.createdAt as string).trim().length > 0\n  );\n}\n\n/**\n * Extract validated attachments from a message's metadata.\n * Returns sorted by index for consistent display order.\n */\nfunction getMessageAttachments(msg: AgentMessage): AttachmentMetadata[] {\n  const meta = (msg.metadata ?? {}) as AgentMessageAttachmentMetadata;\n  const attachments = meta.attachments;\n  if (!Array.isArray(attachments)) return [];\n  return attachments.filter(isAttachmentMetadata).sort((a, b) => a.index - b.index);\n}\n\n/**\n * Map a message to a timeline item\n */\nfunction mapMessageToTimelineItem(msg: AgentMessage): TimelineItem | null {\n  const createdAt = msg.createdAt;\n  const requestId = msg.requestId?.trim() || undefined;\n\n  // User chat messages are displayed in thread header (title + attachments),\n  // so we don't create timeline items for them to avoid duplicate display.\n  if (msg.role === 'user' && msg.messageType === 'chat') {\n    return null;\n  }\n\n  if (msg.role === 'assistant' && msg.messageType === 'chat') {\n    return {\n      kind: 'assistant_text',\n      id: msg.id,\n      requestId,\n      createdAt,\n      messageId: msg.id,\n      text: msg.content,\n      isStreaming: msg.isStreaming === true && !msg.isFinal,\n    };\n  }\n\n  if (msg.role === 'tool' && msg.messageType === 'tool_use') {\n    return {\n      kind: 'tool_use',\n      id: msg.id,\n      requestId,\n      createdAt,\n      messageId: msg.id,\n      tool: presentTool(msg),\n      isStreaming: msg.isStreaming === true && !msg.isFinal,\n    };\n  }\n\n  if (msg.role === 'tool' && msg.messageType === 'tool_result') {\n    const tool = presentTool(msg);\n    return {\n      kind: 'tool_result',\n      id: msg.id,\n      requestId,\n      createdAt,\n      messageId: msg.id,\n      tool,\n      isError: tool.severity === 'error',\n    };\n  }\n\n  // Status messages\n  if (msg.messageType === 'status' || msg.role === 'system') {\n    return {\n      kind: 'status',\n      id: `status:${requestId ?? 'legacy'}:${msg.id}`,\n      requestId,\n      createdAt,\n      status: 'ready',\n      text: msg.content,\n    };\n  }\n\n  return null;\n}\n\n/**\n * Build threads from messages\n */\nfunction buildThreads(\n  messages: AgentMessage[],\n  requestState: RequestState,\n  currentRequestId: string | null,\n): AgentThread[] {\n  // Sort messages by createdAt\n  const sortedMessages = [...messages].sort((a, b) => a.createdAt.localeCompare(b.createdAt));\n\n  // Group messages by requestId or legacy grouping\n  let legacyCounter = 0;\n  let currentLegacyKey: string | null = null;\n\n  const groups = new Map<\n    string,\n    {\n      key: string;\n      requestId?: string;\n      firstAt: string;\n      title?: string;\n      items: TimelineItem[];\n      attachments: AttachmentMetadata[];\n      /** Thread header for special message types */\n      header?: ThreadHeader;\n    }\n  >();\n\n  function ensureGroup(key: string, requestId: string | undefined, createdAt: string) {\n    if (!groups.has(key)) {\n      groups.set(key, { key, requestId, firstAt: createdAt, items: [], attachments: [] });\n    }\n    return groups.get(key)!;\n  }\n\n  for (const msg of sortedMessages) {\n    const rid = msg.requestId?.trim() || undefined;\n\n    // Determine group key\n    let key: string;\n    if (rid) {\n      key = `rid:${rid}`;\n    } else {\n      if (msg.role === 'user') {\n        currentLegacyKey = `legacy:${legacyCounter++}`;\n      }\n      key = currentLegacyKey ?? 'legacy:orphan';\n    }\n\n    const group = ensureGroup(key, rid, msg.createdAt);\n\n    // Title, attachments, and header: first user chat message in group wins\n    if (!group.title && msg.role === 'user' && msg.messageType === 'chat') {\n      const fullContent = msg.content.trim();\n      const attachments = getMessageAttachments(msg);\n      const meta = (msg.metadata ?? {}) as Record<string, unknown>;\n\n      // Extract client metadata for special message types (with runtime validation)\n      const rawClientMeta = meta.clientMeta;\n      const rawDisplayText = meta.displayText;\n\n      // Validate clientMeta structure\n      const clientMeta: WebEditorApplyMeta | undefined =\n        rawClientMeta &&\n        typeof rawClientMeta === 'object' &&\n        'kind' in rawClientMeta &&\n        typeof (rawClientMeta as Record<string, unknown>).kind === 'string' &&\n        ((rawClientMeta as Record<string, unknown>).kind === 'web_editor_apply_batch' ||\n          (rawClientMeta as Record<string, unknown>).kind === 'web_editor_apply_single')\n          ? (rawClientMeta as WebEditorApplyMeta)\n          : undefined;\n\n      const displayText = typeof rawDisplayText === 'string' ? rawDisplayText : undefined;\n\n      // Store attachments for thread header display\n      if (attachments.length > 0) {\n        group.attachments = attachments;\n      }\n\n      // Build thread header for special message types\n      if (clientMeta?.kind?.startsWith('web_editor_apply')) {\n        group.header = {\n          displayText: displayText || `Apply ${clientMeta.elementCount ?? 0} changes`,\n          fullContent,\n          webEditorApply: clientMeta,\n        };\n        // Use display text as title for web editor apply messages\n        group.title = displayText || `Apply ${clientMeta.elementCount ?? 0} changes`;\n      } else if (fullContent) {\n        group.title = fullContent;\n      } else {\n        // Image-only message - use attachment count as title\n        group.title =\n          attachments.length > 0\n            ? `Sent ${attachments.length} image${attachments.length === 1 ? '' : 's'}`\n            : 'Untitled request';\n      }\n\n      group.firstAt = msg.createdAt;\n    }\n\n    // Map message to timeline item\n    const item = mapMessageToTimelineItem(msg);\n    if (item) group.items.push(item);\n\n    // Update earliest timestamp\n    if (msg.createdAt < group.firstAt) group.firstAt = msg.createdAt;\n  }\n\n  // Convert groups to threads\n  const threads: AgentThread[] = [];\n\n  for (const g of groups.values()) {\n    const requestId = g.requestId;\n\n    // Determine thread state based on requestState (not isStreaming)\n    // This ensures the thread shows as running even during tool execution\n    const isActiveRequest =\n      requestState === 'starting' || requestState === 'ready' || requestState === 'running';\n\n    let state: AgentThreadState = 'completed';\n    if (isActiveRequest && currentRequestId && requestId === currentRequestId) {\n      // Map requestState to thread state\n      state = requestState === 'running' ? 'running' : 'starting';\n    } else if (g.items.some((item) => item.kind === 'status')) {\n      state = 'idle';\n    }\n\n    // Sort items by createdAt\n    const items = [...g.items].sort((a, b) => a.createdAt.localeCompare(b.createdAt));\n\n    // Add status item for active requests\n    // Use stable ID without Date.now() to prevent component remount on each render\n    if (state === 'running' || state === 'starting') {\n      const statusText = state === 'running' ? 'Working...' : 'Starting...';\n      items.push({\n        kind: 'status',\n        id: `status:streaming:${requestId ?? 'current'}`,\n        requestId,\n        createdAt: new Date().toISOString(),\n        status: state,\n        text: statusText,\n      });\n    }\n\n    threads.push({\n      id: g.key,\n      requestId,\n      title: g.title ?? 'Untitled request',\n      createdAt: g.firstAt,\n      state,\n      items,\n      attachments: g.attachments,\n      header: g.header,\n    });\n  }\n\n  // Sort threads by createdAt\n  return threads.sort((a, b) => a.createdAt.localeCompare(b.createdAt));\n}\n\n/**\n * Composable for managing agent threads\n */\nexport function useAgentThreads(options: UseAgentThreadsOptions) {\n  const threads = computed(() => {\n    return buildThreads(\n      options.messages.value,\n      options.requestState.value,\n      options.currentRequestId.value,\n    );\n  });\n\n  return {\n    threads,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useAttachments.ts",
    "content": "/**\n * Composable for managing file attachments.\n * Handles file selection, drag-drop, paste, conversion, preview, and removal.\n */\nimport { ref, computed } from 'vue';\nimport type { AgentAttachment } from 'chrome-mcp-shared';\n\nconst MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\nconst MAX_ATTACHMENTS = 10; // Maximum number of attachments\n\n// Allowed image MIME types (exclude SVG for security)\nconst ALLOWED_IMAGE_TYPES = new Set([\n  'image/png',\n  'image/jpeg',\n  'image/jpg',\n  'image/gif',\n  'image/webp',\n]);\n\n/**\n * Extended attachment type with preview URL support.\n */\nexport interface AttachmentWithPreview extends AgentAttachment {\n  /** Data URL for image preview (data:xxx;base64,...) */\n  previewUrl?: string;\n}\n\nexport function useAttachments() {\n  const attachments = ref<AttachmentWithPreview[]>([]);\n  const fileInputRef = ref<HTMLInputElement | null>(null);\n  const error = ref<string | null>(null);\n  const isDragOver = ref(false);\n\n  // Computed: check if we have any image attachments\n  const hasImages = computed(() => attachments.value.some((a) => a.type === 'image'));\n\n  // Computed: check if we can add more attachments\n  const canAddMore = computed(() => attachments.value.length < MAX_ATTACHMENTS);\n\n  /**\n   * Open file picker for image selection.\n   */\n  function openFilePicker(): void {\n    fileInputRef.value?.click();\n  }\n\n  /**\n   * Convert file to base64 string.\n   */\n  function fileToBase64(file: File): Promise<string> {\n    return new Promise((resolve, reject) => {\n      const reader = new FileReader();\n      reader.onload = () => {\n        const result = reader.result as string;\n        // Remove data:xxx;base64, prefix\n        const base64 = result.split(',')[1];\n        resolve(base64);\n      };\n      reader.onerror = () => reject(reader.error);\n      reader.readAsDataURL(file);\n    });\n  }\n\n  /**\n   * Generate preview URL for image attachments.\n   */\n  function getPreviewUrl(attachment: AttachmentWithPreview): string {\n    if (attachment.previewUrl) {\n      return attachment.previewUrl;\n    }\n    // Generate data URL from base64\n    return `data:${attachment.mimeType};base64,${attachment.dataBase64}`;\n  }\n\n  /**\n   * Process files and add them as attachments.\n   * This is the core method used by file input, drag-drop, and paste handlers.\n   */\n  async function handleFiles(files: File[]): Promise<void> {\n    error.value = null;\n\n    // Filter to only allowed image types (exclude SVG for security)\n    const imageFiles = files.filter((file) => ALLOWED_IMAGE_TYPES.has(file.type));\n    if (imageFiles.length === 0) {\n      error.value = 'Only PNG, JPEG, GIF, and WebP images are supported.';\n      return;\n    }\n\n    // Check attachment limit\n    const remaining = MAX_ATTACHMENTS - attachments.value.length;\n    if (remaining <= 0) {\n      error.value = `Maximum ${MAX_ATTACHMENTS} attachments allowed.`;\n      return;\n    }\n\n    const filesToProcess = imageFiles.slice(0, remaining);\n    if (filesToProcess.length < imageFiles.length) {\n      error.value = `Only ${remaining} more attachment(s) allowed. Some files were skipped.`;\n    }\n\n    for (const file of filesToProcess) {\n      // Validate file size\n      if (file.size > MAX_FILE_SIZE) {\n        error.value = `File \"${file.name}\" is too large. Maximum size is 10MB.`;\n        continue;\n      }\n\n      try {\n        const base64 = await fileToBase64(file);\n        const previewUrl = `data:${file.type};base64,${base64}`;\n\n        attachments.value.push({\n          type: 'image',\n          name: file.name,\n          mimeType: file.type || 'image/png',\n          dataBase64: base64,\n          previewUrl,\n        });\n      } catch (err) {\n        console.error('Failed to read file:', err);\n        error.value = `Failed to read file \"${file.name}\".`;\n      }\n    }\n  }\n\n  /**\n   * Handle file selection from input element.\n   */\n  async function handleFileSelect(event: Event): Promise<void> {\n    const input = event.target as HTMLInputElement;\n    const files = input.files;\n    if (!files || files.length === 0) return;\n\n    await handleFiles(Array.from(files));\n\n    // Clear input to allow selecting the same file again\n    input.value = '';\n  }\n\n  /**\n   * Handle drag over event - update visual state.\n   */\n  function handleDragOver(event: DragEvent): void {\n    event.preventDefault();\n    event.stopPropagation();\n    isDragOver.value = true;\n  }\n\n  /**\n   * Handle drag leave event - reset visual state.\n   */\n  function handleDragLeave(event: DragEvent): void {\n    event.preventDefault();\n    event.stopPropagation();\n    isDragOver.value = false;\n  }\n\n  /**\n   * Handle drop event - process dropped files.\n   */\n  async function handleDrop(event: DragEvent): Promise<void> {\n    event.preventDefault();\n    event.stopPropagation();\n    isDragOver.value = false;\n\n    const files = event.dataTransfer?.files;\n    if (!files || files.length === 0) return;\n\n    await handleFiles(Array.from(files));\n  }\n\n  /**\n   * Handle paste event - extract and process pasted images.\n   */\n  async function handlePaste(event: ClipboardEvent): Promise<void> {\n    const items = event.clipboardData?.items;\n    if (!items) return;\n\n    const imageFiles: File[] = [];\n    for (const item of items) {\n      // Only allow specific image types (exclude SVG for security)\n      if (ALLOWED_IMAGE_TYPES.has(item.type)) {\n        const file = item.getAsFile();\n        if (file) {\n          // Generate a name for pasted images (they don't have one)\n          const ext = item.type.split('/')[1] || 'png';\n          const namedFile = new File([file], `pasted-image-${Date.now()}.${ext}`, {\n            type: file.type,\n          });\n          imageFiles.push(namedFile);\n        }\n      }\n    }\n\n    if (imageFiles.length > 0) {\n      // Prevent default paste behavior for images\n      event.preventDefault();\n      await handleFiles(imageFiles);\n    }\n    // Let text paste through normally\n  }\n\n  /**\n   * Remove attachment by index.\n   */\n  function removeAttachment(index: number): void {\n    attachments.value.splice(index, 1);\n    error.value = null;\n  }\n\n  /**\n   * Clear all attachments.\n   */\n  function clearAttachments(): void {\n    attachments.value = [];\n    error.value = null;\n  }\n\n  /**\n   * Get attachments for sending (strips preview URLs).\n   */\n  function getAttachments(): AgentAttachment[] | undefined {\n    if (attachments.value.length === 0) return undefined;\n\n    return attachments.value.map(({ type, name, mimeType, dataBase64 }) => ({\n      type,\n      name,\n      mimeType,\n      dataBase64,\n    }));\n  }\n\n  return {\n    // State\n    attachments,\n    fileInputRef,\n    error,\n    isDragOver,\n\n    // Computed\n    hasImages,\n    canAddMore,\n\n    // Methods\n    openFilePicker,\n    handleFileSelect,\n    handleFiles,\n    handleDragOver,\n    handleDragLeave,\n    handleDrop,\n    handlePaste,\n    removeAttachment,\n    clearAttachments,\n    getAttachments,\n    getPreviewUrl,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useFakeCaret.ts",
    "content": "/**\n * Composable for rendering a \"fake\" caret overlay on top of a textarea.\n *\n * Implementation notes:\n * - We do NOT intercept input; we only compute caret coordinates.\n * - A hidden \"mirror\" element is used to measure caret position reliably with wrapping.\n * - The actual textarea input/IME/selection behavior is preserved.\n * - When calculation is unreliable (IME/selection/error), we fall back to native caret.\n */\nimport {\n  computed,\n  onUnmounted,\n  ref,\n  watch,\n  type CSSProperties,\n  type ComputedRef,\n  type Ref,\n} from 'vue';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface FakeCaretTrailPoint {\n  x: number;\n  y: number;\n  alpha: number;\n}\n\nexport interface UseFakeCaretOptions {\n  /** Reference to the textarea element */\n  textareaRef: Ref<HTMLTextAreaElement | null>;\n  /**\n   * Feature flag for enabling the fake caret.\n   * When false, the composable will report showFakeCaret=false\n   * and the caller should display the native caret.\n   */\n  enabled?: Ref<boolean>;\n}\n\nexport interface UseFakeCaretReturn {\n  /** Style for the overlay container (position: absolute, inset: 0) */\n  overlayStyle: ComputedRef<CSSProperties>;\n  /** Whether to show the fake caret (false when degraded) */\n  showFakeCaret: ComputedRef<boolean>;\n  /** Current X position of caret (animated) */\n  caretX: Ref<number>;\n  /** Current Y position of caret (animated) */\n  caretY: Ref<number>;\n  /** Trail points for comet tail effect */\n  trail: Ref<FakeCaretTrailPoint[]>;\n  /** Manually trigger position update */\n  updatePosition: () => void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst MAX_TRAIL_POINTS = 24;\nconst TRAIL_DECAY = 0.86;\nconst TRAIL_MIN_ALPHA = 0.06;\nconst TRAIL_MIN_DISTANCE_PX = 0.35;\nconst SMOOTHING = 0.35;\nconst SNAP_DISTANCE_PX = 0.2;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFiniteNumber(v: unknown): v is number {\n  return typeof v === 'number' && Number.isFinite(v);\n}\n\nfunction clamp(v: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, v));\n}\n\n// =============================================================================\n// Main Composable\n// =============================================================================\n\nexport function useFakeCaret(options: UseFakeCaretOptions): UseFakeCaretReturn {\n  // Default to disabled (opt-in) for safer rollout\n  const enabled = options.enabled ?? ref(false);\n\n  // Position state (animated values)\n  const caretX = ref(0);\n  const caretY = ref(0);\n  const trail = ref<FakeCaretTrailPoint[]>([]);\n\n  // Internal state\n  const isFocused = ref(false);\n  const isComposing = ref(false);\n  const hasSelection = ref(false);\n  const hasValidMeasurement = ref(false);\n  const prefersReducedMotion = ref(false);\n\n  // Target position (raw measurement)\n  let targetX = 0;\n  let targetY = 0;\n\n  // Animation state\n  let scheduled = false;\n  let rafId: number | null = null;\n\n  // Mirror element for measurement\n  let mirrorEl: HTMLDivElement | null = null;\n  let lastMirrorKey = '';\n\n  // Resize observer\n  let resizeObserver: ResizeObserver | null = null;\n\n  // Trail tracking\n  let lastTrailX = 0;\n  let lastTrailY = 0;\n\n  // Disposed flag to prevent operations after unmount\n  let disposed = false;\n\n  // ---------------------------------------------------------------------------\n  // Computed Properties\n  // ---------------------------------------------------------------------------\n\n  const overlayStyle = computed<CSSProperties>(() => ({\n    position: 'absolute',\n    inset: 0,\n    pointerEvents: 'none',\n    overflow: 'hidden',\n  }));\n\n  const showFakeCaret = computed<boolean>(() => {\n    if (!enabled.value) return false;\n    const el = options.textareaRef.value;\n    if (!el) return false;\n    if (!isFocused.value) return false;\n    if (isComposing.value) return false;\n    if (hasSelection.value) return false;\n    return hasValidMeasurement.value;\n  });\n\n  // ---------------------------------------------------------------------------\n  // Mirror Element Management\n  // ---------------------------------------------------------------------------\n\n  function ensureMirror(): HTMLDivElement | null {\n    if (disposed) return null;\n    if (mirrorEl) return mirrorEl;\n    if (typeof document === 'undefined' || !document.body) return null;\n\n    const el = document.createElement('div');\n    el.setAttribute('data-ac-fake-caret-mirror', 'true');\n    el.style.position = 'fixed';\n    el.style.top = '0';\n    el.style.left = '-10000px';\n    el.style.visibility = 'hidden';\n    el.style.pointerEvents = 'none';\n    el.style.whiteSpace = 'pre-wrap';\n    el.style.wordBreak = 'break-word';\n    el.style.overflowWrap = 'break-word';\n    el.style.overflow = 'auto';\n    el.style.contain = 'layout style paint';\n    el.style.border = '0';\n    el.style.background = 'transparent';\n\n    document.body.appendChild(el);\n    mirrorEl = el;\n    return mirrorEl;\n  }\n\n  function syncMirrorStyle(textarea: HTMLTextAreaElement, mirror: HTMLDivElement): void {\n    const cs = window.getComputedStyle(textarea);\n\n    // clientWidth includes padding but excludes scrollbar\n    const width = `${textarea.clientWidth}px`;\n    const height = `${textarea.clientHeight}px`;\n    const tabSize = cs.getPropertyValue('tab-size');\n\n    // Build cache key to avoid unnecessary style updates\n    const key = [\n      width,\n      height,\n      cs.font,\n      cs.padding,\n      cs.letterSpacing,\n      cs.lineHeight,\n      cs.textTransform,\n      cs.textIndent,\n      cs.textAlign,\n      cs.direction,\n      tabSize,\n    ].join('|');\n\n    if (key === lastMirrorKey) return;\n    lastMirrorKey = key;\n\n    mirror.style.boxSizing = 'border-box';\n    mirror.style.width = width;\n    mirror.style.height = height;\n    mirror.style.padding = cs.padding;\n    mirror.style.font = cs.font;\n    mirror.style.letterSpacing = cs.letterSpacing;\n    mirror.style.lineHeight = cs.lineHeight;\n    mirror.style.textTransform = cs.textTransform;\n    mirror.style.textIndent = cs.textIndent;\n    mirror.style.textAlign = cs.textAlign;\n    mirror.style.direction = cs.direction;\n\n    if (tabSize) {\n      mirror.style.setProperty('tab-size', tabSize);\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Caret Position Measurement\n  // ---------------------------------------------------------------------------\n\n  function measureCaret(textarea: HTMLTextAreaElement): { x: number; y: number } | null {\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n\n    if (!isFiniteNumber(start) || !isFiniteNumber(end)) {\n      hasSelection.value = false;\n      return null;\n    }\n\n    hasSelection.value = start !== end;\n    if (hasSelection.value) return null;\n    if (isComposing.value) return null;\n    if (textarea.clientWidth <= 0 || textarea.clientHeight <= 0) return null;\n\n    const mirror = ensureMirror();\n    if (!mirror) return null;\n\n    syncMirrorStyle(textarea, mirror);\n\n    // Keep mirror scroll in sync\n    mirror.scrollTop = textarea.scrollTop;\n    mirror.scrollLeft = textarea.scrollLeft;\n\n    // Build mirror DOM: [beforeText][marker]\n    mirror.innerHTML = '';\n    const beforeText = textarea.value.slice(0, start);\n    mirror.appendChild(document.createTextNode(beforeText));\n\n    const marker = document.createElement('span');\n    marker.textContent = '\\u200b'; // Zero-width space\n    marker.style.display = 'inline-block';\n    marker.style.width = '1px';\n    marker.style.height = '1em';\n    mirror.appendChild(marker);\n\n    const markerRect = marker.getBoundingClientRect();\n    const mirrorRect = mirror.getBoundingClientRect();\n\n    const x = markerRect.left - mirrorRect.left;\n    const y = markerRect.top - mirrorRect.top;\n\n    if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;\n\n    // Clamp to textarea viewport\n    const clampedX = clamp(x, 0, textarea.clientWidth + 2);\n    const clampedY = clamp(y, 0, textarea.clientHeight + 2);\n\n    // If wildly off, treat as invalid\n    if (Math.abs(clampedX - x) > 20 || Math.abs(clampedY - y) > 20) {\n      return null;\n    }\n\n    return { x: clampedX, y: clampedY };\n  }\n\n  // ---------------------------------------------------------------------------\n  // Position Updates\n  // ---------------------------------------------------------------------------\n\n  function applyTarget(x: number, y: number): void {\n    const positionChanged = targetX !== x || targetY !== y;\n    targetX = x;\n    targetY = y;\n\n    // Skip animation if reduced motion preferred\n    if (prefersReducedMotion.value) {\n      caretX.value = x;\n      caretY.value = y;\n      trail.value = [];\n      lastTrailX = x;\n      lastTrailY = y;\n      return;\n    }\n\n    // Restart RAF if position changed (may have been stopped when idle)\n    if (positionChanged && showFakeCaret.value) {\n      startLoop();\n    }\n  }\n\n  function updateNow(): void {\n    const textarea = options.textareaRef.value;\n    if (!textarea) {\n      hasValidMeasurement.value = false;\n      return;\n    }\n\n    // Only measure when we intend to show the fake caret\n    if (!enabled.value || !isFocused.value || isComposing.value) {\n      hasValidMeasurement.value = false;\n      return;\n    }\n\n    const pos = measureCaret(textarea);\n    if (!pos) {\n      hasValidMeasurement.value = false;\n      return;\n    }\n\n    hasValidMeasurement.value = true;\n    applyTarget(pos.x, pos.y);\n  }\n\n  function scheduleUpdate(): void {\n    if (disposed) return;\n    if (scheduled) return;\n    scheduled = true;\n    requestAnimationFrame(() => {\n      scheduled = false;\n      if (!disposed) {\n        updateNow();\n      }\n    });\n  }\n\n  function updatePosition(): void {\n    scheduleUpdate();\n  }\n\n  // ---------------------------------------------------------------------------\n  // Animation Loop\n  // ---------------------------------------------------------------------------\n\n  function tick(): void {\n    if (!showFakeCaret.value) return;\n    if (prefersReducedMotion.value) return;\n\n    // Smooth caret position\n    const dx = targetX - caretX.value;\n    const dy = targetY - caretY.value;\n\n    // Check if caret has snapped to target\n    const isSnapped = Math.abs(dx) < SNAP_DISTANCE_PX && Math.abs(dy) < SNAP_DISTANCE_PX;\n\n    if (isSnapped) {\n      caretX.value = targetX;\n      caretY.value = targetY;\n    } else {\n      caretX.value = caretX.value + dx * SMOOTHING;\n      caretY.value = caretY.value + dy * SMOOTHING;\n    }\n\n    // Update trail (comet tail effect)\n    const currentTrail = trail.value;\n    const nextTrail: FakeCaretTrailPoint[] = [];\n\n    // Fade existing points\n    for (const p of currentTrail) {\n      const alpha = p.alpha * TRAIL_DECAY;\n      if (alpha >= TRAIL_MIN_ALPHA) {\n        nextTrail.push({ ...p, alpha });\n      }\n    }\n\n    // Add new point if moved enough\n    const moved =\n      Math.abs(caretX.value - lastTrailX) + Math.abs(caretY.value - lastTrailY) >\n      TRAIL_MIN_DISTANCE_PX;\n\n    if (moved) {\n      nextTrail.push({ x: caretX.value, y: caretY.value, alpha: 1 });\n      lastTrailX = caretX.value;\n      lastTrailY = caretY.value;\n    }\n\n    // Only update trail ref if content changed (avoid triggering watchers)\n    // Note: must compare alpha too, otherwise fade animation won't work\n    const trailChanged =\n      nextTrail.length !== currentTrail.length ||\n      nextTrail.some(\n        (p, i) =>\n          p.x !== currentTrail[i]?.x ||\n          p.y !== currentTrail[i]?.y ||\n          Math.abs(p.alpha - (currentTrail[i]?.alpha ?? 0)) > 0.001,\n      );\n\n    if (trailChanged) {\n      // Keep only the last N points\n      if (nextTrail.length > MAX_TRAIL_POINTS) {\n        trail.value = nextTrail.slice(nextTrail.length - MAX_TRAIL_POINTS);\n      } else {\n        trail.value = nextTrail;\n      }\n    }\n\n    // Stop RAF when idle: snapped to target and trail has fully faded\n    if (isSnapped && nextTrail.length === 0) {\n      stopLoop();\n    }\n  }\n\n  function startLoop(): void {\n    if (disposed) return;\n    if (rafId !== null) return;\n    const loop = () => {\n      if (disposed) {\n        rafId = null;\n        return;\n      }\n      rafId = requestAnimationFrame(loop);\n      tick();\n    };\n    rafId = requestAnimationFrame(loop);\n  }\n\n  function stopLoop(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Reduced Motion Preference\n  // ---------------------------------------------------------------------------\n\n  let media: MediaQueryList | null = null;\n  let onMediaChange: ((e: MediaQueryListEvent) => void) | null = null;\n\n  if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n    media = window.matchMedia('(prefers-reduced-motion: reduce)');\n    prefersReducedMotion.value = media.matches;\n    onMediaChange = (e: MediaQueryListEvent) => {\n      prefersReducedMotion.value = e.matches;\n      trail.value = [];\n      scheduleUpdate();\n    };\n    try {\n      media.addEventListener('change', onMediaChange);\n    } catch {\n      // Safari < 14 fallback\n      media.addListener(onMediaChange as EventListener);\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Textarea Event Binding\n  // ---------------------------------------------------------------------------\n\n  watch(\n    () => options.textareaRef.value,\n    (el, _prev, onCleanup) => {\n      if (!el) return;\n\n      const handleFocus = () => {\n        isFocused.value = true;\n        scheduleUpdate();\n      };\n      const handleBlur = () => {\n        isFocused.value = false;\n        hasValidMeasurement.value = false;\n        stopLoop();\n        trail.value = [];\n      };\n      const handleInput = () => scheduleUpdate();\n      const handleKey = () => scheduleUpdate();\n      const handleMouse = () => scheduleUpdate();\n      const handleScroll = () => scheduleUpdate();\n      const handleSelect = () => scheduleUpdate();\n      const handleCompositionStart = () => {\n        isComposing.value = true;\n        scheduleUpdate();\n      };\n      const handleCompositionEnd = () => {\n        isComposing.value = false;\n        scheduleUpdate();\n      };\n\n      el.addEventListener('focus', handleFocus);\n      el.addEventListener('blur', handleBlur);\n      el.addEventListener('input', handleInput);\n      el.addEventListener('keydown', handleKey);\n      el.addEventListener('keyup', handleKey);\n      el.addEventListener('click', handleMouse);\n      el.addEventListener('mouseup', handleMouse);\n      el.addEventListener('scroll', handleScroll, { passive: true });\n      el.addEventListener('select', handleSelect);\n      el.addEventListener('compositionstart', handleCompositionStart);\n      el.addEventListener('compositionend', handleCompositionEnd);\n\n      // Initialize focus state\n      isFocused.value = typeof document !== 'undefined' && document.activeElement === el;\n\n      // Observe size changes\n      if (typeof ResizeObserver !== 'undefined') {\n        resizeObserver?.disconnect();\n        resizeObserver = new ResizeObserver(() => scheduleUpdate());\n        resizeObserver.observe(el);\n      }\n\n      // Initial measurement\n      scheduleUpdate();\n\n      onCleanup(() => {\n        el.removeEventListener('focus', handleFocus);\n        el.removeEventListener('blur', handleBlur);\n        el.removeEventListener('input', handleInput);\n        el.removeEventListener('keydown', handleKey);\n        el.removeEventListener('keyup', handleKey);\n        el.removeEventListener('click', handleMouse);\n        el.removeEventListener('mouseup', handleMouse);\n        el.removeEventListener('scroll', handleScroll);\n        el.removeEventListener('select', handleSelect);\n        el.removeEventListener('compositionstart', handleCompositionStart);\n        el.removeEventListener('compositionend', handleCompositionEnd);\n        resizeObserver?.disconnect();\n        resizeObserver = null;\n      });\n    },\n    { immediate: true },\n  );\n\n  // ---------------------------------------------------------------------------\n  // Watchers for State Changes\n  // ---------------------------------------------------------------------------\n\n  watch(\n    prefersReducedMotion,\n    (reduced) => {\n      if (reduced) {\n        stopLoop();\n        trail.value = [];\n        scheduleUpdate();\n        return;\n      }\n      if (showFakeCaret.value) {\n        startLoop();\n      }\n    },\n    { immediate: true },\n  );\n\n  watch(\n    showFakeCaret,\n    (show) => {\n      if (!show) {\n        stopLoop();\n        trail.value = [];\n        return;\n      }\n\n      // Start animation when showing\n      scheduleUpdate();\n      if (!prefersReducedMotion.value) {\n        startLoop();\n      }\n    },\n    { immediate: true },\n  );\n\n  watch(\n    enabled,\n    (v) => {\n      if (!v) {\n        stopLoop();\n        trail.value = [];\n        hasValidMeasurement.value = false;\n      } else {\n        scheduleUpdate();\n      }\n    },\n    { immediate: true },\n  );\n\n  // ---------------------------------------------------------------------------\n  // Cleanup\n  // ---------------------------------------------------------------------------\n\n  onUnmounted(() => {\n    disposed = true;\n    stopLoop();\n    resizeObserver?.disconnect();\n    resizeObserver = null;\n\n    if (mirrorEl && mirrorEl.parentNode) {\n      mirrorEl.parentNode.removeChild(mirrorEl);\n    }\n    mirrorEl = null;\n\n    if (media && onMediaChange) {\n      try {\n        media.removeEventListener('change', onMediaChange);\n      } catch {\n        media.removeListener(onMediaChange as EventListener);\n      }\n    }\n  });\n\n  return {\n    overlayStyle,\n    showFakeCaret,\n    caretX,\n    caretY,\n    trail,\n    updatePosition,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useFloatingDrag.ts",
    "content": "/**\n * Vue composable for floating drag functionality.\n * Wraps the installFloatingDrag utility for use in Vue components.\n */\n\nimport { ref, onMounted, onUnmounted, type Ref } from 'vue';\nimport {\n  installFloatingDrag,\n  type FloatingPosition,\n} from '@/entrypoints/web-editor-v2/ui/floating-drag';\n\nconst STORAGE_KEY = 'sidepanel_navigator_position';\n\nexport interface UseFloatingDragOptions {\n  /** Storage key for position persistence */\n  storageKey?: string;\n  /** Margin from viewport edges in pixels */\n  clampMargin?: number;\n  /** Threshold for distinguishing click vs drag (ms) */\n  clickThresholdMs?: number;\n  /** Movement threshold for drag activation (px) */\n  moveThresholdPx?: number;\n  /** Default position calculator (called when no saved position exists) */\n  getDefaultPosition?: () => FloatingPosition;\n}\n\nexport interface UseFloatingDragReturn {\n  /** Current position (reactive) */\n  position: Ref<FloatingPosition>;\n  /** Whether dragging is in progress */\n  isDragging: Ref<boolean>;\n  /** Reset position to default */\n  resetToDefault: () => void;\n  /** Computed style object for binding */\n  positionStyle: Ref<{ left: string; top: string }>;\n}\n\n/**\n * Calculate default position (bottom-right corner with margin)\n */\nfunction getDefaultBottomRightPosition(\n  buttonSize: number = 40,\n  margin: number = 12,\n): FloatingPosition {\n  return {\n    left: window.innerWidth - buttonSize - margin,\n    top: window.innerHeight - buttonSize - margin,\n  };\n}\n\n/**\n * Load position from chrome.storage.local\n */\nasync function loadPosition(storageKey: string): Promise<FloatingPosition | null> {\n  try {\n    const result = await chrome.storage.local.get(storageKey);\n    const saved = result[storageKey];\n    if (\n      saved &&\n      typeof saved.left === 'number' &&\n      typeof saved.top === 'number' &&\n      Number.isFinite(saved.left) &&\n      Number.isFinite(saved.top)\n    ) {\n      return saved as FloatingPosition;\n    }\n  } catch (e) {\n    console.warn('Failed to load navigator position:', e);\n  }\n  return null;\n}\n\n/**\n * Save position to chrome.storage.local\n */\nasync function savePosition(storageKey: string, position: FloatingPosition): Promise<void> {\n  try {\n    await chrome.storage.local.set({ [storageKey]: position });\n  } catch (e) {\n    console.warn('Failed to save navigator position:', e);\n  }\n}\n\n/**\n * Vue composable for making an element draggable with position persistence.\n */\nexport function useFloatingDrag(\n  handleRef: Ref<HTMLElement | null>,\n  targetRef: Ref<HTMLElement | null>,\n  options: UseFloatingDragOptions = {},\n): UseFloatingDragReturn {\n  const {\n    storageKey = STORAGE_KEY,\n    clampMargin = 12,\n    clickThresholdMs = 150,\n    moveThresholdPx = 5,\n    getDefaultPosition = () => getDefaultBottomRightPosition(40, clampMargin),\n  } = options;\n\n  const position = ref<FloatingPosition>(getDefaultPosition());\n  const isDragging = ref(false);\n  const positionStyle = ref({ left: `${position.value.left}px`, top: `${position.value.top}px` });\n\n  let cleanup: (() => void) | null = null;\n\n  function updatePositionStyle(): void {\n    positionStyle.value = {\n      left: `${position.value.left}px`,\n      top: `${position.value.top}px`,\n    };\n  }\n\n  function resetToDefault(): void {\n    position.value = getDefaultPosition();\n    updatePositionStyle();\n    savePosition(storageKey, position.value);\n  }\n\n  async function initPosition(): Promise<void> {\n    const saved = await loadPosition(storageKey);\n    if (saved) {\n      // Validate position is within current viewport\n      const maxLeft = window.innerWidth - 40 - clampMargin;\n      const maxTop = window.innerHeight - 40 - clampMargin;\n      position.value = {\n        left: Math.min(Math.max(clampMargin, saved.left), maxLeft),\n        top: Math.min(Math.max(clampMargin, saved.top), maxTop),\n      };\n    } else {\n      position.value = getDefaultPosition();\n    }\n    updatePositionStyle();\n  }\n\n  onMounted(async () => {\n    await initPosition();\n\n    // Wait for refs to be available\n    await new Promise((resolve) => setTimeout(resolve, 0));\n\n    if (!handleRef.value || !targetRef.value) {\n      console.warn('useFloatingDrag: handleRef or targetRef is null');\n      return;\n    }\n\n    cleanup = installFloatingDrag({\n      handleEl: handleRef.value,\n      targetEl: targetRef.value,\n      onPositionChange: (pos) => {\n        position.value = pos;\n        updatePositionStyle();\n        savePosition(storageKey, pos);\n      },\n      clampMargin,\n      clickThresholdMs,\n      moveThresholdPx,\n    });\n\n    // Monitor dragging state via data attribute\n    const observer = new MutationObserver(() => {\n      isDragging.value = handleRef.value?.dataset.dragging === 'true';\n    });\n    if (handleRef.value) {\n      observer.observe(handleRef.value, { attributes: true, attributeFilter: ['data-dragging'] });\n    }\n  });\n\n  onUnmounted(() => {\n    cleanup?.();\n  });\n\n  return {\n    position,\n    isDragging,\n    resetToDefault,\n    positionStyle,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useOpenProjectPreference.ts",
    "content": "/**\n * Composable for managing user preference for opening project directory.\n * Stores the default target (vscode/terminal) in chrome.storage.local.\n */\nimport { ref, type Ref } from 'vue';\nimport type { OpenProjectTarget, OpenProjectResponse } from 'chrome-mcp-shared';\n\n// Storage key for default open target\nconst STORAGE_KEY = 'agent-open-project-default';\n\nexport interface UseOpenProjectPreferenceOptions {\n  /**\n   * Server port for API calls.\n   * Should be provided from useAgentServer.\n   */\n  getServerPort: () => number | null;\n}\n\nexport interface UseOpenProjectPreference {\n  /** Current default target (null if not set) */\n  defaultTarget: Ref<OpenProjectTarget | null>;\n  /** Loading state */\n  loading: Ref<boolean>;\n  /** Load default target from storage */\n  loadDefaultTarget: () => Promise<void>;\n  /** Save default target to storage */\n  saveDefaultTarget: (target: OpenProjectTarget) => Promise<void>;\n  /** Open project by session ID */\n  openBySession: (sessionId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;\n  /** Open project by project ID */\n  openByProject: (projectId: string, target: OpenProjectTarget) => Promise<OpenProjectResponse>;\n}\n\nexport function useOpenProjectPreference(\n  options: UseOpenProjectPreferenceOptions,\n): UseOpenProjectPreference {\n  const defaultTarget = ref<OpenProjectTarget | null>(null);\n  const loading = ref(false);\n\n  /**\n   * Load default target from chrome.storage.local.\n   */\n  async function loadDefaultTarget(): Promise<void> {\n    try {\n      const result = await chrome.storage.local.get(STORAGE_KEY);\n      const stored = result[STORAGE_KEY];\n      if (stored === 'vscode' || stored === 'terminal') {\n        defaultTarget.value = stored;\n      }\n    } catch (error) {\n      console.error('[OpenProjectPreference] Failed to load default target:', error);\n    }\n  }\n\n  /**\n   * Save default target to chrome.storage.local.\n   */\n  async function saveDefaultTarget(target: OpenProjectTarget): Promise<void> {\n    try {\n      await chrome.storage.local.set({ [STORAGE_KEY]: target });\n      defaultTarget.value = target;\n    } catch (error) {\n      console.error('[OpenProjectPreference] Failed to save default target:', error);\n    }\n  }\n\n  /**\n   * Open project directory by session ID.\n   */\n  async function openBySession(\n    sessionId: string,\n    target: OpenProjectTarget,\n  ): Promise<OpenProjectResponse> {\n    const port = options.getServerPort();\n    if (!port) {\n      return { success: false, error: 'Server not connected' };\n    }\n\n    loading.value = true;\n    try {\n      const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}/open`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ target }),\n      });\n\n      const data = (await response.json()) as OpenProjectResponse;\n      return data;\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      return { success: false, error: message };\n    } finally {\n      loading.value = false;\n    }\n  }\n\n  /**\n   * Open project directory by project ID.\n   */\n  async function openByProject(\n    projectId: string,\n    target: OpenProjectTarget,\n  ): Promise<OpenProjectResponse> {\n    const port = options.getServerPort();\n    if (!port) {\n      return { success: false, error: 'Server not connected' };\n    }\n\n    loading.value = true;\n    try {\n      const url = `http://127.0.0.1:${port}/agent/projects/${encodeURIComponent(projectId)}/open`;\n      const response = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ target }),\n      });\n\n      const data = (await response.json()) as OpenProjectResponse;\n      return data;\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      return { success: false, error: message };\n    } finally {\n      loading.value = false;\n    }\n  }\n\n  return {\n    defaultTarget,\n    loading,\n    loadDefaultTarget,\n    saveDefaultTarget,\n    openBySession,\n    openByProject,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Debugger.ts",
    "content": "/**\n * @fileoverview RR V3 Debugger Composable\n * @description Debugger state management, wraps all DebuggerCommand operations\n *\n * Responsibilities:\n * - Send all debug commands via rr_v3.debug RPC method\n * - Maintain reactive DebuggerState\n * - Provide consistent error handling and response normalization\n */\n\nimport { computed, onUnmounted, ref, type ComputedRef, type Ref } from 'vue';\n\nimport type {\n  DebuggerCommand,\n  DebuggerResponse,\n  DebuggerState,\n} from '@/entrypoints/background/record-replay-v3/domain/debug';\nimport type { NodeId, RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { JsonObject, JsonValue } from '@/entrypoints/background/record-replay-v3/domain/json';\nimport type { RunEvent } from '@/entrypoints/background/record-replay-v3/domain/events';\n\nimport { useRRV3Rpc, type UseRRV3Rpc } from './useRRV3Rpc';\n\n// ==================== Types ====================\n\n/** Composable configuration */\nexport interface UseRRV3DebuggerOptions {\n  /** Shared RPC client instance, creates new if not provided */\n  rpc?: UseRRV3Rpc;\n  /** Current runId resolver for command defaults */\n  getRunId?: () => RunId | null;\n  /** State update callback */\n  onStateChange?: (state: DebuggerState) => void;\n  /** Error callback */\n  onError?: (error: string) => void;\n  /**\n   * Auto-refresh DebuggerState when relevant events are received.\n   * Only effective when attached to a run.\n   * Events: run.paused, run.resumed, node.started\n   */\n  autoRefreshOnEvents?: boolean;\n}\n\n/** Composable return type */\nexport interface UseRRV3Debugger {\n  /** RPC client instance */\n  rpc: UseRRV3Rpc;\n\n  // State\n  state: Ref<DebuggerState | null>;\n  lastError: Ref<string | null>;\n  busy: Ref<boolean>;\n\n  // Derived state\n  currentRunId: ComputedRef<RunId | null>;\n  isAttached: ComputedRef<boolean>;\n  isPaused: ComputedRef<boolean>;\n\n  // Connection control\n  attach: (runId?: RunId) => Promise<DebuggerResponse>;\n  detach: (runId?: RunId) => Promise<DebuggerResponse>;\n\n  // Execution control\n  pause: (runId?: RunId) => Promise<DebuggerResponse>;\n  resume: (runId?: RunId) => Promise<DebuggerResponse>;\n  stepOver: (runId?: RunId) => Promise<DebuggerResponse>;\n\n  // Breakpoint management\n  setBreakpoints: (nodeIds: NodeId[], runId?: RunId) => Promise<DebuggerResponse>;\n  addBreakpoint: (nodeId: NodeId, runId?: RunId) => Promise<DebuggerResponse>;\n  removeBreakpoint: (nodeId: NodeId, runId?: RunId) => Promise<DebuggerResponse>;\n\n  // State query\n  getState: (runId?: RunId) => Promise<DebuggerResponse>;\n\n  // Variable operations\n  getVar: (name: string, runId?: RunId) => Promise<DebuggerResponse>;\n  setVar: (name: string, value: JsonValue, runId?: RunId) => Promise<DebuggerResponse>;\n}\n\n// ==================== Helpers ====================\n\nfunction toErrorMessage(error: unknown): string {\n  return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Validate breakpoint structure\n */\nfunction isValidBreakpoint(value: unknown): boolean {\n  if (typeof value !== 'object' || value === null) return false;\n  const bp = value as Record<string, unknown>;\n  return typeof bp.nodeId === 'string' && typeof bp.enabled === 'boolean';\n}\n\n/**\n * Validate DebuggerState structure\n */\nfunction isValidDebuggerState(value: unknown): value is DebuggerState {\n  if (typeof value !== 'object' || value === null) return false;\n  const obj = value as Record<string, unknown>;\n  return (\n    typeof obj.runId === 'string' &&\n    (obj.status === 'attached' || obj.status === 'detached') &&\n    (obj.execution === 'running' || obj.execution === 'paused') &&\n    Array.isArray(obj.breakpoints) &&\n    obj.breakpoints.every(isValidBreakpoint)\n  );\n}\n\n/**\n * Normalize RPC response to DebuggerResponse\n */\nfunction normalizeResponse(raw: JsonValue): DebuggerResponse {\n  if (typeof raw !== 'object' || raw === null) {\n    return { ok: false, error: 'Invalid response format' };\n  }\n\n  const obj = raw as Record<string, unknown>;\n\n  if (obj.ok === true) {\n    const responseState = obj.state;\n    // Validate state if present\n    if (responseState !== undefined && !isValidDebuggerState(responseState)) {\n      return { ok: false, error: 'Invalid DebuggerState in response' };\n    }\n    return {\n      ok: true,\n      state: responseState as DebuggerState | undefined,\n      value: obj.value as JsonValue | undefined,\n    };\n  }\n\n  if (obj.ok === false) {\n    return {\n      ok: false,\n      error: typeof obj.error === 'string' ? obj.error : 'Unknown error',\n    };\n  }\n\n  return { ok: false, error: 'Response missing ok field' };\n}\n\n// ==================== Composable ====================\n\n/** Events that trigger state refresh */\nconst STATE_REFRESH_EVENTS = new Set(['run.paused', 'run.resumed', 'node.started']);\n\n/**\n * RR V3 Debugger client\n */\nexport function useRRV3Debugger(options: UseRRV3DebuggerOptions = {}): UseRRV3Debugger {\n  // RPC client (use provided or create new)\n  const rpc = options.rpc ?? useRRV3Rpc();\n\n  // State\n  const state = ref<DebuggerState | null>(null);\n  const lastError = ref<string | null>(null);\n  const busy = ref(false);\n\n  // Derived state\n  const currentRunId = computed<RunId | null>(() => {\n    // Prefer external resolver\n    const fromGetter = options.getRunId?.();\n    if (fromGetter) return fromGetter;\n    // Fallback to current state\n    return state.value?.runId ?? null;\n  });\n\n  const isAttached = computed(() => state.value?.status === 'attached');\n  const isPaused = computed(() => state.value?.execution === 'paused');\n\n  // ==================== Internal Methods ====================\n\n  function setError(message: string | null): void {\n    lastError.value = message;\n    if (message) options.onError?.(message);\n  }\n\n  function updateState(next?: DebuggerState): void {\n    if (!next) return;\n    state.value = next;\n    options.onStateChange?.(next);\n  }\n\n  function resolveRunId(explicit?: RunId): RunId | null {\n    if (explicit) return explicit;\n    return currentRunId.value;\n  }\n\n  /**\n   * Send debug command\n   */\n  async function send(cmd: DebuggerCommand): Promise<DebuggerResponse> {\n    busy.value = true;\n    try {\n      const raw = await rpc.request('rr_v3.debug', cmd as unknown as JsonObject);\n      const response = normalizeResponse(raw);\n\n      if (response.ok) {\n        setError(null);\n        if (response.state) {\n          updateState(response.state);\n        }\n      } else {\n        setError(response.error);\n      }\n\n      return response;\n    } catch (error) {\n      const message = toErrorMessage(error);\n      setError(message);\n      return { ok: false, error: message };\n    } finally {\n      busy.value = false;\n    }\n  }\n\n  /**\n   * Create error response for missing runId\n   */\n  function missingRunIdError(commandType: string): DebuggerResponse {\n    const message = `${commandType} requires runId`;\n    setError(message);\n    return { ok: false, error: message };\n  }\n\n  // ==================== Public Methods ====================\n\n  async function attach(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.attach');\n    return send({ type: 'debug.attach', runId: resolved });\n  }\n\n  async function detach(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.detach');\n    return send({ type: 'debug.detach', runId: resolved });\n  }\n\n  async function pause(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.pause');\n    return send({ type: 'debug.pause', runId: resolved });\n  }\n\n  async function resume(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.resume');\n    return send({ type: 'debug.resume', runId: resolved });\n  }\n\n  async function stepOver(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.stepOver');\n    return send({ type: 'debug.stepOver', runId: resolved });\n  }\n\n  async function setBreakpoints(nodeIds: NodeId[], runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.setBreakpoints');\n    return send({ type: 'debug.setBreakpoints', runId: resolved, nodeIds });\n  }\n\n  async function addBreakpoint(nodeId: NodeId, runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.addBreakpoint');\n    return send({ type: 'debug.addBreakpoint', runId: resolved, nodeId });\n  }\n\n  async function removeBreakpoint(nodeId: NodeId, runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.removeBreakpoint');\n    return send({ type: 'debug.removeBreakpoint', runId: resolved, nodeId });\n  }\n\n  async function getState(runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.getState');\n    return send({ type: 'debug.getState', runId: resolved });\n  }\n\n  async function getVar(name: string, runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.getVar');\n    return send({ type: 'debug.getVar', runId: resolved, name });\n  }\n\n  async function setVar(name: string, value: JsonValue, runId?: RunId): Promise<DebuggerResponse> {\n    const resolved = resolveRunId(runId);\n    if (!resolved) return missingRunIdError('debug.setVar');\n    return send({ type: 'debug.setVar', runId: resolved, name, value });\n  }\n\n  // ==================== Event Auto-Refresh ====================\n\n  // State refresh scheduling (debounced)\n  let refreshScheduled = false;\n  let refreshTimer: ReturnType<typeof setTimeout> | null = null;\n\n  /**\n   * Schedule a debounced state refresh\n   * Uses microtask to coalesce multiple events in the same tick\n   */\n  function scheduleRefresh(): void {\n    if (refreshScheduled) return;\n    refreshScheduled = true;\n\n    // Clear any existing timer\n    if (refreshTimer) {\n      clearTimeout(refreshTimer);\n      refreshTimer = null;\n    }\n\n    // Use microtask for same-tick debouncing\n    queueMicrotask(async () => {\n      refreshScheduled = false;\n      // Don't update busy state for auto-refresh to avoid UI flicker\n      try {\n        const resolved = currentRunId.value;\n        if (!resolved || !isAttached.value) return;\n        const raw = await rpc.request('rr_v3.debug', {\n          type: 'debug.getState',\n          runId: resolved,\n        } as unknown as JsonObject);\n        const response = normalizeResponse(raw);\n        if (response.ok && response.state) {\n          updateState(response.state);\n        }\n      } catch {\n        // Ignore errors in auto-refresh\n      }\n    });\n  }\n\n  /**\n   * Handle incoming events for auto-refresh\n   */\n  function handleEvent(event: RunEvent): void {\n    // Only refresh if attached and event is for current run\n    if (!isAttached.value) return;\n    if (event.runId !== currentRunId.value) return;\n    if (!STATE_REFRESH_EVENTS.has(event.type)) return;\n\n    scheduleRefresh();\n  }\n\n  // Setup event listener if autoRefreshOnEvents is enabled\n  let unsubscribeEvents: (() => void) | null = null;\n  if (options.autoRefreshOnEvents) {\n    unsubscribeEvents = rpc.onEvent(handleEvent);\n  }\n\n  // Cleanup on unmount\n  onUnmounted(() => {\n    unsubscribeEvents?.();\n    if (refreshTimer) {\n      clearTimeout(refreshTimer);\n    }\n  });\n\n  return {\n    rpc,\n    state,\n    lastError,\n    busy,\n    currentRunId,\n    isAttached,\n    isPaused,\n    attach,\n    detach,\n    pause,\n    resume,\n    stepOver,\n    setBreakpoints,\n    addBreakpoint,\n    removeBreakpoint,\n    getState,\n    getVar,\n    setVar,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useRRV3Rpc.ts",
    "content": "/**\n * @fileoverview Re-export shared useRRV3Rpc composable\n * @description This file re-exports the shared composable for backward compatibility\n */\n\nexport {\n  useRRV3Rpc,\n  type UseRRV3Rpc,\n  type UseRRV3RpcOptions,\n  type RpcRequestOptions,\n} from '@/entrypoints/shared/composables/useRRV3Rpc';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useTextareaAutoResize.ts",
    "content": "/**\n * Composable for textarea auto-resize functionality.\n * Automatically adjusts textarea height based on content while respecting min/max constraints.\n */\nimport { ref, watch, nextTick, onMounted, onUnmounted, type Ref } from 'vue';\n\nexport interface UseTextareaAutoResizeOptions {\n  /** Ref to the textarea element */\n  textareaRef: Ref<HTMLTextAreaElement | null>;\n  /** Ref to the textarea value (for watching changes) */\n  value: Ref<string>;\n  /** Minimum height in pixels */\n  minHeight?: number;\n  /** Maximum height in pixels */\n  maxHeight?: number;\n}\n\nexport interface UseTextareaAutoResizeReturn {\n  /** Current calculated height */\n  height: Ref<number>;\n  /** Whether content exceeds max height (textarea is overflowing) */\n  isOverflowing: Ref<boolean>;\n  /** Manually trigger height recalculation */\n  recalculate: () => void;\n}\n\nconst DEFAULT_MIN_HEIGHT = 50;\nconst DEFAULT_MAX_HEIGHT = 200;\n\n/**\n * Composable for auto-resizing textarea based on content.\n *\n * Features:\n * - Automatically adjusts height on input\n * - Respects min/max height constraints\n * - Handles width changes (line wrapping affects height)\n * - Uses requestAnimationFrame for performance\n */\nexport function useTextareaAutoResize(\n  options: UseTextareaAutoResizeOptions,\n): UseTextareaAutoResizeReturn {\n  const {\n    textareaRef,\n    value,\n    minHeight = DEFAULT_MIN_HEIGHT,\n    maxHeight = DEFAULT_MAX_HEIGHT,\n  } = options;\n\n  const height = ref<number>(minHeight);\n  const isOverflowing = ref(false);\n\n  let scheduled = false;\n  let resizeObserver: ResizeObserver | null = null;\n  let lastWidth = 0;\n\n  /**\n   * Calculate textarea height based on content.\n   * Only updates the reactive `height` and `isOverflowing` refs.\n   * The actual DOM height is controlled via :style binding in the template.\n   */\n  function recalculate(): void {\n    const el = textareaRef.value;\n    if (!el) return;\n\n    // Temporarily set height to 'auto' to get accurate scrollHeight\n    // Save current height to minimize visual flicker\n    const currentHeight = el.style.height;\n    el.style.height = 'auto';\n\n    const contentHeight = el.scrollHeight;\n    const clampedHeight = Math.min(maxHeight, Math.max(minHeight, contentHeight));\n\n    // Restore height immediately (the actual height is controlled by Vue binding)\n    el.style.height = currentHeight;\n\n    // Update reactive state\n    height.value = clampedHeight;\n    // Add small tolerance (1px) to account for rounding\n    isOverflowing.value = contentHeight > maxHeight + 1;\n  }\n\n  /**\n   * Schedule height recalculation using requestAnimationFrame.\n   * Batches multiple calls within the same frame for performance.\n   */\n  function scheduleRecalculate(): void {\n    if (scheduled) return;\n    scheduled = true;\n    requestAnimationFrame(() => {\n      scheduled = false;\n      recalculate();\n    });\n  }\n\n  // Watch value changes\n  watch(\n    value,\n    async () => {\n      await nextTick();\n      scheduleRecalculate();\n    },\n    { flush: 'post' },\n  );\n\n  // Watch textarea ref changes (in case it's replaced)\n  watch(\n    textareaRef,\n    async (newEl, oldEl) => {\n      // Cleanup old observer\n      if (resizeObserver && oldEl) {\n        resizeObserver.unobserve(oldEl);\n      }\n\n      if (!newEl) return;\n\n      await nextTick();\n      scheduleRecalculate();\n\n      // Setup new observer for width changes\n      if (resizeObserver) {\n        lastWidth = newEl.offsetWidth;\n        resizeObserver.observe(newEl);\n      }\n    },\n    { immediate: true },\n  );\n\n  onMounted(() => {\n    const el = textareaRef.value;\n    if (!el) return;\n\n    // Initial calculation\n    scheduleRecalculate();\n\n    // Setup ResizeObserver for width changes\n    // Width changes affect line wrapping, which affects scrollHeight\n    if (typeof ResizeObserver !== 'undefined') {\n      lastWidth = el.offsetWidth;\n      resizeObserver = new ResizeObserver(() => {\n        const current = textareaRef.value;\n        if (!current) return;\n\n        const currentWidth = current.offsetWidth;\n        if (currentWidth !== lastWidth) {\n          lastWidth = currentWidth;\n          scheduleRecalculate();\n        }\n      });\n      resizeObserver.observe(el);\n    }\n  });\n\n  onUnmounted(() => {\n    resizeObserver?.disconnect();\n    resizeObserver = null;\n  });\n\n  return {\n    height,\n    isOverflowing,\n    recalculate,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useWebEditorTxState.ts",
    "content": "/**\n * Composable for managing Web Editor TX (Transaction) state in Sidepanel.\n *\n * Responsibilities:\n * - Listen to WEB_EDITOR_TX_CHANGED messages from background\n * - Persist and recover state from chrome.storage.session\n * - Manage excluded element keys for selective Apply\n * - Provide reactive state for AgentChat chips UI\n *\n * Architecture:\n * - The composable should be initialized ONCE at the AgentChat.vue level\n * - It is then provided via Vue's provide/inject to child components\n * - This prevents duplicate event listener registration\n */\nimport { computed, onMounted, onUnmounted, ref, type InjectionKey } from 'vue';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport type {\n  ElementChangeSummary,\n  SelectedElementSummary,\n  WebEditorElementKey,\n  WebEditorSelectionChangedPayload,\n  WebEditorTxChangedPayload,\n  WebEditorTxChangeAction,\n} from '@/common/web-editor-types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-';\nconst WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-';\nconst WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-';\n\nconst VALID_TX_ACTIONS = new Set<WebEditorTxChangeAction>([\n  'push',\n  'merge',\n  'undo',\n  'redo',\n  'clear',\n  'rollback',\n]);\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\nfunction isValidTabId(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value) && value > 0;\n}\n\nfunction buildTxSessionKey(tabId: number): string {\n  return `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`;\n}\n\nfunction buildExcludedKeysSessionKey(tabId: number): string {\n  return `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`;\n}\n\nfunction buildSelectionSessionKey(tabId: number): string {\n  return `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`;\n}\n\n/**\n * Normalize and validate selection changed payload from storage or message.\n * Returns null if the payload is invalid.\n */\nfunction normalizeSelectionPayload(raw: unknown): WebEditorSelectionChangedPayload | null {\n  if (!raw || typeof raw !== 'object') return null;\n  const obj = raw as Record<string, unknown>;\n\n  const tabId = Number(obj.tabId);\n  if (!Number.isFinite(tabId) || tabId <= 0) return null;\n\n  // Selected can be null (deselection) or an object\n  const selectedRaw = obj.selected;\n  let selected: SelectedElementSummary | null = null;\n\n  if (selectedRaw && typeof selectedRaw === 'object') {\n    const sel = selectedRaw as Record<string, unknown>;\n    const elementKey = typeof sel.elementKey === 'string' ? sel.elementKey.trim() : '';\n    if (!elementKey) return null; // Invalid selection\n\n    selected = {\n      elementKey,\n      locator: sel.locator as SelectedElementSummary['locator'],\n      label: typeof sel.label === 'string' ? sel.label : '',\n      fullLabel: typeof sel.fullLabel === 'string' ? sel.fullLabel : '',\n      tagName: typeof sel.tagName === 'string' ? sel.tagName : '',\n      updatedAt: typeof sel.updatedAt === 'number' ? sel.updatedAt : Date.now(),\n    };\n  }\n\n  return {\n    tabId,\n    selected,\n    pageUrl: typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined,\n  };\n}\n\n/**\n * Normalize and validate TX changed payload from storage or message.\n * Returns null if the payload is invalid.\n */\nfunction normalizeTxChangedPayload(raw: unknown): WebEditorTxChangedPayload | null {\n  if (!raw || typeof raw !== 'object') return null;\n  const obj = raw as Record<string, unknown>;\n\n  const tabId = Number(obj.tabId);\n  if (!Number.isFinite(tabId) || tabId <= 0) return null;\n\n  const actionRaw = typeof obj.action === 'string' ? obj.action : '';\n  if (!VALID_TX_ACTIONS.has(actionRaw as WebEditorTxChangeAction)) return null;\n  const action = actionRaw as WebEditorTxChangeAction;\n\n  // Filter elements to ensure minimal validity (elementKey must be a non-empty string)\n  const rawElements = Array.isArray(obj.elements) ? obj.elements : [];\n  const elements = rawElements.filter(\n    (e): e is ElementChangeSummary =>\n      e &&\n      typeof e === 'object' &&\n      typeof (e as any).elementKey === 'string' &&\n      (e as any).elementKey,\n  );\n\n  const undoCountRaw = Number(obj.undoCount);\n  const redoCountRaw = Number(obj.redoCount);\n  const undoCount = Number.isFinite(undoCountRaw) && undoCountRaw >= 0 ? undoCountRaw : 0;\n  const redoCount = Number.isFinite(redoCountRaw) && redoCountRaw >= 0 ? redoCountRaw : 0;\n\n  const hasApplicableChanges = Boolean(obj.hasApplicableChanges);\n  const pageUrl = typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined;\n\n  return {\n    tabId,\n    action,\n    elements,\n    undoCount,\n    redoCount,\n    hasApplicableChanges,\n    pageUrl,\n  };\n}\n\n/**\n * Normalize and deduplicate excluded keys array from storage.\n * Filters out invalid entries and removes duplicates.\n */\nfunction normalizeExcludedKeys(raw: unknown): WebEditorElementKey[] {\n  if (!Array.isArray(raw)) return [];\n\n  const result: WebEditorElementKey[] = [];\n  const seen = new Set<string>();\n\n  for (const item of raw) {\n    const key = String(item ?? '').trim();\n    if (!key || seen.has(key)) continue;\n    seen.add(key);\n    result.push(key);\n  }\n\n  return result;\n}\n\n/**\n * Persist excluded keys to session storage (per-tab).\n * Best-effort: silently ignores failures.\n */\nasync function persistExcludedKeys(\n  tabId: number,\n  keys: readonly WebEditorElementKey[],\n): Promise<void> {\n  if (!isValidTabId(tabId)) return;\n\n  try {\n    if (typeof chrome === 'undefined' || !chrome.storage?.session?.set) return;\n    const storageKey = buildExcludedKeysSessionKey(tabId);\n    await chrome.storage.session.set({ [storageKey]: [...keys] });\n  } catch (error) {\n    console.error('[useWebEditorTxState] Failed to persist excluded keys:', error);\n  }\n}\n\n/**\n * Default implementation for getting active tab ID.\n */\nasync function getActiveTabIdDefault(): Promise<number | null> {\n  try {\n    if (typeof chrome === 'undefined' || !chrome.tabs?.query) return null;\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const tabId = tabs?.[0]?.id;\n    return typeof tabId === 'number' ? tabId : null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Get current window ID for filtering tab activation events.\n * This prevents processing tab switches from other Chrome windows.\n */\nasync function getCurrentWindowId(): Promise<number | null> {\n  try {\n    if (typeof chrome === 'undefined' || !chrome.windows?.getCurrent) return null;\n    const win = await chrome.windows.getCurrent();\n    return typeof win?.id === 'number' ? win.id : null;\n  } catch {\n    return null;\n  }\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\nexport interface UseWebEditorTxStateOptions {\n  /**\n   * Optional override for resolving the \"current tab\" in sidepanel.\n   * Defaults to chrome.tabs.query({ active: true, currentWindow: true }).\n   */\n  getActiveTabId?: () => Promise<number | null>;\n  /**\n   * If provided, skips querying the active tab on mount.\n   */\n  initialTabId?: number | null;\n}\n\nexport function useWebEditorTxState(options: UseWebEditorTxStateOptions = {}) {\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  /** Current tab ID being tracked */\n  const tabId = ref<number | null>(\n    isValidTabId(options.initialTabId) ? options.initialTabId : null,\n  );\n\n  /** Current TX state from web-editor */\n  const txState = ref<WebEditorTxChangedPayload | null>(null);\n\n  /** Currently selected element (for context, may not have edits) */\n  const selectedElement = ref<SelectedElementSummary | null>(null);\n\n  /** Page URL from selection (may differ from txState.pageUrl if selection is newer) */\n  const selectionPageUrl = ref<string | null>(null);\n\n  /** Excluded element keys (user-deselected elements) */\n  const excludedKeys = ref<WebEditorElementKey[]>([]);\n\n  // ==========================================================================\n  // Computed\n  // ==========================================================================\n\n  /** All elements from TX state */\n  const allElements = computed<ElementChangeSummary[]>(() => txState.value?.elements ?? []);\n\n  /** Set of excluded keys for O(1) lookup */\n  const excludedKeySet = computed(() => new Set(excludedKeys.value));\n\n  /** Elements that will be applied (not excluded) */\n  const applicableElements = computed<ElementChangeSummary[]>(() => {\n    const set = excludedKeySet.value;\n    return allElements.value.filter((e) => !set.has(e.elementKey));\n  });\n\n  /** Elements that are excluded by user */\n  const excludedElements = computed<ElementChangeSummary[]>(() => {\n    const set = excludedKeySet.value;\n    return allElements.value.filter((e) => set.has(e.elementKey));\n  });\n\n  /** Whether there are applicable changes to send to Agent */\n  const hasChanges = computed<boolean>(() => applicableElements.value.length > 0);\n\n  /** Whether there is a selected element */\n  const hasSelection = computed<boolean>(() => selectedElement.value !== null);\n\n  /**\n   * Whether the selected element is also in the edits list.\n   * Used to decide if we need a separate \"selection-only\" chip.\n   */\n  const isSelectionInEdits = computed<boolean>(() => {\n    const sel = selectedElement.value;\n    if (!sel) return false;\n    return allElements.value.some((e) => e.elementKey === sel.elementKey);\n  });\n\n  /** Whether to show the web editor section (has edits OR has selection) */\n  const hasContent = computed<boolean>(\n    () => hasChanges.value || hasSelection.value || allElements.value.length > 0,\n  );\n\n  // ==========================================================================\n  // Actions\n  // ==========================================================================\n\n  /**\n   * Toggle an element's excluded state.\n   * Automatically persists to session storage.\n   */\n  function toggleExclude(elementKey: WebEditorElementKey): void {\n    const key = String(elementKey ?? '').trim();\n    if (!key) return;\n\n    const current = excludedKeys.value;\n    const idx = current.indexOf(key);\n    if (idx >= 0) {\n      // Remove from excluded list\n      excludedKeys.value = [...current.slice(0, idx), ...current.slice(idx + 1)];\n    } else {\n      // Add to excluded list\n      excludedKeys.value = [...current, key];\n    }\n\n    // Persist to session storage\n    if (isValidTabId(tabId.value)) {\n      void persistExcludedKeys(tabId.value, excludedKeys.value);\n    }\n  }\n\n  /**\n   * Clear all excluded elements.\n   * Automatically persists to session storage.\n   */\n  function clearExcluded(): void {\n    excludedKeys.value = [];\n\n    // Persist to session storage\n    if (isValidTabId(tabId.value)) {\n      void persistExcludedKeys(tabId.value, excludedKeys.value);\n    }\n  }\n\n  /**\n   * Remove excluded keys that no longer exist in the current TX state.\n   * This prevents stale keys when elements are undone/cleared.\n   */\n  function pruneStaleExcludedKeys(elements: readonly ElementChangeSummary[] | null): void {\n    if (!elements || !isValidTabId(tabId.value)) return;\n\n    const validKeys = new Set(elements.map((e) => e.elementKey));\n    const prunedKeys = excludedKeys.value.filter((k) => validKeys.has(k));\n\n    // Only update if there are stale keys to remove\n    if (prunedKeys.length === excludedKeys.value.length) return;\n\n    excludedKeys.value = prunedKeys;\n    void persistExcludedKeys(tabId.value, prunedKeys);\n  }\n\n  /** Sequence counter to prevent stale async updates */\n  let refreshSeq = 0;\n\n  /**\n   * Refresh TX state from session storage for a specific tab.\n   * Also restores excluded keys from storage.\n   * On tab change, immediately clears state to prevent cross-tab pollution.\n   */\n  async function refreshFromStorage(targetTabId: number): Promise<void> {\n    if (!isValidTabId(targetTabId)) {\n      tabId.value = null;\n      txState.value = null;\n      excludedKeys.value = [];\n      selectedElement.value = null;\n      selectionPageUrl.value = null;\n      return;\n    }\n\n    // On tab change, immediately clear state to prevent UI showing stale data\n    const isTabChange = tabId.value !== targetTabId;\n    if (isTabChange) {\n      txState.value = null;\n      excludedKeys.value = [];\n      selectedElement.value = null;\n      selectionPageUrl.value = null;\n    }\n    tabId.value = targetTabId;\n\n    const seq = ++refreshSeq;\n    const txKey = buildTxSessionKey(targetTabId);\n    const excludedKey = buildExcludedKeysSessionKey(targetTabId);\n    const selectionKey = buildSelectionSessionKey(targetTabId);\n\n    try {\n      if (typeof chrome === 'undefined' || !chrome.storage?.session?.get) {\n        txState.value = null;\n        excludedKeys.value = [];\n        selectedElement.value = null;\n        selectionPageUrl.value = null;\n        return;\n      }\n\n      // Fetch TX state, excluded keys, and selection in one call\n      const result = (await chrome.storage.session.get([\n        txKey,\n        excludedKey,\n        selectionKey,\n      ])) as Record<string, unknown>;\n\n      // Check for stale async response\n      if (seq !== refreshSeq) return;\n\n      // Update TX state\n      const nextTxState = normalizeTxChangedPayload(result?.[txKey]);\n      txState.value = nextTxState;\n\n      // Restore excluded keys from storage\n      excludedKeys.value = normalizeExcludedKeys(result?.[excludedKey]);\n\n      // Restore selection from storage\n      const nextSelection = normalizeSelectionPayload(result?.[selectionKey]);\n      selectedElement.value = nextSelection?.selected ?? null;\n      selectionPageUrl.value = nextSelection?.pageUrl ?? null;\n\n      // Prune stale excluded keys based on current elements\n      pruneStaleExcludedKeys(nextTxState?.elements ?? null);\n    } catch (error) {\n      console.error('[useWebEditorTxState] Failed to refresh from session storage:', error);\n      // On error, ensure clean state to prevent showing stale data\n      txState.value = null;\n      excludedKeys.value = [];\n      selectedElement.value = null;\n      selectionPageUrl.value = null;\n    }\n  }\n\n  // ==========================================================================\n  // Message Listeners\n  // ==========================================================================\n\n  /**\n   * Handle runtime messages from background.\n   */\n  const onRuntimeMessage = (\n    message: unknown,\n    _sender: chrome.runtime.MessageSender,\n    _sendResponse: (response?: unknown) => void,\n  ): void => {\n    const msg =\n      message && typeof message === 'object' ? (message as Record<string, unknown>) : null;\n    if (!msg) return;\n\n    // Handle TX changed messages\n    if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) {\n      const next = normalizeTxChangedPayload(msg.payload);\n      if (!next) return;\n\n      // Only process messages for the current tab\n      if (!isValidTabId(tabId.value)) return;\n      if (next.tabId !== tabId.value) return;\n\n      txState.value = next;\n\n      // Prune excluded keys that no longer exist (e.g., after undo/clear)\n      pruneStaleExcludedKeys(next.elements);\n      return;\n    }\n\n    // Handle selection changed messages\n    if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) {\n      const next = normalizeSelectionPayload(msg.payload);\n      if (!next) return;\n\n      // Only process messages for the current tab\n      if (!isValidTabId(tabId.value)) return;\n      if (next.tabId !== tabId.value) return;\n\n      selectedElement.value = next.selected;\n      // Store pageUrl from selection for context building\n      selectionPageUrl.value = next.pageUrl ?? null;\n      return;\n    }\n  };\n\n  /**\n   * Handle session storage changes (fallback for cold start).\n   * Only handles TX state changes; excluded keys are managed explicitly.\n   */\n  const onSessionChanged = (changes: { [key: string]: chrome.storage.StorageChange }): void => {\n    if (!isValidTabId(tabId.value)) return;\n    const txKey = buildTxSessionKey(tabId.value);\n\n    const change = changes?.[txKey];\n    if (!change) return;\n\n    if (change.newValue === undefined) {\n      txState.value = null;\n      // Clear excluded keys when TX state is cleared\n      pruneStaleExcludedKeys([]);\n      return;\n    }\n\n    const next = normalizeTxChangedPayload(change.newValue);\n    txState.value = next;\n\n    // Prune stale excluded keys\n    pruneStaleExcludedKeys(next?.elements ?? []);\n  };\n\n  /** Cleanup function for storage listener */\n  let removeStorageListener: (() => void) | null = null;\n\n  /** Cleanup function for tab activated listener */\n  let removeTabActivatedListener: (() => void) | null = null;\n\n  /** Cached window ID to filter tab activation events from other windows */\n  let currentWindowId: number | null = null;\n\n  /**\n   * Handle tab activation events.\n   * Updates tabId and loads TX state when user switches to a different tab.\n   *\n   * Note: currentWindowId filtering is best-effort. If getCurrentWindowId() fails,\n   * events from all windows will be processed (acceptable fallback behavior).\n   */\n  const onTabActivated = (activeInfo: chrome.tabs.TabActiveInfo): void => {\n    try {\n      // Ignore events from other windows (best-effort filter)\n      if (currentWindowId !== null && activeInfo.windowId !== currentWindowId) return;\n\n      const nextTabId = activeInfo.tabId;\n      if (!isValidTabId(nextTabId)) return;\n\n      // Skip if already tracking this tab\n      if (nextTabId === tabId.value) return;\n\n      // Load TX state for the newly activated tab\n      void refreshFromStorage(nextTabId);\n    } catch (error) {\n      console.error('[useWebEditorTxState] Failed to handle tab activation:', error);\n    }\n  };\n\n  // ==========================================================================\n  // Lifecycle\n  // ==========================================================================\n\n  onMounted(async () => {\n    // Register runtime message listener\n    try {\n      if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.addListener) {\n        chrome.runtime.onMessage.addListener(onRuntimeMessage);\n      }\n    } catch (error) {\n      console.error('Failed to register WebEditor TX runtime listener:', error);\n    }\n\n    // Register session storage listener\n    try {\n      if (typeof chrome !== 'undefined' && chrome.storage?.session?.onChanged?.addListener) {\n        // Prefer session-specific listener if available\n        chrome.storage.session.onChanged.addListener(onSessionChanged);\n        removeStorageListener = () => {\n          try {\n            chrome.storage.session.onChanged.removeListener(onSessionChanged);\n          } catch {}\n        };\n      } else if (typeof chrome !== 'undefined' && chrome.storage?.onChanged?.addListener) {\n        // Fallback to generic storage listener with area filter\n        const onChanged = (\n          changes: { [key: string]: chrome.storage.StorageChange },\n          areaName: chrome.storage.AreaName,\n        ) => {\n          if (areaName !== 'session') return;\n          onSessionChanged(changes);\n        };\n\n        chrome.storage.onChanged.addListener(onChanged);\n        removeStorageListener = () => {\n          try {\n            chrome.storage.onChanged.removeListener(onChanged);\n          } catch {}\n        };\n      }\n    } catch (error) {\n      console.error('Failed to register WebEditor TX storage listener:', error);\n    }\n\n    // Cache current window ID for filtering tab activation events\n    currentWindowId = await getCurrentWindowId();\n\n    // Register tab activation listener to track tab switches\n    try {\n      if (typeof chrome !== 'undefined' && chrome.tabs?.onActivated?.addListener) {\n        chrome.tabs.onActivated.addListener(onTabActivated);\n        removeTabActivatedListener = () => {\n          try {\n            chrome.tabs.onActivated.removeListener(onTabActivated);\n          } catch {}\n        };\n      }\n    } catch (error) {\n      console.error('[useWebEditorTxState] Failed to register tab activation listener:', error);\n    }\n\n    // Initialize tab ID if not provided\n    const getActiveTabId = options.getActiveTabId ?? getActiveTabIdDefault;\n\n    if (!isValidTabId(tabId.value)) {\n      const active = await getActiveTabId().catch(() => null);\n      if (isValidTabId(active)) {\n        tabId.value = active;\n      }\n    }\n\n    // Load initial state from storage\n    if (isValidTabId(tabId.value)) {\n      await refreshFromStorage(tabId.value);\n    }\n  });\n\n  onUnmounted(() => {\n    // Clean up runtime message listener\n    try {\n      if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.removeListener) {\n        chrome.runtime.onMessage.removeListener(onRuntimeMessage);\n      }\n    } catch {}\n\n    // Clean up storage listener\n    removeStorageListener?.();\n    removeStorageListener = null;\n\n    // Clean up tab activation listener\n    removeTabActivatedListener?.();\n    removeTabActivatedListener = null;\n  });\n\n  // ==========================================================================\n  // Return\n  // ==========================================================================\n\n  return {\n    // State\n    tabId,\n    txState,\n    excludedKeys,\n    selectedElement,\n    selectionPageUrl,\n\n    // UI State (computed)\n    allElements,\n    hasChanges,\n    hasSelection,\n    isSelectionInEdits,\n    hasContent,\n    applicableElements,\n    excludedElements,\n\n    // Actions\n    toggleExclude,\n    clearExcluded,\n    refreshFromStorage,\n  };\n}\n\n// =============================================================================\n// Type Exports & Injection Key\n// =============================================================================\n\n/**\n * Return type of useWebEditorTxState composable.\n * Used for type-safe provide/inject.\n */\nexport type WebEditorTxStateReturn = ReturnType<typeof useWebEditorTxState>;\n\n/**\n * Injection key for providing WebEditorTxState to child components.\n * Use this with Vue's provide/inject pattern to avoid duplicate listener registration.\n *\n * @example\n * // In AgentChat.vue (parent)\n * const webEditorTx = useWebEditorTxState();\n * provide(WEB_EDITOR_TX_STATE_INJECTION_KEY, webEditorTx);\n *\n * // In WebEditorChanges.vue (child)\n * const tx = inject(WEB_EDITOR_TX_STATE_INJECTION_KEY);\n */\nexport const WEB_EDITOR_TX_STATE_INJECTION_KEY: InjectionKey<WebEditorTxStateReturn> =\n  Symbol('web-editor-tx-state');\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/composables/useWorkflowsV3.ts",
    "content": "/**\n * @fileoverview V3 Workflows Data Layer Composable\n * @description Provides V3 workflows data management for Sidepanel UI\n *\n * This composable wraps the V3 RPC client and provides:\n * - Flow listing, running, and deletion\n * - Run listing and event subscription\n * - Trigger management\n * - Data mapping from V3 types to UI types\n */\n\nimport { onMounted, onUnmounted, ref, type Ref } from 'vue';\n\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { FlowId, RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport { useRRV3Rpc } from './useRRV3Rpc';\n\n// ==================== UI Types ====================\n\n/** Flow type for UI display (compatible with existing WorkflowsView) */\nexport interface FlowLite {\n  id: string;\n  name: string;\n  description?: string;\n  meta?: {\n    domain?: string;\n    tags?: string[];\n    bindings?: Array<{\n      kind?: string; // V3 uses 'kind'\n      type?: string; // V2 uses 'type'\n      value: string;\n    }>;\n  };\n}\n\n/** Run type for UI display (compatible with existing WorkflowsView) */\nexport interface RunLite {\n  id: string;\n  flowId: string;\n  startedAt: string;\n  finishedAt?: string;\n  /**\n   * Terminal success status: true=succeeded, false=failed/canceled, undefined=in progress\n   * UI should check `isInProgress` first to distinguish in-progress from failed\n   */\n  success?: boolean;\n  /** Whether the run is still in progress (queued/running/paused) */\n  isInProgress: boolean;\n  status: RunRecordV3['status'];\n  entries: unknown[];\n}\n\n/** Trigger type for UI display */\nexport interface TriggerLite {\n  id: string;\n  type: string; // UI uses 'type', V3 uses 'kind'\n  kind: string; // V3 uses 'kind'\n  flowId: string;\n  enabled?: boolean;\n  match?: Array<{ kind: string; value: string }>; // For URL triggers\n  [key: string]: unknown;\n}\n\n// ==================== Mappers ====================\n\n/** Convert V3 FlowV3 to UI FlowLite */\nfunction mapFlowV3ToLite(flow: FlowV3): FlowLite {\n  return {\n    id: flow.id,\n    name: flow.name,\n    description: flow.description,\n    meta: {\n      tags: flow.meta?.tags,\n      bindings: flow.meta?.bindings?.map((b) => ({\n        kind: b.kind,\n        type: b.kind, // For V2 compatibility\n        value: b.value,\n      })),\n    },\n  };\n}\n\n/** Convert V3 RunRecordV3 to UI RunLite */\nfunction mapRunV3ToLite(run: RunRecordV3): RunLite {\n  // Determine if run is in progress\n  const inProgressStatuses = ['queued', 'running', 'paused'];\n  const isInProgress = inProgressStatuses.includes(run.status);\n\n  // Map V3 status to success boolean for terminal states only\n  let success: boolean | undefined;\n  if (run.status === 'succeeded') success = true;\n  else if (run.status === 'failed' || run.status === 'canceled') success = false;\n  // For in-progress states, success remains undefined\n\n  return {\n    id: run.id,\n    flowId: run.flowId,\n    startedAt: run.startedAt\n      ? new Date(run.startedAt).toISOString()\n      : new Date(run.createdAt).toISOString(),\n    finishedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,\n    success,\n    isInProgress,\n    status: run.status,\n    entries: [], // V3 doesn't have entries in RunRecord, use getEvents for details\n  };\n}\n\n/** Convert V3 TriggerSpec to UI TriggerLite */\nfunction mapTriggerV3ToLite(trigger: TriggerSpec): TriggerLite {\n  return {\n    ...trigger,\n    type: trigger.kind, // Map 'kind' to 'type' for UI compatibility\n    kind: trigger.kind,\n  } as TriggerLite;\n}\n\n// ==================== Composable ====================\n\nexport interface UseWorkflowsV3Options {\n  /** Auto-refresh interval in ms (0 = disabled) */\n  autoRefreshMs?: number;\n  /** Auto-connect on mount */\n  autoConnect?: boolean;\n}\n\nexport interface UseWorkflowsV3Return {\n  // Connection state\n  connected: Ref<boolean>;\n  loading: Ref<boolean>;\n  error: Ref<string | null>;\n\n  // Data\n  flows: Ref<FlowLite[]>;\n  runs: Ref<RunLite[]>;\n  triggers: Ref<TriggerLite[]>;\n\n  // Actions\n  refresh: () => Promise<void>;\n  refreshFlows: () => Promise<void>;\n  refreshRuns: () => Promise<void>;\n  refreshTriggers: () => Promise<void>;\n  runFlow: (flowId: string) => Promise<{ runId: string } | null>;\n  deleteFlow: (flowId: string) => Promise<boolean>;\n  exportFlow: (flowId: string) => Promise<FlowV3 | null>;\n  deleteTrigger: (triggerId: string) => Promise<boolean>;\n\n  // V3-specific\n  getFlowById: (flowId: string) => Promise<FlowV3 | null>;\n  getRunEvents: (runId: string) => Promise<unknown[]>;\n}\n\n/**\n * V3 Workflows data layer composable\n */\nexport function useWorkflowsV3(options: UseWorkflowsV3Options = {}): UseWorkflowsV3Return {\n  const { autoRefreshMs = 0, autoConnect = true } = options;\n\n  // RPC client\n  const rpc = useRRV3Rpc({ autoConnect });\n\n  // State\n  const loading = ref(false);\n  const error = ref<string | null>(null);\n  const flows = ref<FlowLite[]>([]);\n  const runs = ref<RunLite[]>([]);\n  const triggers = ref<TriggerLite[]>([]);\n\n  // Auto-refresh timer\n  let refreshTimer: ReturnType<typeof setInterval> | null = null;\n  // Event subscription cleanup function\n  let eventUnsubscribe: (() => void) | null = null;\n\n  // ==================== Actions ====================\n\n  async function refreshFlows(): Promise<void> {\n    try {\n      const result = (await rpc.request('rr_v3.listFlows')) as FlowV3[] | null;\n      flows.value = (result || []).map(mapFlowV3ToLite);\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to refresh flows:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n    }\n  }\n\n  async function refreshRuns(): Promise<void> {\n    try {\n      const result = (await rpc.request('rr_v3.listRuns')) as RunRecordV3[] | null;\n      // Sort by createdAt descending (newest first)\n      const sorted = (result || []).slice().sort((a, b) => b.createdAt - a.createdAt);\n      runs.value = sorted.map(mapRunV3ToLite);\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to refresh runs:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n    }\n  }\n\n  async function refreshTriggers(): Promise<void> {\n    try {\n      const result = (await rpc.request('rr_v3.listTriggers')) as TriggerSpec[] | null;\n      triggers.value = (result || []).map(mapTriggerV3ToLite);\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to refresh triggers:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n    }\n  }\n\n  async function refresh(): Promise<void> {\n    loading.value = true;\n    error.value = null;\n    try {\n      await Promise.all([refreshFlows(), refreshRuns(), refreshTriggers()]);\n    } finally {\n      loading.value = false;\n    }\n  }\n\n  async function runFlow(flowId: string): Promise<{ runId: string } | null> {\n    try {\n      const result = (await rpc.request('rr_v3.enqueueRun', {\n        flowId: flowId as FlowId,\n      })) as { runId: RunId; position: number } | null;\n      // Refresh runs to show the new run\n      void refreshRuns();\n      return result ? { runId: result.runId } : null;\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to run flow:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n      return null;\n    }\n  }\n\n  async function deleteFlow(flowId: string): Promise<boolean> {\n    try {\n      await rpc.request('rr_v3.deleteFlow', { flowId: flowId as FlowId });\n      // Refresh flows after deletion\n      void refreshFlows();\n      return true;\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to delete flow:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n      return false;\n    }\n  }\n\n  async function exportFlow(flowId: string): Promise<FlowV3 | null> {\n    try {\n      const result = (await rpc.request('rr_v3.getFlow', {\n        flowId: flowId as FlowId,\n      })) as FlowV3 | null;\n      return result;\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to export flow:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n      return null;\n    }\n  }\n\n  async function deleteTrigger(triggerId: string): Promise<boolean> {\n    try {\n      await rpc.request('rr_v3.deleteTrigger', { triggerId });\n      // Refresh triggers after deletion\n      void refreshTriggers();\n      return true;\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to delete trigger:', e);\n      error.value = e instanceof Error ? e.message : String(e);\n      return false;\n    }\n  }\n\n  async function getFlowById(flowId: string): Promise<FlowV3 | null> {\n    try {\n      return (await rpc.request('rr_v3.getFlow', {\n        flowId: flowId as FlowId,\n      })) as FlowV3 | null;\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to get flow:', e);\n      return null;\n    }\n  }\n\n  async function getRunEvents(runId: string): Promise<unknown[]> {\n    try {\n      return (await rpc.request('rr_v3.getEvents', {\n        runId: runId as RunId,\n      })) as unknown[];\n    } catch (e) {\n      console.warn('[useWorkflowsV3] Failed to get run events:', e);\n      return [];\n    }\n  }\n\n  // ==================== Lifecycle ====================\n\n  onMounted(async () => {\n    if (autoConnect) {\n      await rpc.ensureConnected();\n      await refresh();\n    }\n\n    // Setup auto-refresh\n    if (autoRefreshMs > 0) {\n      refreshTimer = setInterval(() => {\n        void refresh();\n      }, autoRefreshMs);\n    }\n\n    // Subscribe to all run events for real-time updates\n    void rpc.subscribe(null);\n    eventUnsubscribe = rpc.onEvent((event) => {\n      // Refresh runs when run status changes\n      const runStatusEvents = [\n        'run.queued',\n        'run.started',\n        'run.succeeded',\n        'run.failed',\n        'run.canceled',\n        'run.paused',\n        'run.resumed',\n        'run.recovered',\n      ];\n      if (runStatusEvents.includes(event.type)) {\n        void refreshRuns();\n      }\n    });\n  });\n\n  onUnmounted(() => {\n    // Cleanup auto-refresh timer\n    if (refreshTimer) {\n      clearInterval(refreshTimer);\n      refreshTimer = null;\n    }\n    // Cleanup event subscription\n    if (eventUnsubscribe) {\n      eventUnsubscribe();\n      eventUnsubscribe = null;\n    }\n    // Unsubscribe from run events\n    void rpc.unsubscribe(null);\n  });\n\n  return {\n    connected: rpc.connected,\n    loading,\n    error,\n    flows,\n    runs,\n    triggers,\n    refresh,\n    refreshFlows,\n    refreshRuns,\n    refreshTriggers,\n    runFlow,\n    deleteFlow,\n    exportFlow,\n    deleteTrigger,\n    getFlowById,\n    getRunEvents,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>工作流管理</title>\n    <meta name=\"manifest.type\" content=\"side_panel\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/main.ts",
    "content": "import { createApp } from 'vue';\nimport { NativeMessageType } from 'chrome-mcp-shared';\nimport App from './App.vue';\n\n// Tailwind first, then custom tokens\nimport '../styles/tailwind.css';\n// AgentChat theme tokens\nimport './styles/agent-chat.css';\n\nimport { preloadAgentTheme } from './composables';\n\n/**\n * Initialize and mount the Vue app.\n * Preloads theme before mounting to prevent flash.\n */\nasync function init(): Promise<void> {\n  // Preload theme from storage and apply to document\n  // This happens before Vue mounts, preventing theme flash\n  await preloadAgentTheme();\n\n  // Trigger ensure native connection (fire-and-forget, don't block UI mounting)\n  void chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => {\n    // Silent failure - background will handle reconnection\n  });\n\n  // Mount Vue app\n  createApp(App).mount('#app');\n}\n\ninit();\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css",
    "content": "/**\n * AgentChat Theme System\n *\n * This file defines the CSS variable tokens for the AgentChat component.\n * All components must only use these tokens - never hardcode colors.\n *\n * Themes:\n * - warm-editorial (default): Warm, editorial style from agent-ux.html\n * - blueprint-architect: Blueprint grid with technical aesthetic\n * - zen-journal: Calm journal / graphite accent (Muji style)\n * - neo-pop: Thick borders + hard shadow (Brutalist)\n * - dark-console: Dark terminal/console style\n * - swiss-grid: High-contrast brutalist/swiss style\n */\n\n@layer base {\n  .agent-theme {\n    /* ========================================\n       Font Stacks (system font fallbacks)\n       ======================================== */\n    --ac-font-sans:\n      'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,\n      'Apple Color Emoji', 'Segoe UI Emoji';\n    --ac-font-serif: 'Newsreader', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;\n    --ac-font-mono:\n      'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',\n      'Courier New', monospace;\n    --ac-font-grotesk:\n      'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial;\n\n    /* Semantic font tokens */\n    --ac-font-body: var(--ac-font-sans);\n    --ac-font-heading: var(--ac-font-serif);\n    --ac-font-code: var(--ac-font-mono);\n\n    /* ========================================\n       Geometry (Shape & Spacing)\n       ======================================== */\n    --ac-border-width: 1px;\n    --ac-border-width-strong: 2px;\n    --ac-radius-container: 0px;\n    --ac-radius-card: 12px;\n    --ac-radius-inner: 8px;\n    --ac-radius-button: 8px;\n\n    /* Motion */\n    --ac-motion-fast: 120ms;\n    --ac-motion-normal: 180ms;\n\n    /* Timeline sizing */\n    --ac-timeline-line-width: 1px;\n    --ac-timeline-node-size: 8px;\n    --ac-timeline-indent: 24px;\n\n    /* Scrollbar sizing */\n    --ac-scrollbar-size: 4px;\n\n    /* ========================================\n       WARM EDITORIAL (Default Theme)\n       ======================================== */\n\n    /* Background */\n    --ac-bg: #fdfcf8;\n    --ac-bg-pattern: none;\n    --ac-bg-pattern-size: 16px 16px;\n\n    /* Header */\n    --ac-header-bg: rgba(253, 252, 248, 0.95);\n    --ac-header-border: rgba(245, 245, 244, 0.5);\n\n    /* Surfaces */\n    --ac-surface: #ffffff;\n    --ac-surface-muted: #f2f0eb;\n    --ac-surface-inset: #f2f0eb;\n\n    /* Text */\n    --ac-text: #1a1a1a;\n    --ac-text-muted: #6e6e6e;\n    --ac-text-subtle: #a8a29e;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #a8a29e;\n\n    /* Borders */\n    --ac-border: #e7e5e4;\n    --ac-border-strong: #d6d3d1;\n\n    /* Hover states */\n    --ac-hover-bg: #f5f5f4;\n    --ac-hover-bg-subtle: #fafaf9;\n\n    /* Accent (terracotta) */\n    --ac-accent: #d97757;\n    --ac-accent-hover: #c4664a;\n    --ac-accent-subtle: rgba(217, 119, 87, 0.12);\n    --ac-accent-contrast: #ffffff;\n    --ac-accent-2: var(--ac-accent);\n\n    /* Links */\n    --ac-link: var(--ac-accent);\n    --ac-link-hover: var(--ac-accent-hover);\n\n    /* Selection */\n    --ac-selection-bg: #ffedd5;\n    --ac-selection-text: #7c2d12;\n\n    /* Shadows */\n    --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);\n    --ac-shadow-float: 0 4px 20px -2px rgba(0, 0, 0, 0.05);\n\n    /* Focus */\n    --ac-focus-ring: rgba(214, 211, 209, 0.9);\n\n    /* Timeline */\n    --ac-timeline-line: #e7e5e4;\n    --ac-timeline-node: #d6d3d1;\n    --ac-timeline-node-hover: #a8a29e;\n    --ac-timeline-node-active: var(--ac-accent);\n    --ac-timeline-node-active-border: var(--ac-accent);\n    --ac-timeline-node-tool: #94a3b8;\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(217, 119, 87, 0.25), 0 0 12px rgba(217, 119, 87, 0.2);\n\n    /* Chips/Pills */\n    --ac-chip-bg: #f2f0eb;\n    --ac-chip-text: #1a1a1a;\n    --ac-chip-border: #e7e5e4;\n\n    /* Code blocks */\n    --ac-code-bg: #ffffff;\n    --ac-code-text: #1a1a1a;\n    --ac-code-border: #e7e5e4;\n\n    /* Diff colors */\n    --ac-diff-add-bg: rgba(240, 253, 244, 0.6);\n    --ac-diff-add-text: #15803d;\n    --ac-diff-add-border: #4ade80;\n    --ac-diff-del-bg: rgba(254, 242, 242, 0.6);\n    --ac-diff-del-text: #b91c1c;\n    --ac-diff-del-border: #fca5a5;\n\n    /* Status colors */\n    --ac-success: #22c55e;\n    --ac-warning: #f59e0b;\n    --ac-danger: #ef4444;\n\n    /* Scrollbar */\n    --ac-scrollbar-thumb: #e5e5e5;\n    --ac-scrollbar-thumb-hover: #d4d4d4;\n  }\n\n  /* ========================================\n     WARM EDITORIAL (Explicit for clarity)\n     ======================================== */\n  .agent-theme[data-agent-theme='warm-editorial'] {\n    --ac-font-body: var(--ac-font-sans);\n    --ac-font-heading: var(--ac-font-serif);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-bg: #fdfcf8;\n    --ac-bg-pattern: none;\n    --ac-header-bg: rgba(253, 252, 248, 0.95);\n    --ac-header-border: rgba(245, 245, 244, 0.5);\n    --ac-surface: #ffffff;\n    --ac-surface-muted: #f2f0eb;\n    --ac-text: #1a1a1a;\n    --ac-text-muted: #6e6e6e;\n    --ac-text-subtle: #a8a29e;\n    --ac-border: #e7e5e4;\n    --ac-accent: #d97757;\n    --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);\n    --ac-radius-card: 12px;\n    --ac-border-width: 1px;\n  }\n\n  /* ========================================\n     BLUEPRINT ARCHITECT\n     ======================================== */\n  .agent-theme[data-agent-theme='blueprint-architect'] {\n    --ac-font-body: var(--ac-font-grotesk);\n    --ac-font-heading: var(--ac-font-grotesk);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-bg: #f7fbff;\n    --ac-bg-pattern:\n      linear-gradient(to right, rgba(37, 99, 235, 0.14) 1px, transparent 1px),\n      linear-gradient(to bottom, rgba(37, 99, 235, 0.14) 1px, transparent 1px);\n    --ac-bg-pattern-size: 24px 24px;\n\n    --ac-header-bg: rgba(247, 251, 255, 0.86);\n    --ac-header-border: rgba(37, 99, 235, 0.25);\n\n    --ac-surface: rgba(255, 255, 255, 0.92);\n    --ac-surface-muted: rgba(239, 246, 255, 0.9);\n    --ac-surface-inset: rgba(239, 246, 255, 0.9);\n\n    --ac-text: #0b1220;\n    --ac-text-muted: #1f2a44;\n    --ac-text-subtle: #475569;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #64748b;\n\n    --ac-border: rgba(37, 99, 235, 0.25);\n    --ac-border-strong: rgba(37, 99, 235, 0.45);\n\n    --ac-hover-bg: rgba(37, 99, 235, 0.08);\n    --ac-hover-bg-subtle: rgba(37, 99, 235, 0.05);\n\n    --ac-accent: #2563eb;\n    --ac-accent-hover: #1d4ed8;\n    --ac-accent-subtle: rgba(37, 99, 235, 0.12);\n    --ac-accent-contrast: #ffffff;\n\n    --ac-accent-2: #0ea5e9;\n    --ac-link: var(--ac-accent);\n    --ac-link-hover: var(--ac-accent-hover);\n\n    --ac-selection-bg: rgba(37, 99, 235, 0.16);\n    --ac-selection-text: #0b1220;\n\n    --ac-shadow-card: 0 1px 3px rgba(2, 6, 23, 0.12);\n    --ac-shadow-float: 0 10px 28px -10px rgba(2, 6, 23, 0.22);\n    --ac-focus-ring: rgba(37, 99, 235, 0.4);\n\n    --ac-timeline-line: rgba(37, 99, 235, 0.35);\n    --ac-timeline-node: rgba(37, 99, 235, 0.35);\n    --ac-timeline-node-hover: rgba(37, 99, 235, 0.55);\n    --ac-timeline-node-active: var(--ac-accent);\n    --ac-timeline-node-active-border: var(--ac-accent);\n    --ac-timeline-node-tool: rgba(37, 99, 235, 0.5);\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(37, 99, 235, 0.25), 0 0 12px rgba(37, 99, 235, 0.2);\n\n    --ac-chip-bg: rgba(239, 246, 255, 0.9);\n    --ac-chip-text: #0b1220;\n    --ac-chip-border: rgba(37, 99, 235, 0.25);\n\n    --ac-code-bg: rgba(255, 255, 255, 0.92);\n    --ac-code-text: #0b1220;\n    --ac-code-border: rgba(37, 99, 235, 0.25);\n\n    --ac-diff-add-bg: rgba(34, 197, 94, 0.12);\n    --ac-diff-add-text: #15803d;\n    --ac-diff-add-border: rgba(34, 197, 94, 0.4);\n    --ac-diff-del-bg: rgba(239, 68, 68, 0.12);\n    --ac-diff-del-text: #b91c1c;\n    --ac-diff-del-border: rgba(239, 68, 68, 0.4);\n\n    --ac-scrollbar-thumb: rgba(37, 99, 235, 0.2);\n    --ac-scrollbar-thumb-hover: rgba(37, 99, 235, 0.35);\n  }\n\n  /* ========================================\n     ZEN JOURNAL\n     ======================================== */\n  .agent-theme[data-agent-theme='zen-journal'] {\n    --ac-font-body: var(--ac-font-serif);\n    --ac-font-heading: var(--ac-font-serif);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-bg: #fafaf9;\n    --ac-bg-pattern: linear-gradient(to bottom, rgba(120, 113, 108, 0.07) 1px, transparent 1px);\n    --ac-bg-pattern-size: 100% 28px;\n\n    --ac-header-bg: rgba(250, 250, 249, 0.92);\n    --ac-header-border: rgba(231, 229, 228, 0.9);\n\n    --ac-surface: rgba(255, 255, 255, 0.92);\n    --ac-surface-muted: rgba(245, 245, 244, 0.92);\n    --ac-surface-inset: rgba(245, 245, 244, 0.92);\n\n    --ac-text: #1c1917;\n    --ac-text-muted: #44403c;\n    --ac-text-subtle: #78716c;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #a8a29e;\n\n    --ac-border: #e7e5e4;\n    --ac-border-strong: #d6d3d1;\n\n    --ac-hover-bg: rgba(120, 113, 108, 0.08);\n    --ac-hover-bg-subtle: rgba(120, 113, 108, 0.05);\n\n    --ac-accent: #57534e;\n    --ac-accent-hover: #44403c;\n    --ac-accent-subtle: rgba(87, 83, 78, 0.12);\n    --ac-accent-contrast: #ffffff;\n    --ac-accent-2: var(--ac-accent);\n\n    --ac-link: var(--ac-accent);\n    --ac-link-hover: var(--ac-accent-hover);\n\n    --ac-selection-bg: rgba(87, 83, 78, 0.18);\n    --ac-selection-text: #1c1917;\n\n    --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);\n    --ac-shadow-float: 0 14px 34px -18px rgba(0, 0, 0, 0.18);\n    --ac-focus-ring: rgba(87, 83, 78, 0.35);\n\n    --ac-timeline-line: #e7e5e4;\n    --ac-timeline-node: #d6d3d1;\n    --ac-timeline-node-hover: #a8a29e;\n    --ac-timeline-node-active: var(--ac-accent);\n    --ac-timeline-node-active-border: var(--ac-accent);\n    --ac-timeline-node-tool: #94a3b8;\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(87, 83, 78, 0.25), 0 0 12px rgba(87, 83, 78, 0.2);\n\n    --ac-chip-bg: rgba(245, 245, 244, 0.92);\n    --ac-chip-text: #1c1917;\n    --ac-chip-border: #e7e5e4;\n\n    --ac-code-bg: rgba(255, 255, 255, 0.92);\n    --ac-code-text: #1c1917;\n    --ac-code-border: #e7e5e4;\n\n    --ac-diff-add-bg: rgba(34, 197, 94, 0.1);\n    --ac-diff-add-text: #15803d;\n    --ac-diff-add-border: rgba(34, 197, 94, 0.35);\n    --ac-diff-del-bg: rgba(239, 68, 68, 0.1);\n    --ac-diff-del-text: #b91c1c;\n    --ac-diff-del-border: rgba(239, 68, 68, 0.35);\n\n    --ac-scrollbar-thumb: rgba(120, 113, 108, 0.15);\n    --ac-scrollbar-thumb-hover: rgba(120, 113, 108, 0.25);\n  }\n\n  /* ========================================\n     NEO POP\n     ======================================== */\n  .agent-theme[data-agent-theme='neo-pop'] {\n    --ac-font-body: var(--ac-font-sans);\n    --ac-font-heading: var(--ac-font-grotesk);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-border-width: 4px;\n    --ac-border-width-strong: 4px;\n    --ac-radius-card: 0px;\n    --ac-radius-inner: 0px;\n    --ac-radius-button: 0px;\n\n    --ac-bg: #fff7ed;\n    --ac-bg-pattern: radial-gradient(rgba(17, 24, 39, 0.12) 1px, transparent 1px);\n    --ac-bg-pattern-size: 18px 18px;\n\n    --ac-header-bg: rgba(255, 247, 237, 0.92);\n    --ac-header-border: #111827;\n\n    --ac-surface: #ffffff;\n    --ac-surface-muted: #ffedd5;\n    --ac-surface-inset: #ffffff;\n\n    --ac-text: #111827;\n    --ac-text-muted: #374151;\n    --ac-text-subtle: #6b7280;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #9ca3af;\n\n    --ac-border: #111827;\n    --ac-border-strong: #111827;\n\n    --ac-hover-bg: rgba(17, 24, 39, 0.06);\n    --ac-hover-bg-subtle: rgba(17, 24, 39, 0.04);\n\n    --ac-accent: #ff3d7f;\n    --ac-accent-hover: #ff1f6a;\n    --ac-accent-subtle: rgba(255, 61, 127, 0.14);\n    --ac-accent-contrast: #ffffff;\n\n    --ac-accent-2: #22d3ee;\n    --ac-link: var(--ac-accent-2);\n    --ac-link-hover: #06b6d4;\n\n    --ac-selection-bg: rgba(255, 61, 127, 0.25);\n    --ac-selection-text: #111827;\n\n    --ac-shadow-card: 6px 6px 0 0 var(--ac-border);\n    --ac-shadow-float: 8px 8px 0 0 var(--ac-border);\n    --ac-focus-ring: rgba(17, 24, 39, 0.35);\n\n    --ac-timeline-line-width: 4px;\n    --ac-timeline-line: #111827;\n    --ac-timeline-node: #111827;\n    --ac-timeline-node-hover: #374151;\n    --ac-timeline-node-active: var(--ac-accent);\n    --ac-timeline-node-active-border: #111827;\n    --ac-timeline-node-tool: #6b7280;\n    --ac-timeline-node-pulse-shadow: 0 0 0 2px rgba(17, 24, 39, 1);\n\n    --ac-chip-bg: #ffffff;\n    --ac-chip-text: #111827;\n    --ac-chip-border: #111827;\n\n    --ac-code-bg: #ffffff;\n    --ac-code-text: #111827;\n    --ac-code-border: #111827;\n\n    --ac-diff-add-bg: rgba(34, 197, 94, 0.18);\n    --ac-diff-add-text: #15803d;\n    --ac-diff-add-border: #111827;\n    --ac-diff-del-bg: rgba(239, 68, 68, 0.18);\n    --ac-diff-del-text: #b91c1c;\n    --ac-diff-del-border: #111827;\n\n    --ac-scrollbar-thumb: rgba(17, 24, 39, 0.25);\n    --ac-scrollbar-thumb-hover: rgba(17, 24, 39, 0.4);\n  }\n\n  /* ========================================\n     DARK CONSOLE\n     ======================================== */\n  .agent-theme[data-agent-theme='dark-console'] {\n    --ac-font-body: var(--ac-font-mono);\n    --ac-font-heading: var(--ac-font-mono);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-bg: #0f1117;\n    --ac-bg-pattern: none;\n    --ac-bg-pattern-size: 16px 16px;\n\n    --ac-header-bg: #0f1117;\n    --ac-header-border: #1f2937;\n\n    --ac-surface: #0f1117;\n    --ac-surface-muted: #0a0c10;\n    --ac-surface-inset: #1a1d26;\n\n    --ac-text: #e5e7eb;\n    --ac-text-muted: #9ca3af;\n    --ac-text-subtle: #6b7280;\n    --ac-text-inverse: #0a0c10;\n    --ac-text-placeholder: #4b5563;\n\n    --ac-border: #1f2937;\n    --ac-border-strong: #374151;\n\n    --ac-hover-bg: rgba(255, 255, 255, 0.06);\n    --ac-hover-bg-subtle: rgba(255, 255, 255, 0.04);\n\n    --ac-accent: #c084fc;\n    --ac-accent-hover: #d8b4fe;\n    --ac-accent-subtle: rgba(192, 132, 252, 0.14);\n    --ac-accent-contrast: #0a0c10;\n\n    --ac-accent-2: #60a5fa;\n    --ac-link: var(--ac-accent-2);\n    --ac-link-hover: #93c5fd;\n\n    --ac-selection-bg: rgba(192, 132, 252, 0.25);\n    --ac-selection-text: #ffffff;\n\n    --ac-shadow-card: none;\n    --ac-shadow-float: none;\n    --ac-focus-ring: rgba(192, 132, 252, 0.35);\n\n    --ac-timeline-line-width: 2px;\n    --ac-timeline-line: #1f2937;\n    --ac-timeline-node: #374151;\n    --ac-timeline-node-hover: #6b7280;\n    --ac-timeline-node-active: var(--ac-accent);\n    --ac-timeline-node-active-border: var(--ac-accent);\n    --ac-timeline-node-tool: #64748b;\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(192, 132, 252, 0.35), 0 0 14px rgba(192, 132, 252, 0.25);\n\n    --ac-chip-bg: rgba(255, 255, 255, 0.06);\n    --ac-chip-text: #e5e7eb;\n    --ac-chip-border: rgba(255, 255, 255, 0.1);\n\n    --ac-code-bg: #0a0c10;\n    --ac-code-text: #e5e7eb;\n    --ac-code-border: #1f2937;\n\n    --ac-diff-add-bg: rgba(74, 222, 128, 0.1);\n    --ac-diff-add-text: #4ade80;\n    --ac-diff-add-border: rgba(74, 222, 128, 0.35);\n\n    --ac-diff-del-bg: rgba(248, 113, 113, 0.1);\n    --ac-diff-del-text: #f87171;\n    --ac-diff-del-border: rgba(248, 113, 113, 0.35);\n\n    --ac-scrollbar-thumb: rgba(255, 255, 255, 0.12);\n    --ac-scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);\n  }\n\n  /* ========================================\n     SWISS GRID\n     ======================================== */\n  .agent-theme[data-agent-theme='swiss-grid'] {\n    --ac-font-body: var(--ac-font-grotesk);\n    --ac-font-heading: var(--ac-font-grotesk);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-bg: #ffffff;\n    --ac-bg-pattern: radial-gradient(#e5e7eb 1px, transparent 1px);\n    --ac-bg-pattern-size: 16px 16px;\n\n    --ac-header-bg: #ffffff;\n    --ac-header-border: #000000;\n\n    --ac-surface: #ffffff;\n    --ac-surface-muted: #f3f4f6;\n    --ac-surface-inset: #ffffff;\n\n    --ac-text: #000000;\n    --ac-text-muted: #374151;\n    --ac-text-subtle: #6b7280;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #9ca3af;\n\n    --ac-border: #000000;\n    --ac-border-strong: #000000;\n\n    --ac-hover-bg: #f3f4f6;\n    --ac-hover-bg-subtle: #f9fafb;\n\n    --ac-accent: #000000;\n    --ac-accent-hover: #111827;\n    --ac-accent-subtle: rgba(0, 0, 0, 0.06);\n    --ac-accent-contrast: #ffffff;\n    --ac-accent-2: #000000;\n\n    --ac-link: #000000;\n    --ac-link-hover: #111827;\n\n    --ac-selection-bg: #000000;\n    --ac-selection-text: #ffffff;\n\n    --ac-border-width: 2px;\n    --ac-border-width-strong: 2px;\n    --ac-radius-card: 0px;\n    --ac-radius-inner: 0px;\n    --ac-radius-button: 0px;\n\n    --ac-shadow-card: 4px 4px 0 0 rgba(0, 0, 0, 1);\n    --ac-shadow-float: 4px 4px 0 0 rgba(0, 0, 0, 1);\n    --ac-focus-ring: rgba(0, 0, 0, 0.5);\n\n    --ac-timeline-line-width: 2px;\n    --ac-timeline-line: #000000;\n    --ac-timeline-node: #000000;\n    --ac-timeline-node-hover: #111827;\n    --ac-timeline-node-active: #000000;\n    --ac-timeline-node-active-border: #000000;\n    --ac-timeline-node-tool: #4b5563;\n    --ac-timeline-node-pulse-shadow: 0 0 0 2px rgba(0, 0, 0, 1);\n\n    --ac-chip-bg: #ffffff;\n    --ac-chip-text: #000000;\n    --ac-chip-border: #000000;\n\n    --ac-code-bg: #ffffff;\n    --ac-code-text: #000000;\n    --ac-code-border: #000000;\n\n    --ac-diff-add-bg: #000000;\n    --ac-diff-add-text: #ffffff;\n    --ac-diff-add-border: #000000;\n\n    --ac-diff-del-bg: #f3f4f6;\n    --ac-diff-del-text: #6b7280;\n    --ac-diff-del-border: #000000;\n\n    --ac-scrollbar-thumb: rgba(0, 0, 0, 0.25);\n    --ac-scrollbar-thumb-hover: rgba(0, 0, 0, 0.4);\n  }\n\n  /* ========================================\n     Base Styles (apply to .agent-theme)\n     ======================================== */\n  .agent-theme {\n    color: var(--ac-text);\n    background: var(--ac-bg);\n    background-image: var(--ac-bg-pattern);\n    background-size: var(--ac-bg-pattern-size);\n    font-family: var(--ac-font-body);\n  }\n\n  .agent-theme ::selection {\n    background: var(--ac-selection-bg);\n    color: var(--ac-selection-text);\n  }\n\n  /* ========================================\n     Scrollbar Styles\n     ======================================== */\n  .agent-theme .ac-scroll {\n    scrollbar-width: thin;\n    scrollbar-color: var(--ac-scrollbar-thumb) transparent;\n  }\n\n  .agent-theme .ac-scroll::-webkit-scrollbar {\n    width: var(--ac-scrollbar-size);\n    height: var(--ac-scrollbar-size);\n  }\n\n  .agent-theme .ac-scroll::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  .agent-theme .ac-scroll::-webkit-scrollbar-thumb {\n    background-color: var(--ac-scrollbar-thumb);\n    border-radius: 999px;\n  }\n\n  .agent-theme .ac-scroll::-webkit-scrollbar-thumb:hover {\n    background-color: var(--ac-scrollbar-thumb-hover);\n  }\n\n  /* Hide scrollbar but keep functionality */\n  .agent-theme .ac-scroll-hidden {\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n  }\n\n  .agent-theme .ac-scroll-hidden::-webkit-scrollbar {\n    display: none;\n  }\n\n  /* ========================================\n     Utility Classes\n     ======================================== */\n\n  /* Focus ring */\n  .agent-theme .ac-focus-ring:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--ac-focus-ring);\n  }\n\n  /* Hover utilities */\n  .agent-theme .ac-hover-bg:hover {\n    background-color: var(--ac-hover-bg);\n  }\n\n  .agent-theme .ac-hover-text:hover {\n    color: var(--ac-text);\n  }\n\n  .agent-theme .ac-hover-link:hover {\n    color: var(--ac-link);\n  }\n\n  .agent-theme .ac-hover-accent:hover {\n    color: var(--ac-accent);\n  }\n\n  /* Button/interactive element base */\n  .agent-theme .ac-btn {\n    cursor: pointer;\n    transition:\n      background-color var(--ac-motion-fast),\n      color var(--ac-motion-fast);\n  }\n\n  .agent-theme .ac-btn:hover {\n    background-color: var(--ac-hover-bg);\n  }\n\n  /* Menu item */\n  .agent-theme .ac-menu-item {\n    cursor: pointer;\n    transition: background-color var(--ac-motion-fast);\n  }\n\n  .agent-theme .ac-menu-item:hover {\n    background-color: var(--ac-hover-bg);\n  }\n\n  /* Chip/pill hover */\n  .agent-theme .ac-chip-hover:hover {\n    color: var(--ac-link);\n  }\n\n  /* Pulse animation for streaming indicator */\n  @keyframes ac-pulse {\n    0%,\n    100% {\n      opacity: 1;\n    }\n    50% {\n      opacity: 0.5;\n    }\n  }\n\n  .agent-theme .ac-pulse {\n    animation: ac-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n  }\n\n  /* Respect reduced motion preference */\n  @media (prefers-reduced-motion: reduce) {\n    .agent-theme .ac-pulse {\n      animation: none;\n    }\n  }\n\n  /* ============================================================\n     Loading Animation - Shimmer Text & Scribble Icon\n     ============================================================ */\n\n  /* 文案 shimmer 渐变动画 - 光从左到右扫过效果 */\n  .agent-theme .text-shimmer {\n    display: inline-block;\n    background: linear-gradient(\n      90deg,\n      var(--ac-accent, #d97757) 0%,\n      var(--ac-accent, #d97757) 40%,\n      #ffe0d0 50%,\n      var(--ac-accent, #d97757) 60%,\n      var(--ac-accent, #d97757) 100%\n    );\n    background-size: 250% 100%;\n    background-repeat: no-repeat;\n    color: transparent;\n    -webkit-background-clip: text;\n    background-clip: text;\n    -webkit-text-fill-color: transparent;\n    animation: ac-shimmer 1.8s ease-in-out infinite;\n  }\n\n  @keyframes ac-shimmer {\n    0% {\n      background-position: 100% 50%;\n    }\n    100% {\n      background-position: 0% 50%;\n    }\n  }\n\n  /* 螺旋图标 - 笔迹重绘动画 */\n  .agent-theme .loading-scribble path {\n    stroke-dasharray: 300;\n    stroke-dashoffset: 300;\n    animation: ac-scribble-draw 2s ease-in-out infinite;\n  }\n\n  .agent-theme .loading-scribble {\n    animation: ac-slight-rotate 8s linear infinite;\n  }\n\n  @keyframes ac-scribble-draw {\n    0% {\n      stroke-dashoffset: 300;\n    }\n    50% {\n      stroke-dashoffset: 0;\n    }\n    100% {\n      stroke-dashoffset: -300;\n    }\n  }\n\n  @keyframes ac-slight-rotate {\n    0% {\n      transform: rotate(0deg);\n    }\n    100% {\n      transform: rotate(360deg);\n    }\n  }\n\n  /* Respect reduced motion preference for loading animations */\n  @media (prefers-reduced-motion: reduce) {\n    .agent-theme .text-shimmer {\n      animation: none;\n      background: none;\n      color: var(--ac-accent);\n    }\n\n    .agent-theme .loading-scribble,\n    .agent-theme .loading-scribble path {\n      animation: none;\n    }\n\n    .agent-theme .loading-scribble path {\n      stroke-dashoffset: 0;\n    }\n  }\n\n  /* ============================================================\n     Tooltip System - CSS-only tooltips using data-tooltip attribute\n     ============================================================ */\n  .agent-theme [data-tooltip] {\n    position: relative;\n  }\n\n  .agent-theme [data-tooltip]::after {\n    content: attr(data-tooltip);\n    position: absolute;\n    bottom: calc(100% + 6px);\n    left: 50%;\n    transform: translateX(-50%);\n    padding: 4px 8px;\n    font-size: 11px;\n    font-family: var(--ac-font-sans);\n    font-weight: 400;\n    line-height: 1.3;\n    white-space: nowrap;\n    color: var(--ac-text-inverse);\n    background-color: var(--ac-text);\n    border-radius: var(--ac-radius-button);\n    opacity: 0;\n    visibility: hidden;\n    transition:\n      opacity 150ms ease,\n      visibility 150ms ease;\n    pointer-events: none;\n    z-index: 99999;\n  }\n\n  .agent-theme [data-tooltip]:hover::after {\n    opacity: 1;\n    visibility: visible;\n  }\n\n  /* Tooltip arrow */\n  .agent-theme [data-tooltip]::before {\n    content: '';\n    position: absolute;\n    bottom: calc(100% + 2px);\n    left: 50%;\n    transform: translateX(-50%);\n    border: 4px solid transparent;\n    border-top-color: var(--ac-text);\n    opacity: 0;\n    visibility: hidden;\n    transition:\n      opacity 150ms ease,\n      visibility 150ms ease;\n    pointer-events: none;\n    z-index: 99999;\n  }\n\n  .agent-theme [data-tooltip]:hover::before {\n    opacity: 1;\n    visibility: visible;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/sidepanel/utils/loading-texts.ts",
    "content": "/**\n * 随机 Loading 文案\n * 用于 TimelineStatusStep 组件展示趣味等待提示\n */\n\nconst loadingTexts = [\n  // 必选神梗\n  '本来应该从从容容游刃有余',\n  '现在是匆匆忙忙连滚带爬',\n  '我知道你很急，但是先别急',\n  '在知识的海洋里狗刨',\n  '让子弹再飞一会儿',\n  '正在为您手搓答案',\n  '浪浪山小妖怪集结中',\n  '别催，已经在写了（新建文件夹）',\n  '正在汗流浃背地思考中',\n  'CPU 都要给我干烧了',\n  // 生活气息\n  '村咖慢焙，精华需要时间',\n  '知识煎饼翻面中',\n  '敬自己一杯，马上好',\n  '正在把灵感放入烤箱',\n  '让答案再泡一会儿',\n  '情绪价值拉满中',\n  '正在为您编织语言的毛衣',\n  // 脑洞大开\n  '神经元蹦迪中',\n  '熬夜的猫头鹰在思考',\n  '给答案上色中',\n  '正在疯狂翻阅知识库',\n  '大脑马戏团开演',\n  '正在把 0 和 1 捏在一起',\n  '正在憋个大招',\n  '放大镜有点起雾，擦擦',\n  '试图理解这个离谱的需求',\n  // 玄幻\n  '正在施法，莫打扰',\n  '唤醒硅基朋友',\n  '正在连接赛博空间的智慧',\n  '道友请留步，正在推演',\n  '穿越知识黑洞',\n  '正在反向解析人类意图',\n  '水晶球有点模糊，拍两下',\n  // 职场\n  '代码跑得比记者还快',\n  '主理人已上线，请稍候',\n  '快马加鞭赶来中',\n  '正在光速搬运知识',\n  '拼图最后一块',\n  '答案即将杀青',\n  '发射倒计时',\n  '目标锁定中',\n];\n\n/**\n * 获取随机 Loading 文案\n */\nexport function getRandomLoadingText(): string {\n  return loadingTexts[Math.floor(Math.random() * loadingTexts.length)];\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/styles/tailwind.css",
    "content": "@import 'tailwindcss';\n\n/* App background and card helpers */\n@layer base {\n  html,\n  body,\n  #app {\n    height: 100%;\n  }\n  body {\n    @apply bg-slate-50 text-slate-800;\n  }\n\n  /* Record&Replay builder design tokens */\n  .rr-theme {\n    --rr-bg: #f8fafc;\n    --rr-topbar: rgba(255, 255, 255, 0.9);\n    --rr-card: #ffffff;\n    --rr-elevated: #ffffff;\n    --rr-border: #e5e7eb;\n    --rr-subtle: #f3f4f6;\n    --rr-text: #0f172a;\n    --rr-text-weak: #475569;\n    --rr-muted: #64748b;\n    --rr-brand: #7c3aed;\n    --rr-brand-strong: #5b21b6;\n    --rr-accent: #0ea5e9;\n    --rr-success: #10b981;\n    --rr-warn: #f59e0b;\n    --rr-danger: #ef4444;\n    --rr-dot: rgba(2, 6, 23, 0.08);\n  }\n  .rr-theme[data-theme='dark'] {\n    --rr-bg: #0b1020;\n    --rr-topbar: rgba(12, 15, 24, 0.8);\n    --rr-card: #0f1528;\n    --rr-elevated: #121a33;\n    --rr-border: rgba(255, 255, 255, 0.08);\n    --rr-subtle: rgba(255, 255, 255, 0.04);\n    --rr-text: #e5e7eb;\n    --rr-text-weak: #cbd5e1;\n    --rr-muted: #94a3b8;\n    --rr-brand: #a78bfa;\n    --rr-brand-strong: #7c3aed;\n    --rr-accent: #38bdf8;\n    --rr-success: #34d399;\n    --rr-warn: #fbbf24;\n    --rr-danger: #f87171;\n    --rr-dot: rgba(226, 232, 240, 0.08);\n  }\n}\n\n@layer components {\n  .card {\n    @apply rounded-xl shadow-md border;\n    background: var(--rr-card);\n    border-color: var(--rr-border);\n  }\n  /* Generic buttons used across builder */\n  .btn {\n    @apply inline-flex items-center justify-center rounded-lg px-3 py-2 text-sm font-medium transition;\n    background: var(--rr-card);\n    color: var(--rr-text);\n    border: 1px solid var(--rr-border);\n  }\n  .btn:hover {\n    @apply shadow-sm;\n    background: var(--rr-subtle);\n  }\n  .btn[disabled] {\n    @apply opacity-60 cursor-not-allowed;\n  }\n  .btn.primary {\n    color: #fff;\n    background: var(--rr-brand-strong);\n    border-color: var(--rr-brand-strong);\n  }\n  .btn.primary:hover {\n    filter: brightness(1.05);\n  }\n  .btn.ghost {\n    background: transparent;\n    border-color: transparent;\n  }\n\n  .mini {\n    @apply inline-flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium;\n    background: var(--rr-card);\n    color: var(--rr-text);\n    border: 1px solid var(--rr-border);\n  }\n  .mini:hover {\n    background: var(--rr-subtle);\n  }\n  .mini.danger {\n    background: color-mix(in oklab, var(--rr-danger) 8%, transparent);\n    border-color: color-mix(in oklab, var(--rr-danger) 24%, var(--rr-border));\n    color: var(--rr-text);\n  }\n\n  .input {\n    @apply w-full px-3 py-2 rounded-lg text-sm;\n    background: var(--rr-card);\n    color: var(--rr-text);\n    border: 1px solid var(--rr-border);\n    outline: none;\n  }\n  .input:focus {\n    box-shadow: 0 0 0 3px color-mix(in oklab, var(--rr-brand) 26%, transparent);\n    border-color: var(--rr-brand);\n  }\n  .select {\n    @apply w-full px-3 py-2 rounded-lg text-sm;\n    background: var(--rr-card);\n    color: var(--rr-text);\n    border: 1px solid var(--rr-border);\n    outline: none;\n  }\n  .textarea {\n    @apply w-full rounded-lg text-sm;\n    padding: 10px 12px;\n    background: var(--rr-card);\n    color: var(--rr-text);\n    border: 1px solid var(--rr-border);\n    outline: none;\n  }\n  .label {\n    @apply text-sm;\n    color: var(--rr-muted);\n  }\n  .badge {\n    @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;\n  }\n  .badge-purple {\n    background: color-mix(in oklab, var(--rr-brand) 14%, transparent);\n    color: var(--rr-brand);\n  }\n\n  /* Builder topbar */\n  .rr-topbar {\n    height: 56px;\n    border-bottom: 1px solid var(--rr-border);\n    background: var(--rr-topbar);\n  }\n\n  /* Dot grid background utility for canvas container */\n  .rr-dot-grid {\n    background-image: radial-gradient(var(--rr-dot) 1px, transparent 1px);\n    background-size: 20px 20px;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/attr-ui-refactor.md",
    "content": "# Property Panel UI 重构计划\n\n## 背景\n\n当前属性面板的 UI 实现与设计稿 `attr-ui.html` 存在较大差异。本文档详细规划了重构任务，按照优先级从高到低排列，目标是让属性面板的视觉效果和交互体验与设计稿一致。\n\n### 参考文件\n\n- **设计稿**：`/attr-ui.html`\n- **当前样式**：`ui/shadow-host.ts`\n- **面板结构**：`ui/property-panel/property-panel.ts`\n- **控件组件**：`ui/property-panel/controls/*.ts`\n\n---\n\n## 前置任务（已完成）\n\n### 0.1 最小化 Bug 修复 ✅\n\n**问题**：toolbar 和属性面板最小化时，只是背景消失了，里面的内容实际上还在\n\n**根因**：CSS 中 `display: flex/inline-flex` 覆盖了 `[hidden]` 属性的默认 `display: none`\n\n**解决方案**：\n\n- [x] 在 `shadow-host.ts` 末尾添加全局 `[hidden] { display: none !important; }` 规则\n\n### 0.2 输入框优化 ✅\n\n**问题**：\n\n1. 输入框显示 placeholder 而非真实值\n2. Number 类型输入框不支持键盘上下键调整\n\n**解决方案**：\n\n- [x] 创建 `ui/property-panel/controls/number-stepping.ts` 工具模块\n  - 支持 ArrowUp/ArrowDown 键盘步进\n  - 支持 Shift (10x)、Alt (0.1x) 修饰键\n  - 支持多种 CSS 单位 (px, %, rem, em, vh, vw, vmin, vmax)\n- [x] 修改所有 control 显示真实值（inline 优先，fallback 到 computed）\n- [x] 为所有数值输入框添加 keyboard stepping 支持：\n  - `size-control.ts` - Width/Height\n  - `spacing-control.ts` - Margin/Padding\n  - `position-control.ts` - Top/Right/Bottom/Left/Z-Index\n  - `layout-control.ts` - Gap\n  - `typography-control.ts` - Font Size/Line Height\n  - `appearance-control.ts` - Opacity/Border Radius/Border Width\n\n---\n\n## 阶段一：基础视觉系统对齐 ✅ 已完成\n\n### 1.1 颜色方案重构 ✅\n\n**目标**：将颜色系统从当前的灰色调整为设计稿的白底+灰输入框风格\n\n| 属性         | 旧值              | 新值                              | 状态 |\n| ------------ | ----------------- | --------------------------------- | ---- |\n| 面板背景     | `#f8f8f8`         | `#ffffff`                         | ✅   |\n| 输入框背景   | `#f0f0f0`         | `#f3f3f3`                         | ✅   |\n| 输入框 hover | `#e8e8e8` (bg)    | `border #e0e0e0` (inset)          | ✅   |\n| 输入框 focus | `box-shadow` 外圈 | `inset 2px border #3b82f6` + 白底 | ✅   |\n| 边框色       | `#e8e8e8`         | `#e5e5e5`                         | ✅   |\n\n**完成的任务**：\n\n- [x] 更新 CSS 变量定义 (`shadow-host.ts:56-97`)\n- [x] 修改输入框 hover/focus 样式为 inset border 模式\n- [x] 面板背景改为纯白\n\n### 1.2 字体与字号调整 ✅\n\n| 属性         | 旧值     | 新值                      | 状态 |\n| ------------ | -------- | ------------------------- | ---- |\n| 面板基础字号 | `13px`   | `11px`                    | ✅   |\n| 标签字号     | `11px`   | `10px`                    | ✅   |\n| 输入框字号   | `12px`   | `11px`                    | ✅   |\n| 字体家族     | 系统字体 | Inter + 系统字体 fallback | ✅   |\n\n**完成的任务**：\n\n- [x] 添加 Inter 字体声明（使用系统字体 fallback）\n- [x] 调整面板、标签、输入框的字号\n- [x] 移除标签的大写样式\n\n### 1.3 间距与边距调整 ✅\n\n| 属性          | 旧值        | 新值       | 状态 |\n| ------------- | ----------- | ---------- | ---- |\n| 面板宽度      | `320px`     | `280px`    | ✅   |\n| Header 内边距 | `10px 14px` | `8px 12px` | ✅   |\n| Body gap      | `10px`      | `12px`     | ✅   |\n\n**完成的任务**：\n\n- [x] 调整 `.we-panel`, `.we-prop-body`, `.we-field-group` 的 padding/gap\n- [x] 调整 header 的 padding\n\n### 1.4 圆角与阴影 ✅\n\n| 属性       | 旧值        | 新值               | 状态 |\n| ---------- | ----------- | ------------------ | ---- |\n| 面板阴影   | `0 1px 2px` | Tailwind shadow-xl | ✅   |\n| 输入框圆角 | `6px`       | `4px`              | ✅   |\n| Tab 阴影   | 无          | `shadow-sm`        | ✅   |\n\n**完成的任务**：\n\n- [x] 增强面板阴影效果（双层阴影模拟 shadow-xl）\n- [x] 调整输入框圆角为 4px\n- [x] 为激活的 Tab 添加阴影\n\n### 1.5 Group/Section 样式重构 ✅\n\n| 属性         | 旧样式      | 新样式      | 状态 |\n| ------------ | ----------- | ----------- | ---- |\n| Group 边框   | 卡片边框    | 无边框      | ✅   |\n| Section 分隔 | 无          | 顶部分隔线  | ✅   |\n| Header 样式  | 粗体 + 大字 | 11px + #333 | ✅   |\n\n**完成的任务**：\n\n- [x] 移除 `.we-group` 的边框和背景\n- [x] 添加 Section 间的分隔线 (`border-top`)\n- [x] 调整 Group header 样式\n\n---\n\n## 阶段二：输入容器组件重构 ✅ 基础完成\n\n### 2.1 建立输入容器系统 ✅\n\n**背景**：设计稿的输入框不是单体 input，而是一个容器系统，支持：\n\n- 前缀（prefix）：标签、图标\n- 后缀（suffix）：单位、图标\n- 容器驱动的 hover/focus 样式\n\n**当前结构**：\n\n```html\n<div class=\"we-field\">\n  <span class=\"we-field-label\">Width</span>\n  <input class=\"we-input\" />\n</div>\n```\n\n**目标结构**：\n\n```html\n<div class=\"we-field\">\n  <span class=\"we-field-label\">Position</span>\n  <div class=\"we-input-container\">\n    <!-- 新增容器 -->\n    <span class=\"we-input-container__prefix\">X</span>\n    <!-- 可选前缀 -->\n    <input class=\"we-input-container__input\" />\n    <span class=\"we-input-container__suffix\">px</span>\n    <!-- 可选后缀 -->\n  </div>\n</div>\n```\n\n**已完成**：\n\n- [x] 在 `shadow-host.ts` 中定义 `.we-input-container` 样式\n- [x] 定义 `.we-input-container__prefix` 和 `.we-input-container__suffix` 样式\n- [x] 创建 `ui/property-panel/components/input-container.ts` 组件\n- [x] 将 hover/focus 样式移到容器级别（使用 `:focus-within`）\n\n### 2.2 更新各 Control 使用新容器 ✅ 已完成\n\n**需要更新的控件**：\n\n- [x] `size-control.ts` - Width/Height（2列布局 + W/H 前缀 + 动态单位后缀）\n- [x] `spacing-control.ts` - Margin/Padding（重构为 2x2 网格 + 方向图标 + 动态单位后缀）\n- [x] `position-control.ts` - Top/Right/Bottom/Left/Z-Index（T/R/B/L 前缀 + 动态单位后缀）\n- [x] `layout-control.ts` - Gap（图标前缀 + 动态单位后缀）\n- [x] `typography-control.ts` - Font Size/Line Height（动态单位后缀，line-height 智能显示）\n- [ ] `appearance-control.ts` - Opacity/Border Radius/Border Width（待实施）\n\n**已完成的共享模块**：\n\n- [x] 创建 `css-helpers.ts` 共享模块（extractUnitSuffix, hasExplicitUnit, normalizeLength）\n- [x] 所有控件使用共享 helper，消除重复代码\n\n---\n\n## 阶段三：Section 结构重构（待实施）\n\n### 3.1 Tab 信息架构调整\n\n**当前**：4 个 Tab（Design/CSS/Props/DOM）\n**设计稿**：2 个 Tab（Design/CSS）\n\n**方案选择**：\n\n- **方案 A**：保留 4 个 Tab，调整为溢出菜单\n- **方案 B**：将 Props/DOM 移到其他入口\n- **方案 C**：保持 4 个 Tab，调整样式适应\n\n**任务**：\n\n- [ ] 确定 Tab 数量的产品决策\n- [ ] 实现选定方案\n\n---\n\n## 阶段四：功能组件实现（待实施）\n\n### 4.1 Flow 布局图标组 ✅ 已完成\n\n**设计稿位置**：`attr-ui.html:133-156`\n**功能**：4 个图标按钮控制 `flex-direction`\n\n```\n[→] Row\n[↓] Column\n[←] Row Reverse\n[↑] Column Reverse\n```\n\n**已完成**：\n\n- [x] 创建 `ui/property-panel/components/icon-button-group.ts` 通用组件\n- [x] 在 `shadow-host.ts` 中添加 `.we-icon-button-group` 样式\n- [x] 在 `layout-control.ts` 中用图标组替换 Direction select\n- [x] 添加对应的 SVG 箭头图标（row/column/row-reverse/column-reverse）\n\n### 4.2 Alignment 九宫格 ✅ 已完成\n\n**设计稿位置**：`attr-ui.html:166-208`\n**功能**：3x3 网格控制 `justify-content` + `align-items`\n\n```\n[↖][↑][↗]\n[←][·][→]\n[↙][↓][↘]\n```\n\n**已完成**：\n\n- [x] 创建 `ui/property-panel/components/alignment-grid.ts` 组件\n- [x] 在 `shadow-host.ts` 中添加 `.we-alignment-grid` 样式\n- [x] 替换 `layout-control.ts` 中的 Justify/Align select\n- [x] 使用 `beginMultiStyle` 实现两个属性的原子提交\n\n### 4.3 修复 Color Picker ✅ 部分完成\n\n**当前问题**：\n\n- `showPicker()` 无 try/catch，可能抛错\n- alpha 通道被丢弃\n- token 值 `var(--xxx)` 显示不正确\n\n**已完成**：\n\n- [x] 添加 `showPicker()` 的错误处理（try/catch + fallback to click）\n- [x] 改进 `var()` 值的解析和显示（通过 placeholder 传入 computed value）\n\n**待实施**：\n\n- [ ] 支持 alpha 通道（RGBA/HSLA）- 需要引入第三方 color picker\n- [ ] 考虑引入第三方 color picker（如 `@simonwep/pickr`）\n\n---\n\n## 阶段五：新功能模块（待实施）\n\n### 5.1 Shadow & Blur 控制\n\n**设计稿位置**：`attr-ui.html:396-425`\n**功能**：\n\n- 启用/禁用开关\n- 类型选择（Drop shadow/Inner shadow/Layer Blur/Backdrop Blur）\n- 可见性控制\n\n**CSS 属性**：\n\n- `box-shadow`\n- `filter: blur()`\n- `backdrop-filter: blur()`\n\n**任务**：\n\n- [x] 创建 `ui/property-panel/controls/effects-control.ts`\n- [x] 实现 `box-shadow` 值解析和编辑\n- [x] 实现 `filter` 值解析和编辑\n- [x] 实现 `backdrop-filter` 值解析和编辑\n- [x] 添加类型切换 UI\n- [ ] 添加启用/禁用开关（可选，后续实现）\n\n### 5.2 渐变编辑器\n\n**设计稿位置**：`attr-ui.html:269-325`\n**功能**：\n\n- Linear/Radial 渐变类型\n- 颜色停止点（color stops）\n- 角度控制\n- 翻转按钮\n\n**CSS 属性**：\n\n- `background-image: linear-gradient(...)`\n- `background-image: radial-gradient(...)`\n\n**任务**：\n\n- [x] 创建 `ui/property-panel/controls/gradient-control.ts`\n- [x] 实现渐变值解析（CSS gradient → 数据结构）\n- [x] 实现角度/位置输入\n- [x] 实现 2 个颜色停止点的编辑\n- [x] 集成到 property-panel（作为独立的 Gradient 控制组）\n- [ ] 实现渐变预览 slider（可选，后续优化）\n- [ ] 实现 color stop 添加/删除/拖拽（可选，后续优化）\n\n### 5.3 Token/变量 Pill 显示\n\n**设计稿位置**：`attr-ui.html:374-384`\n**功能**：当值为 CSS 变量时，显示为可点击的 pill\n\n**任务**：\n\n- [ ] 检测 `var(--xxx)` 值\n- [ ] 渲染为 pill 样式\n- [ ] 点击打开 token picker\n\n---\n\n## 阶段六：代码质量（贯穿始终）\n\n### 6.1 样式系统统一\n\n- [x] 所有颜色使用 CSS 变量（阶段一完成）\n- [ ] 所有尺寸使用一致的 token\n- [ ] 移除 inline style，统一到 `shadow-host.ts`\n\n### 6.2 组件复用\n\n- [ ] 提取通用组件到 `ui/property-panel/components/`\n- [ ] 统一事件处理模式\n- [ ] 统一 disabled/enabled 状态处理\n\n### 6.3 类型安全\n\n- [ ] 所有组件使用 TypeScript 严格类型\n- [ ] 定义清晰的接口和类型\n- [ ] 移除 any 类型断言\n\n---\n\n## 实施进度\n\n| 阶段 | 任务               | 状态    | 备注                                         |\n| ---- | ------------------ | ------- | -------------------------------------------- |\n| 0.1  | 最小化 Bug 修复    | ✅      | 添加全局 `[hidden]` 规则                     |\n| 0.2  | 输入框优化         | ✅      | number-stepping + 真实值显示                 |\n| 1.1  | 颜色方案重构       | ✅      | 白底 + 灰输入框 + inset focus                |\n| 1.2  | 字体与字号调整     | ✅      | 11px 基准 + Inter 字体                       |\n| 1.3  | 间距与边距调整     | ✅      | 更紧凑的布局                                 |\n| 1.4  | 圆角与阴影         | ✅      | shadow-xl + 4px 圆角                         |\n| 1.5  | Group/Section 样式 | ✅      | 分隔线风格                                   |\n| 2.1  | 输入容器系统       | ✅      | 组件 + CSS 样式                              |\n| 2.2  | 更新 Controls      | ✅      | 所有主要控件已迁移，共享 css-helpers.ts      |\n| 3.1  | Tab 信息架构       | 待实施  |                                              |\n| 4.1  | Flow 图标组        | ✅      | icon-button-group.ts + 集成到 layout-control |\n| 4.2  | Alignment 九宫格   | ✅      | alignment-grid.ts + 集成到 layout-control    |\n| 4.3  | 修复 Color Picker  | ✅ 部分 | showPicker 异常处理 + var() 解析             |\n| 5.1  | Shadow & Blur      | ✅      | effects-control.ts + 集成到 property-panel   |\n| 5.2  | 渐变编辑器         | ✅      | gradient-control.ts + 集成到 property-panel  |\n| 5.3  | Token Pill         | 待实施  |                                              |\n\n---\n\n## 注意事项\n\n1. **渐进式实施**：每个 Phase 完成后应可独立测试和发布\n2. **保持向后兼容**：重构过程中不应破坏现有功能\n3. **设计决策记录**：遇到设计稿与实际需求冲突时，记录决策原因\n4. **性能考虑**：新增组件需考虑渲染性能，避免不必要的 DOM 操作\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/constants.ts",
    "content": "/**\n * Web Editor V2 Constants\n *\n * Centralized configuration values for the visual editor.\n * All magic strings/numbers should be defined here.\n */\n\n/** Editor version number */\nexport const WEB_EDITOR_V2_VERSION = 2 as const;\n\n/** Log prefix for console messages */\nexport const WEB_EDITOR_V2_LOG_PREFIX = '[WebEditorV2]' as const;\n\n// =============================================================================\n// DOM Element IDs\n// =============================================================================\n\n/** Shadow host element ID */\nexport const WEB_EDITOR_V2_HOST_ID = '__mcp_web_editor_v2_host__';\n\n/** Overlay container ID (for Canvas and visual feedback) */\nexport const WEB_EDITOR_V2_OVERLAY_ID = '__mcp_web_editor_v2_overlay__';\n\n/** UI container ID (for panels and controls) */\nexport const WEB_EDITOR_V2_UI_ID = '__mcp_web_editor_v2_ui__';\n\n// =============================================================================\n// Styling\n// =============================================================================\n\n/** Maximum z-index to ensure editor is always on top */\nexport const WEB_EDITOR_V2_Z_INDEX = 2147483647;\n\n/** Default panel width */\nexport const WEB_EDITOR_V2_PANEL_WIDTH = 320;\n\n// =============================================================================\n// Colors (Design System)\n// =============================================================================\n\nexport const WEB_EDITOR_V2_COLORS = {\n  /** Hover highlight color */\n  hover: '#3b82f6', // blue-500\n  /** Selected element color */\n  selected: '#22c55e', // green-500\n  /** Selection box border */\n  selectionBorder: '#6366f1', // indigo-500\n  /** Drag ghost color */\n  dragGhost: 'rgba(99, 102, 241, 0.3)',\n  /** Insertion line color */\n  insertionLine: '#f59e0b', // amber-500\n  /** Alignment guide line color (snap guides) */\n  guideLine: '#ec4899', // pink-500\n  /** Distance label background (Phase 4.3) */\n  distanceLabelBg: 'rgba(15, 23, 42, 0.92)', // slate-900 @ 92%\n  /** Distance label border (Phase 4.3) */\n  distanceLabelBorder: 'rgba(51, 65, 85, 0.5)', // slate-600 @ 50%\n  /** Distance label text (Phase 4.3) */\n  distanceLabelText: 'rgba(255, 255, 255, 0.98)',\n} as const;\n\n// =============================================================================\n// Drag Reorder (Phase 2.4-2.6)\n// =============================================================================\n\n/** Minimum pointer movement (px) to start dragging */\nexport const WEB_EDITOR_V2_DRAG_THRESHOLD_PX = 5;\n\n/** Hysteresis (px) for stable before/after decision to avoid flip-flop */\nexport const WEB_EDITOR_V2_DRAG_HYSTERESIS_PX = 6;\n\n/** Max elements to inspect per hit-test (elementsFromPoint) */\nexport const WEB_EDITOR_V2_DRAG_MAX_HIT_ELEMENTS = 8;\n\n/** Insertion indicator line width in CSS pixels */\nexport const WEB_EDITOR_V2_INSERTION_LINE_WIDTH = 3;\n\n// =============================================================================\n// Snapping & Alignment Guides (Phase 4.2)\n// =============================================================================\n\n/** Snap threshold in CSS pixels - distance at which snapping activates */\nexport const WEB_EDITOR_V2_SNAP_THRESHOLD_PX = 6;\n\n/** Hysteresis in CSS pixels - keeps snap stable near boundary to prevent flicker */\nexport const WEB_EDITOR_V2_SNAP_HYSTERESIS_PX = 2;\n\n/** Maximum sibling elements to consider for snapping (nearest first) */\nexport const WEB_EDITOR_V2_SNAP_MAX_ANCHOR_ELEMENTS = 30;\n\n/** Maximum siblings to scan before applying distance filter */\nexport const WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN = 300;\n\n/** Alignment guide line width in CSS pixels */\nexport const WEB_EDITOR_V2_GUIDE_LINE_WIDTH = 1;\n\n// =============================================================================\n// Distance Labels (Phase 4.3)\n// =============================================================================\n\n/** Minimum distance (px) to display a label - hides 0 and sub-pixel gaps */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_MIN_PX = 1;\n\n/** Measurement line width in CSS pixels */\nexport const WEB_EDITOR_V2_DISTANCE_LINE_WIDTH = 1;\n\n/** Tick size at the ends of measurement lines (CSS pixels) */\nexport const WEB_EDITOR_V2_DISTANCE_TICK_SIZE = 4;\n\n/** Font used for distance label pills */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_FONT =\n  '600 11px system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif';\n\n/** Horizontal padding inside distance label pill (CSS pixels) */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X = 6;\n\n/** Vertical padding inside distance label pill (CSS pixels) */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y = 3;\n\n/** Border radius for distance label pill (CSS pixels) */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS = 4;\n\n/** Offset from the measurement line to place the pill (CSS pixels) */\nexport const WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET = 8;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/css-compare.ts",
    "content": "/**\n * CSS Compare Utilities (Phase 4.8)\n *\n * Provides robust CSS value comparison for HMR consistency verification.\n *\n * Design goals:\n * - Compare computed style values (format-agnostic: \"1rem\" vs \"16px\" both resolve to same computed value)\n * - Handle numeric tolerance for px-based values and transform matrices\n * - Provide detailed diff information for UI feedback\n *\n * Why computed styles?\n * - Editor mutates live DOM via inline styles for immediate preview\n * - Agent may persist changes via classes/CSS modules/Tailwind, not inline styles\n * - Comparing computed values avoids false mismatches from authoring format differences\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Detailed diff for a single CSS property */\nexport interface ComputedDiffItem {\n  /** CSS property name */\n  readonly property: string;\n  /** Expected value (from baseline) */\n  readonly expected: string;\n  /** Actual value (from current DOM) */\n  readonly actual: string;\n  /** Whether values match */\n  readonly match: boolean;\n  /** How comparison was determined */\n  readonly reason?: 'exact' | 'px_epsilon' | 'matrix_epsilon' | 'string';\n}\n\n/** Result of comparing two computed style maps */\nexport interface CompareComputedResult {\n  /** Overall match status */\n  readonly matches: boolean;\n  /** Per-property diff details */\n  readonly diffs: readonly ComputedDiffItem[];\n}\n\n/** Options for CSS value comparison */\nexport interface CompareComputedOptions {\n  /**\n   * Epsilon for px-based numeric comparison.\n   * Defaults to 0.5 to tolerate sub-pixel jitter from rounding.\n   */\n  readonly pxEpsilon?: number;\n  /**\n   * Epsilon for matrix()/matrix3d() numeric comparison.\n   * Defaults to 1e-3 for floating-point precision tolerance.\n   */\n  readonly matrixEpsilon?: number;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_PX_EPSILON = 0.5;\nconst DEFAULT_MATRIX_EPSILON = 1e-3;\n\n// Regex patterns (defined once for performance)\nconst PX_VALUE_REGEX = /(-?\\d*\\.?\\d+(?:e[+-]?\\d+)?)px/gi;\nconst MATRIX_NUMBER_REGEX = /-?\\d*\\.?\\d+(?:e[+-]?\\d+)?/gi;\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Normalize text content for robust comparison.\n * Collapses whitespace and trims edges.\n */\nexport function normalizeText(text: string): string {\n  return String(text ?? '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\n/**\n * Read computed style values for specified CSS properties.\n *\n * @param element - Target element\n * @param properties - CSS property names to read\n * @returns Map of property name to computed value (normalized)\n */\nexport function readComputedMap(\n  element: Element,\n  properties: readonly string[],\n): Record<string, string> {\n  const result: Record<string, string> = {};\n\n  // Deduplicate and filter empty property names\n  const uniqueProps: string[] = [];\n  const seen = new Set<string>();\n  for (const raw of properties) {\n    const prop = String(raw ?? '').trim();\n    if (!prop || seen.has(prop)) continue;\n    seen.add(prop);\n    uniqueProps.push(prop);\n  }\n\n  // Safely get computed style declaration\n  let computed: CSSStyleDeclaration | null = null;\n  try {\n    computed = window.getComputedStyle(element);\n  } catch {\n    // Element may not be attached to DOM or other edge cases\n    computed = null;\n  }\n\n  // Read each property\n  for (const property of uniqueProps) {\n    let value = '';\n    try {\n      value = computed?.getPropertyValue(property) ?? '';\n    } catch {\n      value = '';\n    }\n    result[property] = normalizeCssValue(value);\n  }\n\n  return result;\n}\n\n/**\n * Compare two computed style maps with numeric tolerance.\n *\n * Comparison strategy:\n * 1. Exact string match → pass\n * 2. matrix()/matrix3d() numeric tolerance → pass if within epsilon\n * 3. px-based numeric tolerance → pass if same shape and within epsilon\n * 4. Otherwise → fail\n *\n * @param expected - Baseline computed values\n * @param actual - Current computed values\n * @param options - Comparison options\n * @returns Comparison result with per-property diffs\n */\nexport function compareComputed(\n  expected: Readonly<Record<string, string>>,\n  actual: Readonly<Record<string, string>>,\n  options: CompareComputedOptions = {},\n): CompareComputedResult {\n  const pxEps = Number.isFinite(options.pxEpsilon) ? options.pxEpsilon! : DEFAULT_PX_EPSILON;\n  const matrixEps = Number.isFinite(options.matrixEpsilon)\n    ? options.matrixEpsilon!\n    : DEFAULT_MATRIX_EPSILON;\n\n  const diffs: ComputedDiffItem[] = [];\n\n  for (const property of Object.keys(expected)) {\n    const exp = normalizeCssValue(expected[property] ?? '');\n    const act = normalizeCssValue(actual[property] ?? '');\n\n    const { match, reason } = compareSingleValue(exp, act, pxEps, matrixEps);\n    diffs.push({ property, expected: exp, actual: act, match, reason });\n  }\n\n  const matches = diffs.every((d) => d.match);\n  return { matches, diffs };\n}\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\n/**\n * Normalize a CSS value string for consistent comparison.\n * Collapses whitespace and normalizes spacing around punctuation.\n */\nfunction normalizeCssValue(raw: string): string {\n  return String(raw ?? '')\n    .replace(/\\s+/g, ' ') // Collapse whitespace\n    .replace(/,\\s+/g, ',') // Remove space after commas\n    .replace(/\\(\\s+/g, '(') // Remove space after open paren\n    .replace(/\\s+\\)/g, ')') // Remove space before close paren\n    .trim();\n}\n\n/**\n * Check if two numbers are approximately equal within epsilon.\n */\nfunction approximatelyEqual(a: number, b: number, epsilon: number): boolean {\n  return Math.abs(a - b) <= epsilon;\n}\n\n/**\n * Check if value looks like a CSS matrix transform.\n */\nfunction isMatrixValue(value: string): boolean {\n  const lower = value.toLowerCase();\n  return lower.startsWith('matrix(') || lower.startsWith('matrix3d(');\n}\n\n/**\n * Extract numeric components from a matrix() or matrix3d() value.\n * Returns null if not a valid matrix or contains invalid numbers.\n */\nfunction extractMatrixNumbers(value: string): number[] | null {\n  if (!isMatrixValue(value)) return null;\n\n  const matches = value.match(MATRIX_NUMBER_REGEX);\n  if (!matches || matches.length === 0) return null;\n\n  const nums: number[] = [];\n  for (const m of matches) {\n    const n = Number(m);\n    if (!Number.isFinite(n)) return null;\n    nums.push(n);\n  }\n\n  return nums.length > 0 ? nums : null;\n}\n\n/**\n * Extract px numeric values from a CSS value string.\n * Returns null if no px values found or contains invalid numbers.\n */\nfunction extractPxNumbers(value: string): number[] | null {\n  const nums: number[] = [];\n\n  // Reset regex state (global flag requires this)\n  PX_VALUE_REGEX.lastIndex = 0;\n\n  let match: RegExpExecArray | null;\n  while ((match = PX_VALUE_REGEX.exec(value)) !== null) {\n    const n = Number(match[1]);\n    if (!Number.isFinite(n)) return null;\n    nums.push(n);\n  }\n\n  return nums.length > 0 ? nums : null;\n}\n\n/**\n * Get the \"shape\" of a px-based value by replacing numeric values with placeholders.\n * Used to ensure we're comparing structurally similar values.\n */\nfunction pxValueShape(value: string): string {\n  // Reset regex state\n  PX_VALUE_REGEX.lastIndex = 0;\n  return normalizeCssValue(value).replace(PX_VALUE_REGEX, '#px');\n}\n\n/**\n * Compare two matrix values with numeric tolerance.\n */\nfunction compareMatrixWithEpsilon(expected: string, actual: string, epsilon: number): boolean {\n  const expNums = extractMatrixNumbers(expected);\n  const actNums = extractMatrixNumbers(actual);\n\n  if (!expNums || !actNums) return false;\n  if (expNums.length !== actNums.length) return false;\n\n  // Ensure both are same type (matrix vs matrix3d)\n  const expKind = expected.toLowerCase().startsWith('matrix3d(') ? 'matrix3d' : 'matrix';\n  const actKind = actual.toLowerCase().startsWith('matrix3d(') ? 'matrix3d' : 'matrix';\n  if (expKind !== actKind) return false;\n\n  // Compare each component with epsilon\n  for (let i = 0; i < expNums.length; i++) {\n    if (!approximatelyEqual(expNums[i]!, actNums[i]!, epsilon)) return false;\n  }\n\n  return true;\n}\n\n/**\n * Compare two px-based values with numeric tolerance.\n */\nfunction comparePxWithEpsilon(expected: string, actual: string, epsilon: number): boolean {\n  const expNums = extractPxNumbers(expected);\n  const actNums = extractPxNumbers(actual);\n\n  if (!expNums || !actNums) return false;\n  if (expNums.length !== actNums.length) return false;\n\n  // Ensure values have same structure (e.g., \"10px 20px\" vs \"10px 20px\", not \"10px\" vs \"10px solid\")\n  if (pxValueShape(expected) !== pxValueShape(actual)) return false;\n\n  // Compare each px value with epsilon\n  for (let i = 0; i < expNums.length; i++) {\n    if (!approximatelyEqual(expNums[i]!, actNums[i]!, epsilon)) return false;\n  }\n\n  return true;\n}\n\n/**\n * Compare a single CSS value pair with all available strategies.\n */\nfunction compareSingleValue(\n  expected: string,\n  actual: string,\n  pxEpsilon: number,\n  matrixEpsilon: number,\n): { match: boolean; reason: ComputedDiffItem['reason'] } {\n  // 1. Exact string match (fastest path)\n  if (expected === actual) {\n    return { match: true, reason: 'exact' };\n  }\n\n  // 2. Matrix tolerance comparison\n  if (isMatrixValue(expected) && isMatrixValue(actual)) {\n    if (compareMatrixWithEpsilon(expected, actual, matrixEpsilon)) {\n      return { match: true, reason: 'matrix_epsilon' };\n    }\n  }\n\n  // 3. Px tolerance comparison\n  const expHasPx = PX_VALUE_REGEX.test(expected);\n  PX_VALUE_REGEX.lastIndex = 0; // Reset after test\n  const actHasPx = PX_VALUE_REGEX.test(actual);\n  PX_VALUE_REGEX.lastIndex = 0; // Reset after test\n\n  if (expHasPx && actHasPx) {\n    if (comparePxWithEpsilon(expected, actual, pxEpsilon)) {\n      return { match: true, reason: 'px_epsilon' };\n    }\n  }\n\n  // 4. No match\n  return { match: false, reason: 'string' };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/cssom-styles-collector.ts",
    "content": "/**\n * CSSOM Styles Collector (Phase 4.6)\n *\n * Provides CSS rule collection and cascade computation using CSSOM.\n * Used for the CSS panel's style source tracking feature.\n *\n * Design goals:\n * - Collect matched CSS rules for an element via CSSOM\n * - Compute cascade (specificity + source order + !important)\n * - Track inherited styles from ancestor elements\n * - Handle Shadow DOM stylesheets\n * - Produce UI-ready snapshot for rendering\n *\n * Limitations (CSSOM-only approach):\n * - No reliable file:line info (only href/label available)\n * - @container/@scope rules are not evaluated\n * - @layer ordering is approximated via source order\n */\n\n// =============================================================================\n// Public Types (UI-ready snapshot)\n// =============================================================================\n\nexport type Specificity = readonly [inline: number, ids: number, classes: number, types: number];\n\nexport type DeclStatus = 'active' | 'overridden';\n\nexport interface CssRuleSource {\n  url?: string;\n  label: string;\n}\n\nexport interface CssDeclView {\n  id: string;\n  name: string;\n  value: string;\n  important: boolean;\n  affects: readonly string[];\n  status: DeclStatus;\n}\n\nexport interface CssRuleView {\n  id: string;\n  origin: 'inline' | 'rule';\n  selector: string;\n  matchedSelector?: string;\n  specificity?: Specificity;\n  source?: CssRuleSource;\n  order: number;\n  decls: CssDeclView[];\n}\n\nexport interface CssSectionView {\n  kind: 'inline' | 'matched' | 'inherited';\n  title: string;\n  inheritedFrom?: { label: string };\n  rules: CssRuleView[];\n}\n\nexport interface CssPanelSnapshot {\n  target: {\n    label: string;\n    root: 'document' | 'shadow';\n  };\n  warnings: string[];\n  stats: {\n    roots: number;\n    styleSheets: number;\n    rulesScanned: number;\n    matchedRules: number;\n  };\n  sections: CssSectionView[];\n}\n\n// =============================================================================\n// Internal Types (cascade + collection)\n// =============================================================================\n\ninterface DeclCandidate {\n  id: string;\n  important: boolean;\n  specificity: Specificity;\n  sourceOrder: readonly [sheetIndex: number, ruleOrder: number, declIndex: number];\n  property: string;\n  value: string;\n  affects: readonly string[];\n  ownerRuleId: string;\n  ownerElementId: number;\n}\n\ninterface FlatStyleRule {\n  sheetIndex: number;\n  order: number;\n  selectorText: string;\n  style: CSSStyleDeclaration;\n  source: CssRuleSource;\n}\n\ninterface RuleIndex {\n  root: Document | ShadowRoot;\n  rootId: number;\n  flatRules: FlatStyleRule[];\n  warnings: string[];\n  stats: { styleSheets: number; rulesScanned: number };\n}\n\ninterface CollectElementOptions {\n  includeInline: boolean;\n  declFilter: (decl: { property: string; affects: readonly string[] }) => boolean;\n}\n\ninterface CollectedElementRules {\n  element: Element;\n  elementId: number;\n  root: Document | ShadowRoot;\n  rootType: 'document' | 'shadow';\n  inlineRule: CssRuleView | null;\n  matchedRules: CssRuleView[];\n  candidates: DeclCandidate[];\n  warnings: string[];\n  stats: { matchedRules: number };\n}\n\n// =============================================================================\n// Specificity (Selectors Level 4)\n// =============================================================================\n\nconst ZERO_SPEC: Specificity = [0, 0, 0, 0] as const;\n\nexport function compareSpecificity(a: Specificity, b: Specificity): number {\n  for (let i = 0; i < 4; i++) {\n    if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;\n  }\n  return 0;\n}\n\nfunction splitSelectorList(input: string): string[] {\n  const out: string[] = [];\n  let start = 0;\n  let depthParen = 0;\n  let depthBrack = 0;\n  let quote: \"'\" | '\"' | null = null;\n\n  for (let i = 0; i < input.length; i++) {\n    const ch = input[i];\n\n    if (quote) {\n      if (ch === '\\\\') {\n        i += 1;\n        continue;\n      }\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n\n    if (ch === '[') depthBrack += 1;\n    else if (ch === ']' && depthBrack > 0) depthBrack -= 1;\n    else if (ch === '(') depthParen += 1;\n    else if (ch === ')' && depthParen > 0) depthParen -= 1;\n\n    if (ch === ',' && depthParen === 0 && depthBrack === 0) {\n      const part = input.slice(start, i).trim();\n      if (part) out.push(part);\n      start = i + 1;\n    }\n  }\n\n  const tail = input.slice(start).trim();\n  if (tail) out.push(tail);\n  return out;\n}\n\nfunction maxSpecificity(list: readonly Specificity[]): Specificity {\n  let best: Specificity = ZERO_SPEC;\n  for (const s of list) if (compareSpecificity(s, best) > 0) best = s;\n  return best;\n}\n\nfunction computeSelectorSpecificity(selector: string): Specificity {\n  let ids = 0;\n  let classes = 0;\n  let types = 0;\n\n  let expectType = true;\n\n  for (let i = 0; i < selector.length; i++) {\n    const ch = selector[i];\n\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n\n    if (ch === '[') {\n      classes += 1;\n      i = consumeBracket(selector, i);\n      expectType = false;\n      continue;\n    }\n\n    if (isCombinatorOrWhitespace(selector, i)) {\n      i = consumeWhitespaceAndCombinators(selector, i);\n      expectType = true;\n      continue;\n    }\n\n    if (ch === '#') {\n      ids += 1;\n      i = consumeIdent(selector, i + 1) - 1;\n      expectType = false;\n      continue;\n    }\n\n    if (ch === '.') {\n      classes += 1;\n      i = consumeIdent(selector, i + 1) - 1;\n      expectType = false;\n      continue;\n    }\n\n    if (ch === ':') {\n      const isPseudoEl = selector[i + 1] === ':';\n      if (isPseudoEl) {\n        types += 1;\n        const nameStart = i + 2;\n        const nameEnd = consumeIdent(selector, nameStart);\n        const name = selector.slice(nameStart, nameEnd).toLowerCase();\n        i = nameEnd - 1;\n\n        if (selector[i + 1] === '(' && name === 'slotted') {\n          const { content, endIndex } = consumeParenFunction(selector, i + 1);\n          const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity));\n          ids += maxArg[1];\n          classes += maxArg[2];\n          types += maxArg[3];\n          i = endIndex;\n        }\n\n        expectType = false;\n        continue;\n      }\n\n      const nameStart = i + 1;\n      const nameEnd = consumeIdent(selector, nameStart);\n      const name = selector.slice(nameStart, nameEnd).toLowerCase();\n\n      if (LEGACY_PSEUDO_ELEMENTS.has(name)) {\n        types += 1;\n        i = nameEnd - 1;\n        expectType = false;\n        continue;\n      }\n\n      if (selector[nameEnd] === '(') {\n        const { content, endIndex } = consumeParenFunction(selector, nameEnd);\n        i = endIndex;\n\n        if (name === 'where') {\n          expectType = false;\n          continue;\n        }\n\n        if (name === 'is' || name === 'not' || name === 'has') {\n          const maxArg = maxSpecificity(splitSelectorList(content).map(computeSelectorSpecificity));\n          ids += maxArg[1];\n          classes += maxArg[2];\n          types += maxArg[3];\n          expectType = false;\n          continue;\n        }\n\n        if (name === 'nth-child' || name === 'nth-last-child') {\n          classes += 1;\n          const ofSelectors = extractNthOfSelectorList(content);\n          if (ofSelectors) {\n            const maxArg = maxSpecificity(\n              splitSelectorList(ofSelectors).map(computeSelectorSpecificity),\n            );\n            ids += maxArg[1];\n            classes += maxArg[2];\n            types += maxArg[3];\n          }\n          expectType = false;\n          continue;\n        }\n\n        // Other functional pseudo-classes count as class specificity (+1).\n        classes += 1;\n        expectType = false;\n        continue;\n      }\n\n      classes += 1;\n      i = nameEnd - 1;\n      expectType = false;\n      continue;\n    }\n\n    if (expectType) {\n      if (ch === '*') {\n        expectType = false;\n        continue;\n      }\n      if (isIdentStart(ch)) {\n        types += 1;\n        i = consumeIdent(selector, i + 1) - 1;\n        expectType = false;\n        continue;\n      }\n    }\n  }\n\n  return [0, ids, classes, types] as const;\n}\n\n/**\n * For a selector list, returns the matched selector with max specificity among matches.\n */\nfunction computeMatchedRuleSpecificity(\n  element: Element,\n  selectorText: string,\n): { matchedSelector: string; specificity: Specificity } | null {\n  const selectors = splitSelectorList(selectorText);\n  let bestSel: string | null = null;\n  let bestSpec: Specificity = ZERO_SPEC;\n\n  for (const sel of selectors) {\n    try {\n      if (!element.matches(sel)) continue;\n      const spec = computeSelectorSpecificity(sel);\n      if (!bestSel || compareSpecificity(spec, bestSpec) > 0) {\n        bestSel = sel;\n        bestSpec = spec;\n      }\n    } catch {\n      // Invalid selector for matches() (e.g. pseudo-elements) => ignore.\n    }\n  }\n\n  return bestSel ? { matchedSelector: bestSel, specificity: bestSpec } : null;\n}\n\nconst LEGACY_PSEUDO_ELEMENTS = new Set([\n  'before',\n  'after',\n  'first-line',\n  'first-letter',\n  'selection',\n  'backdrop',\n  'placeholder',\n]);\n\nfunction isIdentStart(ch: string): boolean {\n  return /[a-zA-Z_]/.test(ch) || ch.charCodeAt(0) >= 0x80;\n}\n\nfunction consumeIdent(s: string, start: number): number {\n  let i = start;\n  for (; i < s.length; i++) {\n    const ch = s[i];\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n    if (/[a-zA-Z0-9_-]/.test(ch) || ch.charCodeAt(0) >= 0x80) continue;\n    break;\n  }\n  return i;\n}\n\nfunction consumeBracket(s: string, openIndex: number): number {\n  let depth = 1;\n  let quote: \"'\" | '\"' | null = null;\n\n  for (let i = openIndex + 1; i < s.length; i++) {\n    const ch = s[i];\n    if (quote) {\n      if (ch === '\\\\') {\n        i += 1;\n        continue;\n      }\n      if (ch === quote) quote = null;\n      continue;\n    }\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n    if (ch === '[') depth += 1;\n    else if (ch === ']') {\n      depth -= 1;\n      if (depth === 0) return i;\n    }\n  }\n  return s.length - 1;\n}\n\nfunction consumeParenFunction(\n  s: string,\n  openParenIndex: number,\n): { content: string; endIndex: number } {\n  let depth = 1;\n  let quote: \"'\" | '\"' | null = null;\n\n  for (let i = openParenIndex + 1; i < s.length; i++) {\n    const ch = s[i];\n    if (quote) {\n      if (ch === '\\\\') {\n        i += 1;\n        continue;\n      }\n      if (ch === quote) quote = null;\n      continue;\n    }\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n    if (ch === '[') i = consumeBracket(s, i);\n    else if (ch === '(') depth += 1;\n    else if (ch === ')') {\n      depth -= 1;\n      if (depth === 0) return { content: s.slice(openParenIndex + 1, i), endIndex: i };\n    }\n  }\n  return { content: s.slice(openParenIndex + 1), endIndex: s.length - 1 };\n}\n\nfunction isCombinatorOrWhitespace(s: string, i: number): boolean {\n  const ch = s[i];\n  return /\\s/.test(ch) || ch === '>' || ch === '+' || ch === '~' || ch === '|';\n}\n\nfunction consumeWhitespaceAndCombinators(s: string, i: number): number {\n  let j = i;\n  while (j < s.length && /\\s/.test(s[j])) j++;\n  if (s[j] === '|' && s[j + 1] === '|') return j + 1;\n  if (s[j] === '>' || s[j] === '+' || s[j] === '~' || s[j] === '|') return j;\n  return j - 1;\n}\n\nfunction extractNthOfSelectorList(content: string): string | null {\n  let depthParen = 0;\n  let depthBrack = 0;\n  let quote: \"'\" | '\"' | null = null;\n\n  for (let i = 0; i < content.length; i++) {\n    const ch = content[i];\n\n    if (quote) {\n      if (ch === '\\\\') {\n        i += 1;\n        continue;\n      }\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      i += 1;\n      continue;\n    }\n\n    if (ch === '[') depthBrack += 1;\n    else if (ch === ']' && depthBrack > 0) depthBrack -= 1;\n    else if (ch === '(') depthParen += 1;\n    else if (ch === ')' && depthParen > 0) depthParen -= 1;\n\n    if (depthParen === 0 && depthBrack === 0) {\n      if (isOfTokenAt(content, i)) return content.slice(i + 2).trimStart();\n    }\n  }\n\n  return null;\n}\n\nfunction isOfTokenAt(s: string, i: number): boolean {\n  if (s[i] !== 'o' || s[i + 1] !== 'f') return false;\n  const prev = s[i - 1];\n  const next = s[i + 2];\n  const prevOk = prev === undefined || /\\s/.test(prev);\n  const nextOk = next === undefined || /\\s/.test(next);\n  return prevOk && nextOk;\n}\n\n// =============================================================================\n// Inherited properties\n// =============================================================================\n\nexport const INHERITED_PROPERTIES = new Set<string>([\n  // Color & appearance\n  'color',\n  'color-scheme',\n  'caret-color',\n  'accent-color',\n\n  // Typography / fonts\n  'font',\n  'font-family',\n  'font-feature-settings',\n  'font-kerning',\n  'font-language-override',\n  'font-optical-sizing',\n  'font-palette',\n  'font-size',\n  'font-size-adjust',\n  'font-stretch',\n  'font-style',\n  'font-synthesis',\n  'font-synthesis-small-caps',\n  'font-synthesis-style',\n  'font-synthesis-weight',\n  'font-variant',\n  'font-variant-alternates',\n  'font-variant-caps',\n  'font-variant-east-asian',\n  'font-variant-emoji',\n  'font-variant-ligatures',\n  'font-variant-numeric',\n  'font-variant-position',\n  'font-variation-settings',\n  'font-weight',\n  'letter-spacing',\n  'line-height',\n  'text-rendering',\n  'text-size-adjust',\n  'text-transform',\n  'text-indent',\n  'text-align',\n  'text-align-last',\n  'text-justify',\n  'text-shadow',\n  'text-emphasis-color',\n  'text-emphasis-position',\n  'text-emphasis-style',\n  'text-underline-position',\n  'tab-size',\n  'white-space',\n  'word-break',\n  'overflow-wrap',\n  'word-spacing',\n  'hyphens',\n  'line-break',\n\n  // Writing / bidi\n  'direction',\n  'unicode-bidi',\n  'writing-mode',\n  'text-orientation',\n  'text-combine-upright',\n\n  // Lists\n  'list-style',\n  'list-style-image',\n  'list-style-position',\n  'list-style-type',\n\n  // Tables\n  'border-collapse',\n  'border-spacing',\n  'caption-side',\n  'empty-cells',\n\n  // Visibility / interaction\n  'cursor',\n  'visibility',\n  'pointer-events',\n  'user-select',\n\n  // Quotes & pagination\n  'quotes',\n  'orphans',\n  'widows',\n\n  // SVG\n  'fill',\n  'fill-opacity',\n  'fill-rule',\n  'stroke',\n  'stroke-width',\n  'stroke-linecap',\n  'stroke-linejoin',\n  'stroke-miterlimit',\n  'stroke-dasharray',\n  'stroke-dashoffset',\n  'stroke-opacity',\n  'paint-order',\n  'shape-rendering',\n  'image-rendering',\n  'color-interpolation',\n  'color-interpolation-filters',\n  'color-rendering',\n  'dominant-baseline',\n  'alignment-baseline',\n  'baseline-shift',\n  'text-anchor',\n  'stop-color',\n  'stop-opacity',\n  'flood-color',\n  'flood-opacity',\n  'lighting-color',\n  'marker',\n  'marker-start',\n  'marker-mid',\n  'marker-end',\n]);\n\nexport function isInheritableProperty(property: string): boolean {\n  const p = String(property || '').trim();\n  if (!p) return false;\n  if (p.startsWith('--')) return true;\n  return INHERITED_PROPERTIES.has(p.toLowerCase());\n}\n\n// =============================================================================\n// Shorthand expansion\n// =============================================================================\n\nexport const SHORTHAND_TO_LONGHANDS: Record<string, readonly string[]> = {\n  // Spacing\n  margin: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],\n  padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],\n  inset: ['top', 'right', 'bottom', 'left'],\n\n  // Border\n  border: [\n    'border-top-width',\n    'border-right-width',\n    'border-bottom-width',\n    'border-left-width',\n    'border-top-style',\n    'border-right-style',\n    'border-bottom-style',\n    'border-left-style',\n    'border-top-color',\n    'border-right-color',\n    'border-bottom-color',\n    'border-left-color',\n  ],\n  'border-width': [\n    'border-top-width',\n    'border-right-width',\n    'border-bottom-width',\n    'border-left-width',\n  ],\n  'border-style': [\n    'border-top-style',\n    'border-right-style',\n    'border-bottom-style',\n    'border-left-style',\n  ],\n  'border-color': [\n    'border-top-color',\n    'border-right-color',\n    'border-bottom-color',\n    'border-left-color',\n  ],\n\n  'border-top': ['border-top-width', 'border-top-style', 'border-top-color'],\n  'border-right': ['border-right-width', 'border-right-style', 'border-right-color'],\n  'border-bottom': ['border-bottom-width', 'border-bottom-style', 'border-bottom-color'],\n  'border-left': ['border-left-width', 'border-left-style', 'border-left-color'],\n\n  'border-radius': [\n    'border-top-left-radius',\n    'border-top-right-radius',\n    'border-bottom-right-radius',\n    'border-bottom-left-radius',\n  ],\n\n  outline: ['outline-color', 'outline-style', 'outline-width'],\n\n  // Background\n  background: [\n    'background-attachment',\n    'background-clip',\n    'background-color',\n    'background-image',\n    'background-origin',\n    'background-position',\n    'background-repeat',\n    'background-size',\n  ],\n\n  // Font\n  font: [\n    'font-style',\n    'font-variant',\n    'font-weight',\n    'font-stretch',\n    'font-size',\n    'line-height',\n    'font-family',\n  ],\n\n  // Flexbox\n  flex: ['flex-grow', 'flex-shrink', 'flex-basis'],\n  'flex-flow': ['flex-direction', 'flex-wrap'],\n\n  // Alignment\n  'place-content': ['align-content', 'justify-content'],\n  'place-items': ['align-items', 'justify-items'],\n  'place-self': ['align-self', 'justify-self'],\n\n  // Gaps\n  gap: ['row-gap', 'column-gap'],\n  'grid-gap': ['row-gap', 'column-gap'],\n\n  // Overflow\n  overflow: ['overflow-x', 'overflow-y'],\n\n  // Grid\n  'grid-area': ['grid-row-start', 'grid-column-start', 'grid-row-end', 'grid-column-end'],\n  'grid-row': ['grid-row-start', 'grid-row-end'],\n  'grid-column': ['grid-column-start', 'grid-column-end'],\n  'grid-template': ['grid-template-rows', 'grid-template-columns', 'grid-template-areas'],\n\n  // Text\n  'text-emphasis': ['text-emphasis-style', 'text-emphasis-color'],\n  'text-decoration': [\n    'text-decoration-line',\n    'text-decoration-style',\n    'text-decoration-color',\n    'text-decoration-thickness',\n  ],\n\n  // Animations / transitions\n  transition: [\n    'transition-property',\n    'transition-duration',\n    'transition-timing-function',\n    'transition-delay',\n  ],\n  animation: [\n    'animation-name',\n    'animation-duration',\n    'animation-timing-function',\n    'animation-delay',\n    'animation-iteration-count',\n    'animation-direction',\n    'animation-fill-mode',\n    'animation-play-state',\n  ],\n\n  // Multi-column\n  columns: ['column-width', 'column-count'],\n  'column-rule': ['column-rule-width', 'column-rule-style', 'column-rule-color'],\n\n  // Lists\n  'list-style': ['list-style-position', 'list-style-image', 'list-style-type'],\n};\n\nexport function expandToLonghands(property: string): readonly string[] {\n  const raw = String(property || '').trim();\n  if (!raw) return [];\n  if (raw.startsWith('--')) return [raw];\n  const p = raw.toLowerCase();\n  return SHORTHAND_TO_LONGHANDS[p] ?? [p];\n}\n\nfunction normalizePropertyName(property: string): string {\n  const raw = String(property || '').trim();\n  if (!raw) return '';\n  if (raw.startsWith('--')) return raw;\n  return raw.toLowerCase();\n}\n\n// =============================================================================\n// Cascade / override\n// =============================================================================\n\nfunction compareSourceOrder(\n  a: readonly [number, number, number],\n  b: readonly [number, number, number],\n): number {\n  if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1;\n  if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1;\n  if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1;\n  return 0;\n}\n\nfunction compareCascade(a: DeclCandidate, b: DeclCandidate): number {\n  if (a.important !== b.important) return a.important ? 1 : -1;\n  const spec = compareSpecificity(a.specificity, b.specificity);\n  if (spec !== 0) return spec;\n  return compareSourceOrder(a.sourceOrder, b.sourceOrder);\n}\n\nfunction computeOverrides(candidates: readonly DeclCandidate[]): {\n  winners: Map<string, DeclCandidate>;\n  declStatus: Map<string, DeclStatus>;\n} {\n  const winners = new Map<string, DeclCandidate>();\n\n  for (const cand of candidates) {\n    for (const longhand of cand.affects) {\n      const cur = winners.get(longhand);\n      if (!cur || compareCascade(cand, cur) > 0) winners.set(longhand, cand);\n    }\n  }\n\n  const declStatus = new Map<string, DeclStatus>();\n  for (const cand of candidates) declStatus.set(cand.id, 'overridden');\n  for (const [, winner] of winners) declStatus.set(winner.id, 'active');\n\n  return { winners, declStatus };\n}\n\n// =============================================================================\n// CSSOM Rule Index\n// =============================================================================\n\nconst CONTAINER_RULE = (globalThis as unknown as { CSSRule?: { CONTAINER_RULE?: number } }).CSSRule\n  ?.CONTAINER_RULE;\nconst SCOPE_RULE = (globalThis as unknown as { CSSRule?: { SCOPE_RULE?: number } }).CSSRule\n  ?.SCOPE_RULE;\n\nfunction isSheetApplicable(sheet: CSSStyleSheet): boolean {\n  if ((sheet as { disabled?: boolean }).disabled) return false;\n\n  try {\n    const mediaText = sheet.media?.mediaText?.trim() ?? '';\n    if (!mediaText || mediaText.toLowerCase() === 'all') return true;\n    return window.matchMedia(mediaText).matches;\n  } catch {\n    return true;\n  }\n}\n\nfunction describeStyleSheet(sheet: CSSStyleSheet, fallbackIndex: number): CssRuleSource {\n  const href = typeof sheet.href === 'string' ? sheet.href : undefined;\n\n  if (href) {\n    const file = href.split('/').pop()?.split('?')[0] ?? href;\n    return { url: href, label: file };\n  }\n\n  const ownerNode = sheet.ownerNode as Node | null | undefined;\n  if (ownerNode && ownerNode.nodeType === Node.ELEMENT_NODE) {\n    const el = ownerNode as Element;\n    if (el.tagName === 'STYLE') return { label: `<style #${fallbackIndex}>` };\n    if (el.tagName === 'LINK') return { label: `<link #${fallbackIndex}>` };\n  }\n\n  return { label: `<constructed #${fallbackIndex}>` };\n}\n\nfunction safeReadCssRules(sheet: CSSStyleSheet): CSSRuleList | null {\n  try {\n    return sheet.cssRules;\n  } catch {\n    return null;\n  }\n}\n\nfunction evalMediaRule(rule: CSSMediaRule, warnings: string[]): boolean {\n  try {\n    const mediaText = rule.media?.mediaText?.trim() ?? '';\n    if (!mediaText || mediaText.toLowerCase() === 'all') return true;\n    return window.matchMedia(mediaText).matches;\n  } catch (e) {\n    warnings.push(`Failed to evaluate @media rule: ${String(e)}`);\n    return false;\n  }\n}\n\nfunction evalSupportsRule(rule: CSSSupportsRule, warnings: string[]): boolean {\n  try {\n    const cond = rule.conditionText?.trim() ?? '';\n    if (!cond) return true;\n    if (typeof CSS?.supports !== 'function') return true;\n    return CSS.supports(cond);\n  } catch (e) {\n    warnings.push(`Failed to evaluate @supports rule: ${String(e)}`);\n    return false;\n  }\n}\n\nfunction createRuleIndexForRoot(root: Document | ShadowRoot, rootId: number): RuleIndex {\n  const warnings: string[] = [];\n  const flatRules: FlatStyleRule[] = [];\n  let rulesScanned = 0;\n\n  const docOrShadow = root as DocumentOrShadowRoot;\n  const styleSheets: CSSStyleSheet[] = [];\n\n  try {\n    for (const s of Array.from(docOrShadow.styleSheets ?? [])) {\n      if (s && s instanceof CSSStyleSheet) styleSheets.push(s);\n    }\n  } catch {\n    // ignore\n  }\n\n  try {\n    const adopted = Array.from(docOrShadow.adoptedStyleSheets ?? []) as CSSStyleSheet[];\n    for (const s of adopted) if (s && s instanceof CSSStyleSheet) styleSheets.push(s);\n  } catch {\n    // ignore\n  }\n\n  let order = 0;\n\n  function walkRuleList(\n    list: CSSRuleList,\n    ctx: {\n      sheetIndex: number;\n      sourceForRules: CssRuleSource;\n      topSheet: CSSStyleSheet;\n      stack: Set<CSSStyleSheet>;\n    },\n  ): void {\n    for (const rule of Array.from(list)) {\n      rulesScanned += 1;\n\n      if (CONTAINER_RULE && rule.type === CONTAINER_RULE) {\n        warnings.push('Skipped @container rules (not evaluated in CSSOM collector)');\n        continue;\n      }\n\n      if (SCOPE_RULE && rule.type === SCOPE_RULE) {\n        warnings.push('Skipped @scope rules (not evaluated in CSSOM collector)');\n        continue;\n      }\n\n      if (rule.type === CSSRule.IMPORT_RULE) {\n        const importRule = rule as CSSImportRule;\n\n        try {\n          const mediaText = importRule.media?.mediaText?.trim() ?? '';\n          if (\n            mediaText &&\n            mediaText.toLowerCase() !== 'all' &&\n            !window.matchMedia(mediaText).matches\n          ) {\n            continue;\n          }\n        } catch {\n          // ignore\n        }\n\n        const imported = importRule.styleSheet;\n        if (imported) {\n          // Check for cycle BEFORE adding to stack\n          if (ctx.stack.has(imported)) {\n            const src = describeStyleSheet(imported, ctx.sheetIndex);\n            warnings.push(`Detected @import cycle, skipping: ${src.url ?? src.label}`);\n            continue;\n          }\n\n          // Add to stack, process, then remove\n          ctx.stack.add(imported);\n          try {\n            // Recursively walk the imported stylesheet\n            if (!isSheetApplicable(imported)) {\n              continue;\n            }\n\n            const cssRules = safeReadCssRules(imported);\n            const src = describeStyleSheet(imported, ctx.sheetIndex);\n\n            if (!cssRules) {\n              warnings.push(\n                `Skipped @import stylesheet (cannot access cssRules, likely cross-origin): ${src.url ?? src.label}`,\n              );\n              continue;\n            }\n\n            walkRuleList(cssRules, {\n              sheetIndex: ctx.sheetIndex,\n              sourceForRules: src,\n              topSheet: imported,\n              stack: ctx.stack,\n            });\n          } finally {\n            ctx.stack.delete(imported);\n          }\n        }\n        continue;\n      }\n\n      if (rule.type === CSSRule.MEDIA_RULE) {\n        if (evalMediaRule(rule as CSSMediaRule, warnings)) {\n          walkRuleList((rule as CSSMediaRule).cssRules, ctx);\n        }\n        continue;\n      }\n\n      if (rule.type === CSSRule.SUPPORTS_RULE) {\n        if (evalSupportsRule(rule as CSSSupportsRule, warnings)) {\n          walkRuleList((rule as CSSSupportsRule).cssRules, ctx);\n        }\n        continue;\n      }\n\n      if (rule.type === CSSRule.STYLE_RULE) {\n        const styleRule = rule as CSSStyleRule;\n        flatRules.push({\n          sheetIndex: ctx.sheetIndex,\n          order: order++,\n          selectorText: styleRule.selectorText ?? '',\n          style: styleRule.style,\n          source: ctx.sourceForRules,\n        });\n        continue;\n      }\n\n      // Best-effort: traverse grouping rules we don't explicitly model (e.g. @layer blocks).\n      const anyRule = rule as { cssRules?: CSSRuleList };\n      if (anyRule.cssRules && typeof anyRule.cssRules.length === 'number') {\n        try {\n          walkRuleList(anyRule.cssRules, ctx);\n        } catch {\n          // ignore\n        }\n      }\n    }\n  }\n\n  for (let sheetIndex = 0; sheetIndex < styleSheets.length; sheetIndex++) {\n    const sheet = styleSheets[sheetIndex]!;\n    if (!isSheetApplicable(sheet)) continue;\n\n    const sheetSource = describeStyleSheet(sheet, sheetIndex);\n    const cssRules = safeReadCssRules(sheet);\n    if (!cssRules) {\n      warnings.push(\n        `Skipped stylesheet (cannot access cssRules, likely cross-origin): ${sheetSource.url ?? sheetSource.label}`,\n      );\n      continue;\n    }\n\n    // Create a fresh recursion stack for each top-level stylesheet\n    const recursionStack = new Set<CSSStyleSheet>();\n    recursionStack.add(sheet); // Add self to prevent self-import cycles\n    walkRuleList(cssRules, {\n      sheetIndex,\n      sourceForRules: sheetSource,\n      topSheet: sheet,\n      stack: recursionStack,\n    });\n  }\n\n  return {\n    root,\n    rootId,\n    flatRules,\n    warnings,\n    stats: { styleSheets: styleSheets.length, rulesScanned },\n  };\n}\n\n// =============================================================================\n// Per-element collection\n// =============================================================================\n\nfunction readStyleDecls(style: CSSStyleDeclaration): Array<{\n  property: string;\n  value: string;\n  important: boolean;\n  declIndex: number;\n}> {\n  const out: Array<{ property: string; value: string; important: boolean; declIndex: number }> = [];\n\n  const len = Number(style?.length ?? 0);\n  for (let i = 0; i < len; i++) {\n    let prop = '';\n    try {\n      prop = style.item(i);\n    } catch {\n      prop = '';\n    }\n    prop = normalizePropertyName(prop);\n    if (!prop) continue;\n\n    let value = '';\n    let important = false;\n    try {\n      value = style.getPropertyValue(prop) ?? '';\n      important = String(style.getPropertyPriority(prop) ?? '') === 'important';\n    } catch {\n      value = '';\n      important = false;\n    }\n\n    out.push({ property: prop, value: String(value).trim(), important, declIndex: i });\n  }\n\n  return out;\n}\n\nfunction canReadInlineStyle(element: Element): element is Element & { style: CSSStyleDeclaration } {\n  const anyEl = element as { style?: CSSStyleDeclaration };\n  return (\n    !!anyEl.style &&\n    typeof anyEl.style.getPropertyValue === 'function' &&\n    typeof anyEl.style.getPropertyPriority === 'function'\n  );\n}\n\nfunction formatElementLabel(element: Element, maxClasses = 2): string {\n  const tag = element.tagName.toLowerCase();\n  const id = (element as HTMLElement).id?.trim();\n  if (id) return `${tag}#${id}`;\n\n  const classes = Array.from(element.classList ?? [])\n    .slice(0, maxClasses)\n    .filter(Boolean);\n  if (classes.length) return `${tag}.${classes.join('.')}`;\n\n  return tag;\n}\n\nfunction getElementRoot(element: Element): Document | ShadowRoot {\n  try {\n    const root = element.getRootNode?.();\n    return root instanceof ShadowRoot ? root : (element.ownerDocument ?? document);\n  } catch {\n    return element.ownerDocument ?? document;\n  }\n}\n\nfunction getParentElementOrHost(element: Element): Element | null {\n  if (element.parentElement) return element.parentElement;\n\n  try {\n    const root = element.getRootNode?.();\n    if (root instanceof ShadowRoot) return root.host;\n  } catch {\n    // ignore\n  }\n\n  return null;\n}\n\nfunction collectForElement(\n  element: Element,\n  index: RuleIndex,\n  elementId: number,\n  options: CollectElementOptions,\n): CollectedElementRules {\n  const warnings: string[] = [];\n  const matchedRules: CssRuleView[] = [];\n  const candidates: DeclCandidate[] = [];\n\n  const rootType: 'document' | 'shadow' = index.root instanceof ShadowRoot ? 'shadow' : 'document';\n\n  let inlineRule: CssRuleView | null = null;\n\n  if (options.includeInline && canReadInlineStyle(element)) {\n    const declsRaw = readStyleDecls(element.style);\n    const decls: CssDeclView[] = [];\n\n    for (const d of declsRaw) {\n      const affects = expandToLonghands(d.property);\n      if (!options.declFilter({ property: d.property, affects })) continue;\n\n      const declId = `inline:${elementId}:${d.declIndex}`;\n\n      decls.push({\n        id: declId,\n        name: d.property,\n        value: d.value,\n        important: d.important,\n        affects,\n        status: 'overridden',\n      });\n\n      candidates.push({\n        id: declId,\n        important: d.important,\n        specificity: [1, 0, 0, 0] as const,\n        sourceOrder: [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, d.declIndex],\n        property: d.property,\n        value: d.value,\n        affects,\n        ownerRuleId: `inline:${elementId}`,\n        ownerElementId: elementId,\n      });\n    }\n\n    inlineRule = {\n      id: `inline:${elementId}`,\n      origin: 'inline',\n      selector: 'element.style',\n      matchedSelector: 'element.style',\n      specificity: [1, 0, 0, 0] as const,\n      source: { label: 'element.style' },\n      order: Number.MAX_SAFE_INTEGER,\n      decls,\n    };\n  }\n\n  for (const flat of index.flatRules) {\n    const match = computeMatchedRuleSpecificity(element, flat.selectorText);\n    if (!match) continue;\n\n    const declsRaw = readStyleDecls(flat.style);\n    const decls: CssDeclView[] = [];\n    const ruleId = `rule:${index.rootId}:${flat.sheetIndex}:${flat.order}`;\n\n    for (const d of declsRaw) {\n      const affects = expandToLonghands(d.property);\n      if (!options.declFilter({ property: d.property, affects })) continue;\n\n      const declId = `${ruleId}:${d.declIndex}`;\n\n      decls.push({\n        id: declId,\n        name: d.property,\n        value: d.value,\n        important: d.important,\n        affects,\n        status: 'overridden',\n      });\n\n      candidates.push({\n        id: declId,\n        important: d.important,\n        specificity: match.specificity,\n        sourceOrder: [flat.sheetIndex, flat.order, d.declIndex],\n        property: d.property,\n        value: d.value,\n        affects,\n        ownerRuleId: ruleId,\n        ownerElementId: elementId,\n      });\n    }\n\n    if (decls.length === 0) continue;\n\n    matchedRules.push({\n      id: ruleId,\n      origin: 'rule',\n      selector: flat.selectorText,\n      matchedSelector: match.matchedSelector,\n      specificity: match.specificity,\n      source: flat.source,\n      order: flat.order,\n      decls,\n    });\n  }\n\n  // Sort matched rules in a DevTools-like way (best-effort).\n  matchedRules.sort((a, b) => {\n    const sa = a.specificity ?? ZERO_SPEC;\n    const sb = b.specificity ?? ZERO_SPEC;\n    const spec = compareSpecificity(sb, sa); // desc\n    if (spec !== 0) return spec;\n    return b.order - a.order; // later first\n  });\n\n  return {\n    element,\n    elementId,\n    root: index.root,\n    rootType,\n    inlineRule,\n    matchedRules,\n    candidates,\n    warnings,\n    stats: { matchedRules: matchedRules.length },\n  };\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Collect matched rules for ONE element (no inheritance), plus DeclCandidate[] used for cascade.\n */\nexport function collectMatchedRules(element: Element): {\n  inlineRule: CssRuleView | null;\n  matchedRules: CssRuleView[];\n  candidates: DeclCandidate[];\n  warnings: string[];\n  stats: { styleSheets: number; rulesScanned: number; matchedRules: number };\n} {\n  const root = getElementRoot(element);\n\n  const index = createRuleIndexForRoot(root, 1);\n  const res = collectForElement(element, index, 1, {\n    includeInline: true,\n    declFilter: () => true,\n  });\n\n  return {\n    inlineRule: res.inlineRule,\n    matchedRules: res.matchedRules,\n    candidates: res.candidates,\n    warnings: [...index.warnings, ...res.warnings],\n    stats: {\n      styleSheets: index.stats.styleSheets,\n      rulesScanned: index.stats.rulesScanned,\n      matchedRules: res.stats.matchedRules,\n    },\n  };\n}\n\n/**\n * Collect full snapshot: inline + matched + inherited chain (ancestor traversal).\n */\nexport function collectCssPanelSnapshot(\n  element: Element,\n  options: { maxInheritanceDepth?: number } = {},\n): CssPanelSnapshot {\n  const warnings: string[] = [];\n  const maxDepth = Number.isFinite(options.maxInheritanceDepth)\n    ? Math.max(0, options.maxInheritanceDepth!)\n    : 10;\n\n  const elementIds = new WeakMap<Element, number>();\n  let nextElementId = 1;\n  const rootIds = new WeakMap<Document | ShadowRoot, number>();\n  let nextRootId = 1;\n  // Use WeakMap for caching, but also maintain a list for stats aggregation\n  const indexCache = new WeakMap<Document | ShadowRoot, RuleIndex>();\n  const indexList: RuleIndex[] = [];\n\n  function getElementId(el: Element): number {\n    const existing = elementIds.get(el);\n    if (existing) return existing;\n    const id = nextElementId++;\n    elementIds.set(el, id);\n    return id;\n  }\n\n  function getIndex(root: Document | ShadowRoot): RuleIndex {\n    const cached = indexCache.get(root);\n    if (cached) return cached;\n    const rootId =\n      rootIds.get(root) ??\n      (() => {\n        const v = nextRootId++;\n        rootIds.set(root, v);\n        return v;\n      })();\n    const idx = createRuleIndexForRoot(root, rootId);\n    indexCache.set(root, idx);\n    indexList.push(idx); // Also add to list for stats aggregation\n    return idx;\n  }\n\n  if (!element || !element.isConnected) {\n    return {\n      target: { label: formatElementLabel(element), root: 'document' },\n      warnings: ['Target element is not connected; snapshot may be incomplete.'],\n      stats: { roots: 0, styleSheets: 0, rulesScanned: 0, matchedRules: 0 },\n      sections: [],\n    };\n  }\n\n  // ---- Target (direct rules) ----\n  const targetRoot = getElementRoot(element);\n  const targetIndex = getIndex(targetRoot);\n  warnings.push(...targetIndex.warnings);\n\n  const targetCollected = collectForElement(element, targetIndex, getElementId(element), {\n    includeInline: true,\n    declFilter: () => true,\n  });\n\n  // Compute overrides on target itself.\n  const targetOverrides = computeOverrides(targetCollected.candidates);\n  const targetDeclStatus = targetOverrides.declStatus;\n\n  if (targetCollected.inlineRule) {\n    for (const d of targetCollected.inlineRule.decls) {\n      d.status = targetDeclStatus.get(d.id) ?? 'overridden';\n    }\n  }\n  for (const rule of targetCollected.matchedRules) {\n    for (const d of rule.decls) d.status = targetDeclStatus.get(d.id) ?? 'overridden';\n  }\n\n  // ---- Ancestor chain (inherited props only) ----\n  const ancestors: Element[] = [];\n  let cur: Element | null = getParentElementOrHost(element);\n  while (cur && ancestors.length < maxDepth) {\n    ancestors.push(cur);\n    cur = getParentElementOrHost(cur);\n  }\n\n  const inheritableLonghands = new Set<string>();\n\n  // Only consider inheritable longhands that appear in collected declarations (keeps work bounded).\n  for (const cand of targetCollected.candidates) {\n    for (const lh of cand.affects) if (isInheritableProperty(lh)) inheritableLonghands.add(lh);\n  }\n\n  const ancestorData: Array<{\n    ancestor: Element;\n    label: string;\n    collected: CollectedElementRules;\n    overrides: ReturnType<typeof computeOverrides>;\n  }> = [];\n\n  for (const a of ancestors) {\n    const aRoot = getElementRoot(a);\n    const aIndex = getIndex(aRoot);\n    warnings.push(...aIndex.warnings);\n\n    const aCollected = collectForElement(a, aIndex, getElementId(a), {\n      includeInline: true,\n      declFilter: ({ affects }) => affects.some(isInheritableProperty),\n    });\n\n    // Filter candidates to inheritable longhands only (affects subset).\n    const filteredCandidates: DeclCandidate[] = [];\n\n    for (const cand of aCollected.candidates) {\n      const affects = cand.affects.filter(isInheritableProperty);\n      if (affects.length === 0) continue;\n      const next: DeclCandidate = { ...cand, affects };\n      filteredCandidates.push(next);\n      for (const lh of affects) inheritableLonghands.add(lh);\n    }\n\n    const aOverrides = computeOverrides(filteredCandidates);\n\n    // Keep only inheritable decls in rule views (already filtered by declFilter), but ensure affects trimmed.\n    if (aCollected.inlineRule) {\n      aCollected.inlineRule.decls = aCollected.inlineRule.decls\n        .map((d) => ({ ...d, affects: d.affects.filter(isInheritableProperty) }))\n        .filter((d) => d.affects.length > 0);\n      if (aCollected.inlineRule.decls.length === 0) aCollected.inlineRule = null;\n    }\n    aCollected.matchedRules = aCollected.matchedRules\n      .map((r) => ({\n        ...r,\n        decls: r.decls\n          .map((d) => ({ ...d, affects: d.affects.filter(isInheritableProperty) }))\n          .filter((d) => d.affects.length > 0),\n      }))\n      .filter((r) => r.decls.length > 0);\n\n    if (!aCollected.inlineRule && aCollected.matchedRules.length === 0) continue;\n\n    ancestorData.push({\n      ancestor: a,\n      label: formatElementLabel(a),\n      collected: { ...aCollected, candidates: filteredCandidates },\n      overrides: aOverrides,\n    });\n  }\n\n  // Determine which inherited declaration IDs actually provide the final inherited value for target.\n  const finalInheritedDeclIds = new Set<string>();\n\n  for (const longhand of inheritableLonghands) {\n    if (targetOverrides.winners.has(longhand)) continue;\n\n    for (const a of ancestorData) {\n      const win = a.overrides.winners.get(longhand);\n      if (win) {\n        finalInheritedDeclIds.add(win.id);\n        break;\n      }\n    }\n  }\n\n  // Apply inherited statuses: active only if it is the chosen inherited source for any longhand.\n  for (const a of ancestorData) {\n    if (a.collected.inlineRule) {\n      for (const d of a.collected.inlineRule.decls) {\n        d.status = finalInheritedDeclIds.has(d.id) ? 'active' : 'overridden';\n      }\n    }\n    for (const r of a.collected.matchedRules) {\n      for (const d of r.decls) d.status = finalInheritedDeclIds.has(d.id) ? 'active' : 'overridden';\n    }\n  }\n\n  // ---- Build sections ----\n  const sections: CssSectionView[] = [];\n\n  sections.push({\n    kind: 'inline',\n    title: 'element.style',\n    rules: targetCollected.inlineRule ? [targetCollected.inlineRule] : [],\n  });\n\n  sections.push({\n    kind: 'matched',\n    title: 'Matched CSS Rules',\n    rules: targetCollected.matchedRules,\n  });\n\n  for (const a of ancestorData) {\n    const rules: CssRuleView[] = [];\n    if (a.collected.inlineRule) rules.push(a.collected.inlineRule);\n    rules.push(...a.collected.matchedRules);\n\n    sections.push({\n      kind: 'inherited',\n      title: `Inherited from ${a.label}`,\n      inheritedFrom: { label: a.label },\n      rules,\n    });\n  }\n\n  // ---- Aggregate stats ----\n  let totalStyleSheets = 0;\n  let totalRulesScanned = 0;\n  const rootsSeen = new Set<number>();\n  for (const idx of indexList) {\n    rootsSeen.add(idx.rootId);\n    totalStyleSheets += idx.stats.styleSheets;\n    totalRulesScanned += idx.stats.rulesScanned;\n  }\n\n  const dedupWarnings = Array.from(new Set([...warnings, ...targetCollected.warnings]));\n\n  return {\n    target: {\n      label: formatElementLabel(element),\n      root: targetRoot instanceof ShadowRoot ? 'shadow' : 'document',\n    },\n    warnings: dedupWarnings,\n    stats: {\n      roots: rootsSeen.size,\n      styleSheets: totalStyleSheets,\n      rulesScanned: totalRulesScanned,\n      matchedRules: targetCollected.stats.matchedRules,\n    },\n    sections,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/debug-source.ts",
    "content": "/**\n * Debug Source Extraction (Shared Module)\n *\n * Extracts source file location from React/Vue component debug info.\n * Used by both locator.ts (for Transaction recording) and payload-builder.ts (for single Apply).\n *\n * Design goals:\n * - Best-effort extraction (never throws)\n * - Walk up DOM tree to find nearest component with debug info\n * - Support both React (_debugSource) and Vue (__file)\n */\n\nimport type { DebugSource } from '@/common/web-editor-types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Maximum depth to walk up the DOM tree for debug source */\nconst MAX_DOM_DEPTH = 15;\n\n/** Maximum depth to walk up React fiber tree */\nconst MAX_FIBER_DEPTH = 40;\n\n// =============================================================================\n// Helper Functions\n// =============================================================================\n\n/**\n * Safely access object as record\n */\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n  if (value && typeof value === 'object') {\n    return value as Record<string, unknown>;\n  }\n  return null;\n}\n\n/**\n * Read optional string value\n */\nfunction readString(value: unknown): string | undefined {\n  if (typeof value === 'string') {\n    const trimmed = value.trim();\n    return trimmed || undefined;\n  }\n  return undefined;\n}\n\n/**\n * Read optional number value\n */\nfunction readNumber(value: unknown): number | undefined {\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return value;\n  }\n  const parsed = Number.parseInt(String(value), 10);\n  return Number.isFinite(parsed) ? parsed : undefined;\n}\n\n/**\n * Read component name from function/object\n */\nfunction readComponentName(value: unknown): string | undefined {\n  if (!value) return undefined;\n\n  if (typeof value === 'function') {\n    const fn = value as { displayName?: unknown; name?: unknown };\n    return readString(fn.displayName) ?? readString(fn.name);\n  }\n\n  const rec = asRecord(value);\n  if (rec) {\n    return readString(rec.displayName) ?? readString(rec.name);\n  }\n\n  return undefined;\n}\n\n// =============================================================================\n// React Debug Source Extraction\n// =============================================================================\n\n/**\n * Extract debug source from React Fiber\n */\nfunction extractReactDebugSource(fiber: unknown): DebugSource | null {\n  let current = fiber;\n\n  for (let i = 0; i < MAX_FIBER_DEPTH && current; i++) {\n    const rec = asRecord(current);\n    if (!rec) break;\n\n    // Check _debugSource\n    const src = asRecord(rec._debugSource);\n    const file = readString(src?.fileName);\n    if (file) {\n      const componentName = readComponentName(rec.elementType) ?? readComponentName(rec.type);\n      return {\n        file,\n        line: readNumber(src?.lineNumber),\n        column: readNumber(src?.columnNumber),\n        componentName,\n      };\n    }\n\n    // Check owner's debug source\n    const owner = asRecord(rec._debugOwner);\n    const ownerSrc = asRecord(owner?._debugSource);\n    const ownerFile = readString(ownerSrc?.fileName);\n    if (ownerFile) {\n      const componentName = readComponentName(owner?.elementType) ?? readComponentName(owner?.type);\n      return {\n        file: ownerFile,\n        line: readNumber(ownerSrc?.lineNumber),\n        column: readNumber(ownerSrc?.columnNumber),\n        componentName,\n      };\n    }\n\n    current = rec.return;\n  }\n\n  return null;\n}\n\n/**\n * Find React debug source from element\n */\nexport function findReactDebugSource(element: Element): DebugSource | null {\n  try {\n    let node: Element | null = element;\n\n    for (let depth = 0; depth < MAX_DOM_DEPTH && node; depth++) {\n      const rec = node as unknown as Record<string, unknown>;\n\n      for (const key of Object.keys(rec)) {\n        if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {\n          const source = extractReactDebugSource(rec[key]);\n          if (source) return source;\n        }\n      }\n\n      node = node.parentElement;\n    }\n  } catch {\n    // Best-effort only\n  }\n\n  return null;\n}\n\n// =============================================================================\n// Vue Debug Source Extraction\n// =============================================================================\n\n/**\n * Parse Vue inspector location attribute value.\n * Format: \"src/components/Foo.vue:23:7\" or \"C:\\path\\file.vue:10:5\" (Windows)\n *\n * Uses trailing regex to safely handle Windows paths with drive letters.\n */\nfunction parseVInspector(value: unknown): DebugSource | null {\n  if (typeof value !== 'string') return null;\n  const raw = value.trim();\n  if (!raw) return null;\n\n  // Match only trailing :line or :line:column to avoid Windows drive letter issues\n  const match = raw.match(/:(\\d+)(?::(\\d+))?$/);\n  if (!match) {\n    // No line info, return file only\n    return { file: raw };\n  }\n\n  const file = raw.slice(0, match.index).trim();\n  if (!file) return null;\n\n  const line = Number.parseInt(match[1], 10);\n  const columnRaw = match[2] ? Number.parseInt(match[2], 10) : undefined;\n\n  return {\n    file,\n    line: Number.isFinite(line) && line > 0 ? line : undefined,\n    column:\n      columnRaw !== undefined && Number.isFinite(columnRaw) && columnRaw > 0\n        ? columnRaw\n        : undefined,\n  };\n}\n\n/**\n * Walk up DOM tree to find data-v-inspector attribute.\n * This attribute is injected by @vitejs/plugin-vue-inspector.\n */\nfunction findInspectorLocation(element: Element): DebugSource | null {\n  try {\n    let node: Element | null = element;\n    for (let depth = 0; depth < MAX_DOM_DEPTH && node; depth++) {\n      if (typeof node.getAttribute === 'function') {\n        const attr = node.getAttribute('data-v-inspector');\n        if (attr) {\n          const parsed = parseVInspector(attr);\n          if (parsed?.file) return parsed;\n        }\n      }\n      node = node.parentElement;\n    }\n  } catch {\n    // Best-effort extraction\n  }\n  return null;\n}\n\n/**\n * Find Vue debug source from element.\n * Priority: data-v-inspector (has line/column) > type.__file (file only)\n */\nexport function findVueDebugSource(element: Element): DebugSource | null {\n  try {\n    // Priority 1: data-v-inspector attribute (has precise line/column)\n    const inspector = findInspectorLocation(element);\n    if (inspector?.file) {\n      // Try to get component name from Vue instance\n      let componentName: string | undefined;\n      let node: Element | null = element;\n      for (let depth = 0; depth < MAX_DOM_DEPTH && node; depth++) {\n        const rec = node as unknown as Record<string, unknown>;\n        const inst = asRecord(rec.__vueParentComponent);\n        const typeRec = asRecord(inst?.type);\n        componentName = readString(typeRec?.name);\n        if (componentName) break;\n        node = node.parentElement;\n      }\n      return {\n        ...inspector,\n        componentName,\n      };\n    }\n\n    // Priority 2: type.__file (file only, no line/column)\n    let node: Element | null = element;\n    for (let depth = 0; depth < MAX_DOM_DEPTH && node; depth++) {\n      const rec = node as unknown as Record<string, unknown>;\n      const inst = asRecord(rec.__vueParentComponent);\n      const typeRec = asRecord(inst?.type);\n      const file = readString(typeRec?.__file);\n\n      if (file) {\n        return {\n          file,\n          componentName: readString(typeRec?.name),\n        };\n      }\n\n      node = node.parentElement;\n    }\n  } catch {\n    // Best-effort only\n  }\n\n  return null;\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Find debug source from element (tries React first, then Vue).\n * Returns null if no debug info is available.\n *\n * @param element - DOM element to extract debug source from\n * @returns Debug source with file path and optional line/column/component name\n */\nexport function findDebugSource(element: Element): DebugSource | null {\n  // Try React first\n  const react = findReactDebugSource(element);\n  if (react) return react;\n\n  // Try Vue\n  const vue = findVueDebugSource(element);\n  if (vue) return vue;\n\n  return null;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/design-tokens-service.ts",
    "content": "/**\n * Design Tokens Service (Phase 5.4)\n *\n * Central orchestrator for design token functionality.\n *\n * Responsibilities:\n * - Token index caching per root (Document/ShadowRoot)\n * - Cache invalidation on stylesheet changes\n * - Unified query interface for UI components\n * - Integration with TransactionManager for applying tokens\n *\n * Cache strategy:\n * - Root-level WeakMap cache (auto-GC when roots are removed)\n * - Optional TTL for handling adoptedStyleSheets changes (no native events)\n * - MutationObserver on document.head for stylesheet injection detection\n */\n\nimport { Disposer } from '../../utils/disposables';\nimport {\n  createTokenDetector,\n  type TokenDetector,\n  type TokenDetectorOptions,\n} from './token-detector';\nimport {\n  createTokenResolver,\n  type TokenResolver,\n  type TokenResolverOptions,\n} from './token-resolver';\nimport type {\n  ContextToken,\n  CssVarName,\n  CssVarReference,\n  DesignToken,\n  RootCacheKey,\n  RootType,\n  TokenDeclaration,\n  TokenInvalidationEvent,\n  TokenInvalidationReason,\n  TokenIndex,\n  TokenQueryResult,\n  TokenResolution,\n  TokenResolvedForProperty,\n  Unsubscribe,\n} from './types';\nimport type { TransactionManager } from '../transaction-manager';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for getRootTokens query */\nexport interface GetRootTokensOptions {\n  /** Sort tokens alphabetically by name (default: true) */\n  sortByName?: boolean;\n}\n\n/** Options for getContextTokens query */\nexport interface GetContextTokensOptions {\n  /** Include tokens from inline styles on ancestors (default: true) */\n  includeInlineAncestors?: boolean;\n  /** Maximum ancestor depth for inline scanning */\n  inlineMaxDepth?: number;\n  /** Sort tokens alphabetically by name (default: true) */\n  sortByName?: boolean;\n}\n\n/** Options for creating the design tokens service */\nexport interface DesignTokensServiceOptions {\n  /**\n   * Cache TTL in milliseconds. When expired, index is recomputed.\n   * Set to 0 to disable TTL (default).\n   * @default 0\n   */\n  cacheMaxAgeMs?: number;\n\n  /**\n   * Watch document.head for stylesheet changes.\n   * @default true\n   */\n  observeHead?: boolean;\n\n  /**\n   * Watch ShadowRoot for changes.\n   * @default false (performance consideration)\n   */\n  observeShadowRoots?: boolean;\n\n  /**\n   * Default max depth for inline ancestor scanning.\n   * @default 8\n   */\n  maxInlineDepth?: number;\n\n  /** Time source override (for testing) */\n  now?: () => number;\n\n  /** Injected detector (for testing) */\n  detector?: TokenDetector;\n\n  /** Injected resolver (for testing) */\n  resolver?: TokenResolver;\n\n  /** Options for default detector */\n  detectorOptions?: TokenDetectorOptions;\n\n  /** Options for default resolver */\n  resolverOptions?: TokenResolverOptions;\n}\n\n/** Design tokens service public interface */\nexport interface DesignTokensService {\n  // --- Query Methods ---\n\n  /**\n   * Get all tokens declared in a root's stylesheets.\n   * Results are cached per root.\n   */\n  getRootTokens(root: RootCacheKey, options?: GetRootTokensOptions): TokenQueryResult<DesignToken>;\n\n  /**\n   * Get tokens available in an element's context.\n   * Only includes tokens that resolve to a value.\n   */\n  getContextTokens(\n    element: Element,\n    options?: GetContextTokensOptions,\n  ): TokenQueryResult<ContextToken>;\n\n  // --- Resolution Methods ---\n\n  /** Resolve a token's value in an element's context */\n  resolveToken(element: Element, name: CssVarName): TokenResolution;\n\n  /** Build CSS value for applying a token to a property */\n  resolveTokenForProperty(\n    element: Element,\n    name: CssVarName,\n    cssProperty: string,\n    options?: { fallback?: string; preview?: boolean },\n  ): TokenResolvedForProperty;\n\n  // --- Utility Methods ---\n\n  /** Format a CSS var() expression */\n  formatCssVar(name: CssVarName, fallback?: string): string;\n\n  /** Parse a var() expression */\n  parseCssVar(value: string): CssVarReference | null;\n\n  /** Extract var() references from a CSS value */\n  extractCssVarNames(value: string): CssVarName[];\n\n  // --- Cache Management ---\n\n  /** Manually invalidate a root's cache */\n  invalidateRoot(root: RootCacheKey, reason?: TokenInvalidationReason): void;\n\n  /** Subscribe to cache invalidation events */\n  onInvalidation(handler: (event: TokenInvalidationEvent) => void): Unsubscribe;\n\n  // --- TransactionManager Integration ---\n\n  /**\n   * Apply a token to an element's style via TransactionManager.\n   * Convenience method that formats var() and calls applyStyle.\n   */\n  applyTokenToStyle(\n    transactionManager: TransactionManager,\n    target: Element,\n    cssProperty: string,\n    tokenName: CssVarName,\n    options?: { fallback?: string; merge?: boolean },\n  ): ReturnType<TransactionManager['applyStyle']>;\n\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/** Cache entry for a root's token index */\ninterface RootCacheEntry {\n  index: TokenIndex;\n  collectedAt: number;\n}\n\n/**\n * Create a design tokens service instance.\n */\nexport function createDesignTokensService(\n  options: DesignTokensServiceOptions = {},\n): DesignTokensService {\n  const disposer = new Disposer();\n\n  // Configuration\n  const getNow = options.now ?? (() => performance.now());\n  const cacheMaxAgeMs = Math.max(0, Math.floor(options.cacheMaxAgeMs ?? 0));\n  const observeHead = options.observeHead !== false;\n  const observeShadowRoots = Boolean(options.observeShadowRoots);\n  const maxInlineDepth = Math.max(0, Math.floor(options.maxInlineDepth ?? 8));\n\n  // Dependencies\n  const detector = options.detector ?? createTokenDetector(options.detectorOptions);\n  const resolver = options.resolver ?? createTokenResolver(options.resolverOptions);\n\n  // State\n  const rootCache = new WeakMap<RootCacheKey, RootCacheEntry>();\n  const observedRoots = new WeakSet<RootCacheKey>();\n  const invalidationListeners = new Set<(event: TokenInvalidationEvent) => void>();\n\n  // ===========================================================================\n  // Helpers\n  // ===========================================================================\n\n  function getRootType(root: RootCacheKey): RootType {\n    return root instanceof ShadowRoot ? 'shadow' : 'document';\n  }\n\n  function emitInvalidation(root: RootCacheKey, reason: TokenInvalidationReason): void {\n    const event: TokenInvalidationEvent = {\n      root,\n      rootType: getRootType(root),\n      reason,\n      timestamp: getNow(),\n    };\n\n    for (const handler of invalidationListeners) {\n      try {\n        handler(event);\n      } catch {\n        // Best-effort notification\n      }\n    }\n  }\n\n  function getElementRoot(element: Element): RootCacheKey {\n    try {\n      const root = element.getRootNode?.();\n      if (root instanceof ShadowRoot) return root;\n      return element.ownerDocument ?? document;\n    } catch {\n      return element.ownerDocument ?? document;\n    }\n  }\n\n  // ===========================================================================\n  // Cache Management\n  // ===========================================================================\n\n  function ensureObserved(root: RootCacheKey): void {\n    if (observedRoots.has(root)) return;\n    observedRoots.add(root);\n\n    if (root instanceof ShadowRoot) {\n      if (!observeShadowRoots) return;\n\n      disposer.observeMutation(root, () => invalidateRoot(root, 'shadow_mutation'), {\n        childList: true,\n        subtree: true,\n        characterData: true,\n        attributes: true,\n      });\n      return;\n    }\n\n    // Document root - observe head for stylesheet changes\n    if (!observeHead) return;\n\n    const head = root.head;\n    if (!head) return;\n\n    disposer.observeMutation(head, () => invalidateRoot(root, 'head_mutation'), {\n      childList: true,\n      subtree: true,\n      characterData: true,\n      attributes: true,\n    });\n  }\n\n  function getOrCollectIndex(root: RootCacheKey): TokenIndex {\n    const cached = rootCache.get(root);\n\n    if (cached) {\n      // Check TTL\n      if (cacheMaxAgeMs > 0) {\n        const age = getNow() - cached.collectedAt;\n        if (age >= cacheMaxAgeMs) {\n          invalidateRoot(root, 'ttl');\n        } else {\n          return cached.index;\n        }\n      } else {\n        return cached.index;\n      }\n    }\n\n    // Collect fresh index\n    const index = detector.collectRootIndex(root);\n    rootCache.set(root, { index, collectedAt: getNow() });\n    return index;\n  }\n\n  function invalidateRoot(root: RootCacheKey, reason: TokenInvalidationReason = 'manual'): void {\n    rootCache.delete(root);\n    emitInvalidation(root, reason);\n  }\n\n  // ===========================================================================\n  // Token Model Conversion\n  // ===========================================================================\n\n  function toDesignToken(name: CssVarName, declarations: readonly TokenDeclaration[]): DesignToken {\n    // Sort by source order\n    const sorted = [...declarations].sort((a, b) => a.order - b.order);\n    return { name, kind: 'unknown', declarations: sorted };\n  }\n\n  // ===========================================================================\n  // Query Methods\n  // ===========================================================================\n\n  function getRootTokens(\n    root: RootCacheKey,\n    options: GetRootTokensOptions = {},\n  ): TokenQueryResult<DesignToken> {\n    ensureObserved(root);\n    const index = getOrCollectIndex(root);\n\n    const tokens: DesignToken[] = [];\n    for (const [name, declarations] of index.tokens) {\n      tokens.push(toDesignToken(name, declarations));\n    }\n\n    // Sort by name (default: true)\n    if (options.sortByName !== false) {\n      tokens.sort((a, b) => a.name.localeCompare(b.name));\n    }\n\n    return {\n      tokens,\n      warnings: index.warnings,\n      stats: index.stats,\n    };\n  }\n\n  function getContextTokens(\n    element: Element,\n    options: GetContextTokensOptions = {},\n  ): TokenQueryResult<ContextToken> {\n    const root = getElementRoot(element);\n    ensureObserved(root);\n    const index = getOrCollectIndex(root);\n\n    // Collect candidate token names\n    const candidateNames = new Set<CssVarName>();\n\n    // Add all tokens from stylesheets\n    for (const name of index.tokens.keys()) {\n      candidateNames.add(name);\n    }\n\n    // Add inline tokens from ancestors\n    const includeInline = options.includeInlineAncestors !== false;\n    if (includeInline) {\n      const inlineDepth = options.inlineMaxDepth ?? maxInlineDepth;\n      const inlineNames = detector.collectInlineTokenNames(element, {\n        maxDepth: inlineDepth,\n      });\n      for (const name of inlineNames) {\n        candidateNames.add(name);\n      }\n    }\n\n    // Filter to tokens that resolve in this context\n    // PERF: Get computed style once, then read multiple properties\n    const results: ContextToken[] = [];\n    let computedStyle: CSSStyleDeclaration | null = null;\n\n    try {\n      computedStyle = window.getComputedStyle(element);\n    } catch {\n      // Element may be disconnected or invalid\n    }\n\n    if (computedStyle) {\n      for (const name of candidateNames) {\n        let computedValue = '';\n        try {\n          computedValue = computedStyle.getPropertyValue(name).trim();\n        } catch {\n          // Ignore property read errors\n        }\n\n        // Only include tokens that have a value in this context\n        if (!computedValue) continue;\n\n        const declarations = index.tokens.get(name) ?? [];\n        results.push({\n          token: toDesignToken(name, declarations),\n          computedValue,\n        });\n      }\n    }\n\n    // Sort by name (default: true)\n    if (options.sortByName !== false) {\n      results.sort((a, b) => a.token.name.localeCompare(b.token.name));\n    }\n\n    return {\n      tokens: results,\n      warnings: index.warnings,\n      stats: index.stats,\n    };\n  }\n\n  // ===========================================================================\n  // Resolution Methods\n  // ===========================================================================\n\n  function resolveToken(element: Element, name: CssVarName): TokenResolution {\n    return resolver.resolveToken(element, name);\n  }\n\n  function resolveTokenForProperty(\n    element: Element,\n    name: CssVarName,\n    cssProperty: string,\n    options?: { fallback?: string; preview?: boolean },\n  ): TokenResolvedForProperty {\n    return resolver.resolveTokenForProperty(element, name, cssProperty, options);\n  }\n\n  // ===========================================================================\n  // Utility Passthrough\n  // ===========================================================================\n\n  function formatCssVar(name: CssVarName, fallback?: string): string {\n    return resolver.formatCssVar(name, fallback);\n  }\n\n  function parseCssVar(value: string): CssVarReference | null {\n    return resolver.parseCssVar(value);\n  }\n\n  function extractCssVarNames(value: string): CssVarName[] {\n    return resolver.extractCssVarNames(value);\n  }\n\n  // ===========================================================================\n  // TransactionManager Integration\n  // ===========================================================================\n\n  function applyTokenToStyle(\n    transactionManager: TransactionManager,\n    target: Element,\n    cssProperty: string,\n    tokenName: CssVarName,\n    options?: { fallback?: string; merge?: boolean },\n  ): ReturnType<TransactionManager['applyStyle']> {\n    const value = formatCssVar(tokenName, options?.fallback);\n    return transactionManager.applyStyle(target, cssProperty, value, {\n      merge: options?.merge,\n    });\n  }\n\n  // ===========================================================================\n  // Event Subscription\n  // ===========================================================================\n\n  function onInvalidation(handler: (event: TokenInvalidationEvent) => void): Unsubscribe {\n    invalidationListeners.add(handler);\n    return () => invalidationListeners.delete(handler);\n  }\n\n  // ===========================================================================\n  // Cleanup\n  // ===========================================================================\n\n  function dispose(): void {\n    invalidationListeners.clear();\n    disposer.dispose();\n  }\n\n  return {\n    getRootTokens,\n    getContextTokens,\n    resolveToken,\n    resolveTokenForProperty,\n    formatCssVar,\n    parseCssVar,\n    extractCssVarNames,\n    invalidateRoot,\n    onInvalidation,\n    applyTokenToStyle,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/index.ts",
    "content": "/**\n * Design Tokens Module (Phase 5.4)\n *\n * Runtime CSS custom property detection, resolution, and application.\n *\n * Usage:\n * ```typescript\n * import { createDesignTokensService } from './core/design-tokens';\n *\n * const service = createDesignTokensService();\n *\n * // Get available tokens for an element\n * const { tokens } = service.getContextTokens(element);\n *\n * // Apply a token to a style property\n * service.applyTokenToStyle(transactionManager, element, 'color', '--color-primary');\n *\n * // Cleanup\n * service.dispose();\n * ```\n */\n\n// Main service\nexport {\n  createDesignTokensService,\n  type DesignTokensService,\n  type DesignTokensServiceOptions,\n  type GetContextTokensOptions,\n  type GetRootTokensOptions,\n} from './design-tokens-service';\n\n// Detector\nexport {\n  createTokenDetector,\n  type TokenDetector,\n  type TokenDetectorOptions,\n} from './token-detector';\n\n// Resolver\nexport {\n  createTokenResolver,\n  type TokenResolver,\n  type TokenResolverOptions,\n  type ResolveForPropertyOptions,\n} from './token-resolver';\n\n// Types\nexport type {\n  // Core identifiers\n  CssVarName,\n  RootCacheKey,\n  RootType,\n  // Token classification\n  TokenKind,\n  // Declaration source\n  StyleSheetRef,\n  TokenDeclarationOrigin,\n  TokenDeclaration,\n  // Token model\n  DesignToken,\n  // Index and query\n  TokenIndexStats,\n  TokenIndex,\n  ContextToken,\n  TokenQueryResult,\n  // Resolution\n  CssVarReference,\n  TokenAvailability,\n  TokenResolutionMethod,\n  TokenResolution,\n  TokenResolvedForProperty,\n  // Cache invalidation\n  TokenInvalidationReason,\n  TokenInvalidationEvent,\n  Unsubscribe,\n} from './types';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/token-detector.ts",
    "content": "/**\n * Token Detector (Phase 5.4)\n *\n * Scans CSSOM to discover CSS custom property declarations.\n *\n * Key features:\n * - Traverses document and shadow root stylesheets\n * - Handles @import, @media, @supports rules\n * - Gracefully handles cross-origin stylesheet restrictions\n * - Collects inline style custom properties from element ancestors\n *\n * Performance considerations:\n * - Uses lazy evaluation (only scans when needed)\n * - Limits declarations per token to prevent pathological cases\n * - Skips disabled stylesheets and non-matching media queries\n */\n\nimport type {\n  CssVarName,\n  RootCacheKey,\n  RootType,\n  StyleSheetRef,\n  TokenDeclaration,\n  TokenIndex,\n  TokenIndexStats,\n} from './types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating a token detector */\nexport interface TokenDetectorOptions {\n  /**\n   * Maximum declarations stored per token name.\n   * Prevents memory issues on pages with many overrides.\n   * @default 50\n   */\n  maxDeclarationsPerToken?: number;\n\n  /**\n   * Maximum ancestor depth for inline style scanning.\n   * @default 8\n   */\n  maxInlineDepth?: number;\n}\n\n/** Token detector public interface */\nexport interface TokenDetector {\n  /**\n   * Scan a root's stylesheets for token declarations.\n   * This is the primary scanning method.\n   */\n  collectRootIndex(root: RootCacheKey): TokenIndex;\n\n  /**\n   * Discover token names from inline styles on element and ancestors.\n   * Useful for finding dynamically set tokens not in stylesheets.\n   */\n  collectInlineTokenNames(element: Element, options?: { maxDepth?: number }): Set<CssVarName>;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_MAX_DECLARATIONS_PER_TOKEN = 50;\nconst DEFAULT_MAX_INLINE_DEPTH = 8;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a token detector instance.\n */\nexport function createTokenDetector(options: TokenDetectorOptions = {}): TokenDetector {\n  const maxDeclarationsPerToken = Math.max(\n    1,\n    Math.floor(options.maxDeclarationsPerToken ?? DEFAULT_MAX_DECLARATIONS_PER_TOKEN),\n  );\n  const defaultInlineDepth = Math.max(\n    0,\n    Math.floor(options.maxInlineDepth ?? DEFAULT_MAX_INLINE_DEPTH),\n  );\n\n  // ===========================================================================\n  // CSSOM Traversal Helpers\n  // ===========================================================================\n\n  /**\n   * Safely read cssRules from a stylesheet.\n   * Returns null if access is blocked (e.g., cross-origin).\n   */\n  function safeReadCssRules(sheet: CSSStyleSheet): CSSRuleList | null {\n    try {\n      return sheet.cssRules;\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Check if a stylesheet is applicable (not disabled, media matches).\n   */\n  function isSheetApplicable(sheet: CSSStyleSheet): boolean {\n    if ((sheet as { disabled?: boolean }).disabled) return false;\n\n    try {\n      const mediaText = sheet.media?.mediaText?.trim() ?? '';\n      if (!mediaText || mediaText.toLowerCase() === 'all') return true;\n      return window.matchMedia(mediaText).matches;\n    } catch {\n      return true; // Assume applicable if we can't check\n    }\n  }\n\n  /**\n   * Create a human-readable reference to a stylesheet.\n   */\n  function describeStyleSheet(sheet: CSSStyleSheet, fallbackIndex: number): StyleSheetRef {\n    const href = typeof sheet.href === 'string' ? sheet.href : undefined;\n    if (href) {\n      const file = href.split('/').pop()?.split('?')[0] ?? href;\n      return { url: href, label: file };\n    }\n\n    const ownerNode = sheet.ownerNode as Node | null | undefined;\n    if (ownerNode?.nodeType === Node.ELEMENT_NODE) {\n      const el = ownerNode as Element;\n      const tag = el.tagName.toLowerCase();\n      return { label: `<${tag} #${fallbackIndex}>` };\n    }\n\n    return { label: `<constructed #${fallbackIndex}>` };\n  }\n\n  /**\n   * Evaluate @media rule condition.\n   */\n  function evalMediaRule(rule: CSSMediaRule, warnings: string[]): boolean {\n    try {\n      const mediaText = rule.media?.mediaText?.trim() ?? '';\n      if (!mediaText || mediaText.toLowerCase() === 'all') return true;\n      return window.matchMedia(mediaText).matches;\n    } catch (e) {\n      warnings.push(`Failed to evaluate @media: ${String(e)}`);\n      return false;\n    }\n  }\n\n  /**\n   * Evaluate @supports rule condition.\n   */\n  function evalSupportsRule(rule: CSSSupportsRule, warnings: string[]): boolean {\n    try {\n      const cond = rule.conditionText?.trim() ?? '';\n      if (!cond) return true;\n      if (typeof CSS?.supports !== 'function') return true;\n      return CSS.supports(cond);\n    } catch (e) {\n      warnings.push(`Failed to evaluate @supports: ${String(e)}`);\n      return false;\n    }\n  }\n\n  /**\n   * Extract custom property declarations from a style declaration.\n   */\n  function extractCustomProperties(\n    style: CSSStyleDeclaration,\n  ): Array<{ name: string; value: string; important: boolean }> {\n    const results: Array<{ name: string; value: string; important: boolean }> = [];\n    const len = Number(style?.length ?? 0);\n\n    for (let i = 0; i < len; i++) {\n      let name = '';\n      try {\n        name = String(style.item(i) ?? '').trim();\n      } catch {\n        continue;\n      }\n\n      if (!name.startsWith('--')) continue;\n\n      let value = '';\n      let important = false;\n      try {\n        value = String(style.getPropertyValue(name) ?? '').trim();\n        important = style.getPropertyPriority(name) === 'important';\n      } catch {\n        // Keep empty value\n      }\n\n      results.push({ name, value, important });\n    }\n\n    return results;\n  }\n\n  // ===========================================================================\n  // Main Collection Logic\n  // ===========================================================================\n\n  function collectRootIndex(root: RootCacheKey): TokenIndex {\n    const rootType: RootType = root instanceof ShadowRoot ? 'shadow' : 'document';\n    const warnings: string[] = [];\n    const tokens = new Map<CssVarName, TokenDeclaration[]>();\n\n    let rulesScanned = 0;\n    let totalDeclarations = 0;\n    let order = 0;\n\n    /**\n     * Add a declaration to the index.\n     */\n    function addDeclaration(decl: Omit<TokenDeclaration, 'order'>): void {\n      const list = tokens.get(decl.name) ?? [];\n      if (list.length >= maxDeclarationsPerToken) return;\n\n      list.push({ ...decl, order: order++ });\n      tokens.set(decl.name, list);\n      totalDeclarations++;\n    }\n\n    /**\n     * Recursively walk a rule list.\n     */\n    function walkRules(\n      rules: CSSRuleList,\n      context: {\n        sheetIndex: number;\n        source: StyleSheetRef;\n        visited: Set<CSSStyleSheet>;\n      },\n    ): void {\n      for (const rule of Array.from(rules)) {\n        rulesScanned++;\n\n        // @import rule\n        if (rule.type === CSSRule.IMPORT_RULE) {\n          const importRule = rule as CSSImportRule;\n          const imported = importRule.styleSheet;\n\n          if (imported && !context.visited.has(imported)) {\n            // Check @import media condition (align with cssom-styles-collector)\n            try {\n              const importMedia = importRule.media?.mediaText?.trim() ?? '';\n              if (importMedia && importMedia.toLowerCase() !== 'all') {\n                if (!window.matchMedia(importMedia).matches) {\n                  continue; // Skip non-matching @import media\n                }\n              }\n            } catch {\n              // Ignore media evaluation errors, proceed with import\n            }\n\n            if (!isSheetApplicable(imported)) continue;\n\n            const importedRules = safeReadCssRules(imported);\n            const importSource = describeStyleSheet(imported, context.sheetIndex);\n\n            if (!importedRules) {\n              warnings.push(\n                `Skipped @import (cross-origin): ${importSource.url ?? importSource.label}`,\n              );\n              continue;\n            }\n\n            context.visited.add(imported);\n            try {\n              walkRules(importedRules, {\n                ...context,\n                source: importSource,\n              });\n            } finally {\n              context.visited.delete(imported);\n            }\n          }\n          continue;\n        }\n\n        // @media rule\n        if (rule.type === CSSRule.MEDIA_RULE) {\n          if (evalMediaRule(rule as CSSMediaRule, warnings)) {\n            walkRules((rule as CSSMediaRule).cssRules, context);\n          }\n          continue;\n        }\n\n        // @supports rule\n        if (rule.type === CSSRule.SUPPORTS_RULE) {\n          if (evalSupportsRule(rule as CSSSupportsRule, warnings)) {\n            walkRules((rule as CSSSupportsRule).cssRules, context);\n          }\n          continue;\n        }\n\n        // Style rule\n        if (rule.type === CSSRule.STYLE_RULE) {\n          const styleRule = rule as CSSStyleRule;\n          const selectorText = String(styleRule.selectorText ?? '').trim() || undefined;\n          const customProps = extractCustomProperties(styleRule.style);\n\n          for (const prop of customProps) {\n            addDeclaration({\n              name: prop.name as CssVarName,\n              value: prop.value,\n              important: prop.important,\n              origin: 'rule',\n              rootType,\n              styleSheet: context.source,\n              selectorText,\n            });\n          }\n          continue;\n        }\n\n        // Best-effort: traverse other grouping rules\n        const anyRule = rule as { cssRules?: CSSRuleList };\n        if (anyRule.cssRules?.length) {\n          try {\n            walkRules(anyRule.cssRules, context);\n          } catch {\n            // Ignore errors in unknown rule types\n          }\n        }\n      }\n    }\n\n    // Collect stylesheets from root\n    const docOrShadow = root as DocumentOrShadowRoot;\n    const styleSheets: CSSStyleSheet[] = [];\n\n    try {\n      for (const s of Array.from(docOrShadow.styleSheets ?? [])) {\n        if (s instanceof CSSStyleSheet) styleSheets.push(s);\n      }\n    } catch {\n      // Ignore access errors\n    }\n\n    try {\n      const adopted = Array.from(docOrShadow.adoptedStyleSheets ?? []);\n      for (const s of adopted) {\n        if (s instanceof CSSStyleSheet) styleSheets.push(s);\n      }\n    } catch {\n      // adoptedStyleSheets may not be supported\n    }\n\n    // Process each stylesheet\n    for (let sheetIndex = 0; sheetIndex < styleSheets.length; sheetIndex++) {\n      const sheet = styleSheets[sheetIndex]!;\n      if (!isSheetApplicable(sheet)) continue;\n\n      const sheetSource = describeStyleSheet(sheet, sheetIndex);\n      const cssRules = safeReadCssRules(sheet);\n\n      if (!cssRules) {\n        warnings.push(`Skipped stylesheet (cross-origin): ${sheetSource.url ?? sheetSource.label}`);\n        continue;\n      }\n\n      const visited = new Set<CSSStyleSheet>([sheet]);\n      walkRules(cssRules, {\n        sheetIndex,\n        source: sheetSource,\n        visited,\n      });\n    }\n\n    const stats: TokenIndexStats = {\n      styleSheets: styleSheets.length,\n      rulesScanned,\n      tokens: tokens.size,\n      declarations: totalDeclarations,\n    };\n\n    return {\n      rootType,\n      tokens,\n      warnings: [...new Set(warnings)], // Deduplicate\n      stats,\n    };\n  }\n\n  // ===========================================================================\n  // Inline Style Collection\n  // ===========================================================================\n\n  /**\n   * Get parent element, crossing shadow boundaries if needed.\n   */\n  function getParentElementOrHost(element: Element): Element | null {\n    if (element.parentElement) return element.parentElement;\n\n    try {\n      const root = element.getRootNode?.();\n      if (root instanceof ShadowRoot) return root.host;\n    } catch {\n      // Ignore errors\n    }\n\n    return null;\n  }\n\n  function collectInlineTokenNames(\n    element: Element,\n    options?: { maxDepth?: number },\n  ): Set<CssVarName> {\n    const maxDepth = Math.max(0, Math.floor(options?.maxDepth ?? defaultInlineDepth));\n    const result = new Set<CssVarName>();\n\n    let current: Element | null = element;\n    let depth = 0;\n\n    while (current && depth <= maxDepth) {\n      depth++;\n\n      try {\n        const style = (current as HTMLElement).style;\n        if (style) {\n          const len = Number(style.length ?? 0);\n          for (let i = 0; i < len; i++) {\n            const name = String(style.item(i) ?? '').trim();\n            if (name.startsWith('--')) {\n              result.add(name as CssVarName);\n            }\n          }\n        }\n      } catch {\n        // Ignore access errors\n      }\n\n      // Always advance to parent (fixes potential infinite loop)\n      current = getParentElementOrHost(current);\n    }\n\n    return result;\n  }\n\n  return {\n    collectRootIndex,\n    collectInlineTokenNames,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/token-resolver.ts",
    "content": "/**\n * Token Resolver (Phase 5.4)\n *\n * Resolves CSS custom property values for specific element contexts.\n *\n * Key features:\n * - Parse and format var() expressions\n * - Read computed custom property values\n * - Check token availability in element context\n *\n * Design decisions:\n * - Primarily uses getComputedStyle() for resolution (safe, no DOM mutation)\n * - Avoids probe element insertion by default (prevents layout/selector side effects)\n * - Probe-based resolution reserved for future phases if needed\n */\n\nimport type {\n  CssVarName,\n  CssVarReference,\n  TokenResolution,\n  TokenResolvedForProperty,\n  TokenResolutionMethod,\n} from './types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating a token resolver */\nexport interface TokenResolverOptions {\n  /**\n   * Enable probe-based resolution for property previews.\n   * @default false (Phase 5.4 recommendation)\n   */\n  enableProbe?: boolean;\n}\n\n/** Options for resolving a token for a specific property */\nexport interface ResolveForPropertyOptions {\n  /** Optional fallback value for var() expression */\n  fallback?: string;\n  /** Attempt to compute a preview value */\n  preview?: boolean;\n}\n\n/** Token resolver public interface */\nexport interface TokenResolver {\n  /**\n   * Format a CSS var() expression.\n   * @example formatCssVar('--color-primary') => 'var(--color-primary)'\n   * @example formatCssVar('--color-primary', 'blue') => 'var(--color-primary, blue)'\n   */\n  formatCssVar(name: CssVarName, fallback?: string): string;\n\n  /**\n   * Parse a var() expression.\n   * @example parseCssVar('var(--color)') => { name: '--color' }\n   * @example parseCssVar('var(--color, blue)') => { name: '--color', fallback: 'blue' }\n   * @returns null if not a valid var() expression\n   */\n  parseCssVar(value: string): CssVarReference | null;\n\n  /**\n   * Extract all CSS variable names from an arbitrary value.\n   * @example extractCssVarNames('calc(var(--a) + var(--b))') => ['--a', '--b']\n   */\n  extractCssVarNames(value: string): CssVarName[];\n\n  /**\n   * Read the computed value of a custom property for an element.\n   * Uses getComputedStyle().getPropertyValue().\n   */\n  readComputedValue(element: Element, name: CssVarName): string;\n\n  /**\n   * Resolve a token's availability and value in an element's context.\n   */\n  resolveToken(element: Element, name: CssVarName): TokenResolution;\n\n  /**\n   * Build the CSS value to apply a token to a specific property.\n   * Returns metadata useful for UI display and preview.\n   */\n  resolveTokenForProperty(\n    element: Element,\n    name: CssVarName,\n    cssProperty: string,\n    options?: ResolveForPropertyOptions,\n  ): TokenResolvedForProperty;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a token resolver instance.\n */\nexport function createTokenResolver(options: TokenResolverOptions = {}): TokenResolver {\n  const enableProbe = Boolean(options.enableProbe);\n\n  // ===========================================================================\n  // var() Formatting and Parsing\n  // ===========================================================================\n\n  function formatCssVar(name: CssVarName, fallback?: string): string {\n    const fb = typeof fallback === 'string' ? fallback.trim() : '';\n    return fb ? `var(${name}, ${fb})` : `var(${name})`;\n  }\n\n  function parseCssVar(value: string): CssVarReference | null {\n    const raw = String(value ?? '').trim();\n    // Case-insensitive var() prefix check\n    if (!raw.toLowerCase().startsWith('var(')) return null;\n\n    // Find matching closing parenthesis\n    let depth = 0;\n    let endIndex = -1;\n\n    for (let i = 0; i < raw.length; i++) {\n      const ch = raw[i]!;\n      if (ch === '(') {\n        depth++;\n      } else if (ch === ')') {\n        depth--;\n        if (depth === 0) {\n          endIndex = i;\n          break;\n        }\n      }\n    }\n\n    // Strict mode: closing paren must be the last character (standalone var() expression)\n    // This rejects values like \"var(--x))\" or \"var(--x) foo\"\n    if (endIndex < 0 || endIndex !== raw.length - 1) return null;\n\n    // Extract content between var( and )\n    const inner = raw.slice(4, endIndex).trim();\n    if (!inner) return null;\n\n    // Find top-level comma (not inside nested parentheses)\n    let commaIndex = -1;\n    depth = 0;\n\n    for (let i = 0; i < inner.length; i++) {\n      const ch = inner[i]!;\n      if (ch === '(') {\n        depth++;\n      } else if (ch === ')') {\n        depth = Math.max(0, depth - 1);\n      } else if (ch === ',' && depth === 0) {\n        commaIndex = i;\n        break;\n      }\n    }\n\n    const nameStr = (commaIndex >= 0 ? inner.slice(0, commaIndex) : inner).trim();\n    const fallbackStr = commaIndex >= 0 ? inner.slice(commaIndex + 1).trim() : '';\n\n    if (!nameStr.startsWith('--')) return null;\n\n    const name = nameStr as CssVarName;\n    return fallbackStr ? { name, fallback: fallbackStr } : { name };\n  }\n\n  function extractCssVarNames(value: string): CssVarName[] {\n    const results: CssVarName[] = [];\n    const str = String(value ?? '');\n\n    // Match var(--name patterns\n    // Note: This regex extracts the name up to the first comma, closing paren, or whitespace\n    const regex = /var\\(\\s*(--[\\w-]+)/g;\n    let match: RegExpExecArray | null;\n\n    while ((match = regex.exec(str))) {\n      const name = match[1]?.trim();\n      if (name?.startsWith('--')) {\n        results.push(name as CssVarName);\n      }\n    }\n\n    return results;\n  }\n\n  // ===========================================================================\n  // Computed Value Reading\n  // ===========================================================================\n\n  function readComputedValue(element: Element, name: CssVarName): string {\n    try {\n      const computed = window.getComputedStyle(element);\n      return computed.getPropertyValue(name).trim();\n    } catch {\n      return '';\n    }\n  }\n\n  // ===========================================================================\n  // Token Resolution\n  // ===========================================================================\n\n  function resolveToken(element: Element, name: CssVarName): TokenResolution {\n    const computedValue = readComputedValue(element, name);\n\n    return {\n      token: name,\n      computedValue,\n      availability: computedValue ? 'available' : 'unset',\n    };\n  }\n\n  function resolveTokenForProperty(\n    element: Element,\n    name: CssVarName,\n    cssProperty: string,\n    options: ResolveForPropertyOptions = {},\n  ): TokenResolvedForProperty {\n    const cssValue = formatCssVar(name, options.fallback);\n\n    // Determine resolution method\n    // Phase 5.4: Avoid probe by default, use computed custom property\n    const method: TokenResolutionMethod = enableProbe && options.preview ? 'probe' : 'computed';\n\n    // For 'computed' method, we can provide the custom property value\n    // but NOT the resolved value for a specific CSS property\n    // (that would require a probe element)\n    let resolvedValue: string | undefined;\n\n    if (method === 'computed' && options.preview) {\n      // Best-effort: return the custom property value itself\n      resolvedValue = readComputedValue(element, name) || undefined;\n    }\n\n    return {\n      token: name,\n      cssProperty,\n      cssValue,\n      resolvedValue,\n      method,\n    };\n  }\n\n  return {\n    formatCssVar,\n    parseCssVar,\n    extractCssVarNames,\n    readComputedValue,\n    resolveToken,\n    resolveTokenForProperty,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/design-tokens/types.ts",
    "content": "/**\n * Design Tokens Types (Phase 5.4)\n *\n * Type definitions for runtime CSS custom properties (design tokens).\n *\n * Scope:\n * - Phase 5.4: Runtime CSS variables only (no server-side token scanning)\n * - Future phases may extend to support project-level tokens from config files\n */\n\n// =============================================================================\n// Core Identifiers\n// =============================================================================\n\n/**\n * CSS custom property name (must start with `--`).\n * Example: '--color-primary', '--spacing-md'\n */\nexport type CssVarName = `--${string}`;\n\n/**\n * Root key for caching token indices.\n * Uses Document or ShadowRoot as WeakMap keys.\n */\nexport type RootCacheKey = Document | ShadowRoot;\n\n/** Type of root context */\nexport type RootType = 'document' | 'shadow';\n\n// =============================================================================\n// Token Classification\n// =============================================================================\n\n/**\n * Token value type classification.\n * Used for filtering and UI grouping.\n */\nexport type TokenKind =\n  | 'color' // Color values (hex, rgb, hsl, etc.)\n  | 'length' // Length values (px, rem, em, %, etc.)\n  | 'number' // Unitless numbers\n  | 'shadow' // Box/text shadow values\n  | 'font' // Font family or font-related values\n  | 'unknown'; // Unable to classify\n\n// =============================================================================\n// Declaration Source\n// =============================================================================\n\n/** Reference to a stylesheet */\nexport interface StyleSheetRef {\n  /** Full URL if available */\n  url?: string;\n  /** Human-readable label (filename or element description) */\n  label: string;\n}\n\n/** Where the token declaration originated */\nexport type TokenDeclarationOrigin = 'rule' | 'inline';\n\n/**\n * A single declaration site for a token.\n * One token name can have multiple declarations across stylesheets/rules.\n */\nexport interface TokenDeclaration {\n  /** Token name (e.g., '--color-primary') */\n  name: CssVarName;\n  /** Raw declared value */\n  value: string;\n  /** Whether declared with !important */\n  important: boolean;\n  /** Origin type */\n  origin: TokenDeclarationOrigin;\n  /** Root type where declared */\n  rootType: RootType;\n  /** Source stylesheet reference */\n  styleSheet?: StyleSheetRef;\n  /** CSS selector for rule-based declarations */\n  selectorText?: string;\n  /** Source order within collection pass (ascending) */\n  order: number;\n}\n\n// =============================================================================\n// Token Model\n// =============================================================================\n\n/**\n * Design token with all known declarations.\n * Aggregates declaration sites for a single token name.\n */\nexport interface DesignToken {\n  /** Token name */\n  name: CssVarName;\n  /** Best-effort value type classification */\n  kind: TokenKind;\n  /** All declaration sites in source order */\n  declarations: readonly TokenDeclaration[];\n}\n\n// =============================================================================\n// Index and Query Results\n// =============================================================================\n\n/** Statistics from a token collection pass */\nexport interface TokenIndexStats {\n  /** Number of stylesheets scanned */\n  styleSheets: number;\n  /** Number of CSS rules processed */\n  rulesScanned: number;\n  /** Number of unique token names found */\n  tokens: number;\n  /** Total number of declaration sites */\n  declarations: number;\n}\n\n/**\n * Root-level token index.\n * Contains all token declarations found in a root's stylesheets.\n */\nexport interface TokenIndex {\n  /** Root type */\n  rootType: RootType;\n  /** Map of token name to declaration sites */\n  tokens: Map<CssVarName, TokenDeclaration[]>;\n  /** Warnings encountered during scanning */\n  warnings: string[];\n  /** Collection statistics */\n  stats: TokenIndexStats;\n}\n\n/**\n * Token with its computed value in a specific element context.\n * Used for showing available tokens when editing an element.\n */\nexport interface ContextToken {\n  /** Token definition */\n  token: DesignToken;\n  /** Computed value via getComputedStyle(element).getPropertyValue(name) */\n  computedValue: string;\n}\n\n/** Generic query result wrapper */\nexport interface TokenQueryResult<T> {\n  /** Result items */\n  tokens: readonly T[];\n  /** Warnings from the operation */\n  warnings: readonly string[];\n  /** Statistics */\n  stats: TokenIndexStats;\n}\n\n// =============================================================================\n// Resolution Types\n// =============================================================================\n\n/** Parsed var() reference */\nexport interface CssVarReference {\n  /** Token name */\n  name: CssVarName;\n  /** Optional fallback value */\n  fallback?: string;\n}\n\n/** Token availability status */\nexport type TokenAvailability = 'available' | 'unset';\n\n/** Method used to resolve token value */\nexport type TokenResolutionMethod =\n  | 'computed' // getComputedStyle().getPropertyValue()\n  | 'probe' // DOM probe element\n  | 'none'; // Not resolved\n\n/** Token resolution result */\nexport interface TokenResolution {\n  /** Token name */\n  token: CssVarName;\n  /** Computed custom property value (may be empty if unset) */\n  computedValue: string;\n  /** Availability status */\n  availability: TokenAvailability;\n}\n\n/** Resolved token ready to apply to a CSS property */\nexport interface TokenResolvedForProperty {\n  /** Token name */\n  token: CssVarName;\n  /** Target CSS property */\n  cssProperty: string;\n  /** CSS value to apply (e.g., 'var(--token)' or 'var(--token, fallback)') */\n  cssValue: string;\n  /** Best-effort resolved preview value */\n  resolvedValue?: string;\n  /** Resolution method used */\n  method: TokenResolutionMethod;\n}\n\n// =============================================================================\n// Cache Invalidation\n// =============================================================================\n\n/** Reason for cache invalidation */\nexport type TokenInvalidationReason =\n  | 'manual' // Explicitly invalidated via API\n  | 'head_mutation' // Document head changed (style/link added/removed)\n  | 'shadow_mutation' // ShadowRoot content changed\n  | 'ttl' // Time-to-live expired\n  | 'unknown';\n\n/** Event emitted when token cache is invalidated */\nexport interface TokenInvalidationEvent {\n  /** Affected root */\n  root: RootCacheKey;\n  /** Root type */\n  rootType: RootType;\n  /** Invalidation reason */\n  reason: TokenInvalidationReason;\n  /** Timestamp */\n  timestamp: number;\n}\n\n/** Unsubscribe function for event listeners */\nexport type Unsubscribe = () => void;\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/editor.ts",
    "content": "/**\n * Web Editor V2 Core\n *\n * Main orchestrator for the visual editor.\n * Manages lifecycle of all subsystems (Shadow Host, Canvas, Interaction Engine, etc.)\n */\n\nimport type {\n  WebEditorApplyBatchPayload,\n  WebEditorElementKey,\n  WebEditorRevertElementResponse,\n  WebEditorSelectionChangedPayload,\n  SelectedElementSummary,\n  WebEditorState,\n  WebEditorTxChangedPayload,\n  WebEditorTxChangeAction,\n  WebEditorV2Api,\n} from '@/common/web-editor-types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { WEB_EDITOR_V2_VERSION, WEB_EDITOR_V2_LOG_PREFIX } from '../constants';\nimport { mountShadowHost, type ShadowHostManager } from '../ui/shadow-host';\nimport { createToolbar, type Toolbar } from '../ui/toolbar';\nimport { createBreadcrumbs, type Breadcrumbs } from '../ui/breadcrumbs';\nimport { createPropertyPanel, type PropertyPanel } from '../ui/property-panel';\nimport { createPropsBridge, type PropsBridge } from './props-bridge';\nimport { createCanvasOverlay, type CanvasOverlay } from '../overlay/canvas-overlay';\nimport { createHandlesController, type HandlesController } from '../overlay/handles-controller';\nimport {\n  createDragReorderController,\n  type DragReorderController,\n} from '../drag/drag-reorder-controller';\nimport {\n  createEventController,\n  type EventController,\n  type EventModifiers,\n} from './event-controller';\nimport { createPositionTracker, type PositionTracker, type TrackedRects } from './position-tracker';\nimport { createSelectionEngine, type SelectionEngine } from '../selection/selection-engine';\nimport {\n  createTransactionManager,\n  type TransactionManager,\n  type TransactionChangeEvent,\n} from './transaction-manager';\nimport { locateElement, createElementLocator } from './locator';\nimport { sendTransactionToAgent } from './payload-builder';\nimport { aggregateTransactionsByElement } from './transaction-aggregator';\nimport {\n  generateStableElementKey,\n  generateElementLabel,\n  generateFullElementLabel,\n} from './element-key';\nimport {\n  createExecutionTracker,\n  type ExecutionTracker,\n  type ExecutionState,\n} from './execution-tracker';\nimport { createHmrConsistencyVerifier, type HmrConsistencyVerifier } from './hmr-consistency';\nimport { createPerfMonitor, type PerfMonitor } from './perf-monitor';\nimport { createDesignTokensService, type DesignTokensService } from './design-tokens';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Apply operation snapshot for rollback tracking */\ninterface ApplySnapshot {\n  txId: string;\n  txTimestamp: number;\n}\n\n/** Internal editor state */\ninterface EditorInternalState {\n  active: boolean;\n  shadowHost: ShadowHostManager | null;\n  canvasOverlay: CanvasOverlay | null;\n  handlesController: HandlesController | null;\n  eventController: EventController | null;\n  positionTracker: PositionTracker | null;\n  selectionEngine: SelectionEngine | null;\n  dragReorderController: DragReorderController | null;\n  transactionManager: TransactionManager | null;\n  executionTracker: ExecutionTracker | null;\n  hmrConsistencyVerifier: HmrConsistencyVerifier | null;\n  toolbar: Toolbar | null;\n  breadcrumbs: Breadcrumbs | null;\n  propertyPanel: PropertyPanel | null;\n  /** Runtime props bridge (Phase 7) */\n  propsBridge: PropsBridge | null;\n  /** Design tokens service (Phase 5.3) */\n  tokensService: DesignTokensService | null;\n  /** Performance monitor (Phase 5.3) - disabled by default */\n  perfMonitor: PerfMonitor | null;\n  /** Cleanup function for perf monitor hotkey */\n  perfHotkeyCleanup: (() => void) | null;\n  /** Currently hovered element (for hover highlight) */\n  hoveredElement: Element | null;\n  /** One-shot flag: whether next hover rect update should animate */\n  pendingHoverTransition: boolean;\n  /** Currently selected element (for selection highlight) */\n  selectedElement: Element | null;\n  /** Snapshot of transaction being applied (for rollback on failure) */\n  applyingSnapshot: ApplySnapshot | null;\n  /** Floating toolbar position (viewport coordinates), null when docked */\n  toolbarPosition: { left: number; top: number } | null;\n  /** Floating property panel position (viewport coordinates), null when anchored */\n  propertyPanelPosition: { left: number; top: number } | null;\n  /** Cleanup for window resize clamping (floating UI) */\n  uiResizeCleanup: (() => void) | null;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create the Web Editor V2 instance.\n *\n * This is the main factory function that creates the editor API.\n * The returned object implements WebEditorV2Api and is exposed on window.__MCP_WEB_EDITOR_V2__\n */\nexport function createWebEditorV2(): WebEditorV2Api {\n  const state: EditorInternalState = {\n    active: false,\n    shadowHost: null,\n    canvasOverlay: null,\n    handlesController: null,\n    eventController: null,\n    positionTracker: null,\n    selectionEngine: null,\n    dragReorderController: null,\n    transactionManager: null,\n    executionTracker: null,\n    hmrConsistencyVerifier: null,\n    toolbar: null,\n    breadcrumbs: null,\n    propertyPanel: null,\n    propsBridge: null,\n    tokensService: null,\n    perfMonitor: null,\n    perfHotkeyCleanup: null,\n    hoveredElement: null,\n    pendingHoverTransition: false,\n    selectedElement: null,\n    applyingSnapshot: null,\n    toolbarPosition: null,\n    propertyPanelPosition: null,\n    uiResizeCleanup: null,\n  };\n\n  /** Default modifiers for programmatic selection (e.g., from breadcrumbs) */\n  const DEFAULT_MODIFIERS: EventModifiers = {\n    alt: false,\n    shift: false,\n    ctrl: false,\n    meta: false,\n  };\n\n  // ===========================================================================\n  // Text Editing Session (Phase 2.7)\n  // ===========================================================================\n\n  interface EditSession {\n    element: HTMLElement;\n    beforeText: string;\n    beforeContentEditable: string | null;\n    beforeSpellcheck: boolean;\n    keydownHandler: (ev: KeyboardEvent) => void;\n    blurHandler: () => void;\n  }\n\n  let editSession: EditSession | null = null;\n\n  /** Check if element is a valid text edit target */\n  function isTextEditTarget(element: Element): element is HTMLElement {\n    if (!(element instanceof HTMLElement)) return false;\n    // Not for form controls\n    if (element instanceof HTMLInputElement) return false;\n    if (element instanceof HTMLTextAreaElement) return false;\n    // Only for text-only targets (no element children)\n    if (element.childElementCount > 0) return false;\n    return true;\n  }\n\n  /** Restore element to pre-edit state */\n  function restoreEditTarget(session: EditSession): void {\n    const { element, beforeContentEditable, beforeSpellcheck } = session;\n\n    if (beforeContentEditable === null) {\n      element.removeAttribute('contenteditable');\n    } else {\n      element.setAttribute('contenteditable', beforeContentEditable);\n    }\n\n    element.spellcheck = beforeSpellcheck;\n\n    // Remove event listeners\n    element.removeEventListener('keydown', session.keydownHandler, true);\n    element.removeEventListener('blur', session.blurHandler, true);\n  }\n\n  /** Commit the current edit session */\n  function commitEdit(): void {\n    const session = editSession;\n    if (!session) return;\n\n    editSession = null;\n\n    const element = session.element;\n    const afterText = element.textContent ?? '';\n\n    // Normalize to text-only to avoid structure drift from contentEditable\n    element.textContent = afterText;\n\n    restoreEditTarget(session);\n\n    // Record transaction if text changed\n    if (session.beforeText !== afterText) {\n      state.transactionManager?.recordText(element, session.beforeText, afterText);\n    }\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit committed`);\n  }\n\n  /** Cancel the current edit session */\n  function cancelEdit(): void {\n    const session = editSession;\n    if (!session) return;\n\n    editSession = null;\n\n    // Restore original text\n    session.element.textContent = session.beforeText;\n\n    restoreEditTarget(session);\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit cancelled`);\n  }\n\n  /** Start editing an element */\n  function startEdit(element: Element, modifiers: EventModifiers): boolean {\n    if (!isTextEditTarget(element)) return false;\n    if (!element.isConnected) return false;\n\n    // Ensure element is selected\n    if (state.selectedElement !== element) {\n      handleSelect(element, modifiers);\n    }\n\n    // If already editing this element, keep editing\n    if (editSession?.element === element) return true;\n\n    // Commit previous edit if any\n    if (editSession) {\n      commitEdit();\n    }\n\n    const beforeText = element.textContent ?? '';\n    const beforeContentEditable = element.getAttribute('contenteditable');\n    const beforeSpellcheck = element.spellcheck;\n\n    // ESC cancels editing\n    const keydownHandler = (ev: KeyboardEvent) => {\n      if (ev.key !== 'Escape') return;\n      ev.preventDefault();\n      ev.stopPropagation();\n      ev.stopImmediatePropagation();\n      cancelEdit();\n      state.eventController?.setMode('selecting');\n    };\n\n    // Blur commits editing\n    const blurHandler = () => {\n      commitEdit();\n      state.eventController?.setMode('selecting');\n    };\n\n    element.addEventListener('keydown', keydownHandler, true);\n    element.addEventListener('blur', blurHandler, true);\n\n    element.setAttribute('contenteditable', 'true');\n    element.spellcheck = false;\n\n    try {\n      element.focus({ preventScroll: true });\n    } catch {\n      try {\n        element.focus();\n      } catch {\n        // Best-effort only\n      }\n    }\n\n    editSession = {\n      element,\n      beforeText,\n      beforeContentEditable,\n      beforeSpellcheck,\n      keydownHandler,\n      blurHandler,\n    };\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Text edit started`);\n    return true;\n  }\n\n  // ===========================================================================\n  // Event Handlers (wired to EventController callbacks)\n  // ===========================================================================\n\n  /**\n   * Handle hover state changes from EventController\n   */\n  function handleHover(element: Element | null): void {\n    const prevElement = state.hoveredElement;\n    state.hoveredElement = element;\n\n    // Determine if we should animate the hover rect transition\n    // Only animate when switching between two valid elements (not null)\n    const shouldAnimate = prevElement !== null && element !== null && prevElement !== element;\n    state.pendingHoverTransition = shouldAnimate;\n\n    // Delegate position tracking to PositionTracker\n    // Use forceUpdate to avoid extra rAF frame delay\n    if (state.positionTracker) {\n      state.positionTracker.setHoverElement(element);\n      state.positionTracker.forceUpdate();\n    }\n  }\n\n  /**\n   * Handle element selection from EventController\n   */\n  function handleSelect(element: Element, modifiers: EventModifiers): void {\n    // Commit any in-progress edit when selecting a different element\n    if (editSession && editSession.element !== element) {\n      commitEdit();\n    }\n\n    state.selectedElement = element;\n    state.hoveredElement = null;\n\n    // Delegate position tracking to PositionTracker\n    // Clear hover, set selection, then force immediate update\n    if (state.positionTracker) {\n      state.positionTracker.setHoverElement(null);\n      state.positionTracker.setSelectionElement(element);\n      state.positionTracker.forceUpdate();\n    }\n\n    // Update breadcrumbs to show element ancestry\n    state.breadcrumbs?.setTarget(element);\n\n    // Update property panel with selected element\n    state.propertyPanel?.setTarget(element);\n\n    // Update resize handles target (Phase 4.9)\n    state.handlesController?.setTarget(element);\n\n    // Notify HMR consistency verifier of selection change (Phase 4.8)\n    state.hmrConsistencyVerifier?.onSelectionChange(element);\n\n    // Broadcast selection to sidepanel for AgentChat context\n    broadcastSelectionChanged(element);\n\n    // Log selection with modifier info for debugging\n    const modInfo = modifiers.alt ? ' (Alt: drill-up)' : '';\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Selected${modInfo}:`, element.tagName, element);\n  }\n\n  /**\n   * Handle deselection (ESC key) from EventController\n   */\n  function handleDeselect(): void {\n    state.selectedElement = null;\n\n    // Clear selection tracking and force immediate update\n    if (state.positionTracker) {\n      state.positionTracker.setSelectionElement(null);\n      state.positionTracker.forceUpdate();\n    }\n\n    // Clear breadcrumbs\n    state.breadcrumbs?.setTarget(null);\n\n    // Clear property panel\n    state.propertyPanel?.setTarget(null);\n\n    // Hide resize handles (Phase 4.9)\n    state.handlesController?.setTarget(null);\n\n    // Notify HMR consistency verifier of deselection (Phase 4.8)\n    // Deselection should cancel any ongoing verification\n    state.hmrConsistencyVerifier?.onSelectionChange(null);\n\n    // Broadcast deselection to sidepanel\n    broadcastSelectionChanged(null);\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Deselected`);\n  }\n\n  /**\n   * Handle position updates from PositionTracker (scroll/resize sync)\n   */\n  function handlePositionUpdate(rects: TrackedRects): void {\n    // Anchor breadcrumbs to the selection rect (viewport coordinates)\n    state.breadcrumbs?.setAnchorRect(rects.selection);\n\n    // Consume one-shot animation flag (must read before clearing)\n    // This flag is only set when hover element changes, not for scroll/resize\n    const animateHover = state.pendingHoverTransition;\n    state.pendingHoverTransition = false;\n\n    if (!state.canvasOverlay) return;\n\n    // Update canvas overlay with new positions\n    state.canvasOverlay.setHoverRect(rects.hover, { animate: animateHover });\n    state.canvasOverlay.setSelectionRect(rects.selection);\n\n    // Sync resize handles with latest selection rect (Phase 4.9)\n    state.handlesController?.setSelectionRect(rects.selection);\n\n    // Force immediate render to avoid extra rAF delay\n    // This collapses the render to the same frame as position calculation\n    state.canvasOverlay.render();\n  }\n\n  // ===========================================================================\n  // AgentChat Integration (Phase 1.4)\n  // ===========================================================================\n\n  const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-' as const;\n  const TX_CHANGED_BROADCAST_DEBOUNCE_MS = 100;\n\n  let txChangedBroadcastTimer: number | null = null;\n  let pendingTxAction: WebEditorTxChangeAction = 'push';\n\n  /**\n   * Broadcast aggregated transaction state to extension UI (e.g., Sidepanel).\n   *\n   * This runs on a short debounce because TransactionManager can emit frequent\n   * merge events during continuous interactions (e.g., dragging sliders).\n   *\n   * NOTE: tabId is set to 0 here; background script will fill in the actual\n   * tabId from sender.tab.id and update storage with per-tab keys.\n   */\n  function broadcastTxChanged(action: WebEditorTxChangeAction): void {\n    // Track the action for when debounce fires\n    pendingTxAction = action;\n\n    // For 'clear' action, broadcast immediately without debounce\n    // This ensures UI updates instantly when user applies changes\n    const shouldBroadcastImmediately = action === 'clear';\n\n    if (txChangedBroadcastTimer !== null) {\n      window.clearTimeout(txChangedBroadcastTimer);\n      txChangedBroadcastTimer = null;\n    }\n\n    const doBroadcast = (): void => {\n      const tm = state.transactionManager;\n      if (!tm) return;\n\n      const undoStack = tm.getUndoStack();\n      const redoStack = tm.getRedoStack();\n      const elements = aggregateTransactionsByElement(undoStack);\n\n      const payload: WebEditorTxChangedPayload = {\n        tabId: 0, // Will be filled by background script from sender.tab.id\n        action: pendingTxAction,\n        elements,\n        undoCount: undoStack.length,\n        redoCount: redoStack.length,\n        hasApplicableChanges: elements.length > 0,\n        pageUrl: window.location.href,\n      };\n\n      // Broadcast to extension UI (background will handle storage persistence)\n      if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {\n        chrome.runtime\n          .sendMessage({\n            type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED,\n            payload,\n          })\n          .catch(() => {\n            // Ignore if no listeners (e.g., sidepanel not open)\n          });\n      }\n    };\n\n    if (shouldBroadcastImmediately) {\n      doBroadcast();\n    } else {\n      txChangedBroadcastTimer = window.setTimeout(doBroadcast, TX_CHANGED_BROADCAST_DEBOUNCE_MS);\n    }\n  }\n\n  /** Last broadcasted selection key to avoid duplicate broadcasts */\n  let lastBroadcastedSelectionKey: string | null = null;\n\n  /**\n   * Broadcast selection change to sidepanel (no debounce - immediate).\n   * Called when user selects or deselects an element.\n   */\n  function broadcastSelectionChanged(element: Element | null): void {\n    // Build selected element summary if element is provided\n    let selected: SelectedElementSummary | null = null;\n\n    if (element) {\n      const elementKey = generateStableElementKey(element);\n\n      // Dedupe: skip if same element already broadcasted\n      if (elementKey === lastBroadcastedSelectionKey) return;\n      lastBroadcastedSelectionKey = elementKey;\n\n      const locator = createElementLocator(element);\n      selected = {\n        elementKey,\n        locator,\n        label: generateElementLabel(element),\n        fullLabel: generateFullElementLabel(element),\n        tagName: element.tagName.toLowerCase(),\n        updatedAt: Date.now(),\n      };\n    } else {\n      // Deselection - clear tracking\n      if (lastBroadcastedSelectionKey === null) return; // Already deselected\n      lastBroadcastedSelectionKey = null;\n    }\n\n    const payload: WebEditorSelectionChangedPayload = {\n      tabId: 0, // Will be filled by background script from sender.tab.id\n      selected,\n      pageUrl: window.location.href,\n    };\n\n    // Broadcast immediately (no debounce for selection changes)\n    if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {\n      chrome.runtime\n        .sendMessage({\n          type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED,\n          payload,\n        })\n        .catch(() => {\n          // Ignore if no listeners (e.g., sidepanel not open)\n        });\n    }\n  }\n\n  /**\n   * Broadcast \"editor cleared\" state when stopping.\n   * Sends empty TX and null selection to remove chips from sidepanel.\n   */\n  function broadcastEditorCleared(): void {\n    // Reset selection dedupe so next start can broadcast correctly\n    lastBroadcastedSelectionKey = null;\n\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return;\n\n    const pageUrl = window.location.href;\n\n    // Send empty TX state\n    const txPayload: WebEditorTxChangedPayload = {\n      tabId: 0,\n      action: 'clear',\n      elements: [],\n      undoCount: 0,\n      redoCount: 0,\n      hasApplicableChanges: false,\n      pageUrl,\n    };\n\n    // Send null selection\n    const selectionPayload: WebEditorSelectionChangedPayload = {\n      tabId: 0,\n      selected: null,\n      pageUrl,\n    };\n\n    chrome.runtime\n      .sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED,\n        payload: txPayload,\n      })\n      .catch(() => {});\n\n    chrome.runtime\n      .sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED,\n        payload: selectionPayload,\n      })\n      .catch(() => {});\n  }\n\n  /**\n   * Handle transaction changes from TransactionManager\n   */\n  function handleTransactionChange(event: TransactionChangeEvent): void {\n    // Log transaction events for debugging\n    const { action, undoCount, redoCount } = event;\n    console.log(\n      `${WEB_EDITOR_V2_LOG_PREFIX} Transaction: ${action} (undo: ${undoCount}, redo: ${redoCount})`,\n    );\n\n    // Update toolbar UI with undo/redo counts\n    state.toolbar?.setHistory(undoCount, redoCount);\n\n    // Refresh property panel after undo/redo to reflect current styles\n    if (action === 'undo' || action === 'redo') {\n      state.propertyPanel?.refresh();\n    }\n\n    // Broadcast aggregated TX state for AgentChat integration (Phase 1.4)\n    broadcastTxChanged(action as WebEditorTxChangeAction);\n\n    // Notify HMR consistency verifier of transaction change (Phase 4.8)\n    state.hmrConsistencyVerifier?.onTransactionChange(event);\n  }\n\n  /**\n   * Check if the transaction being applied is still the latest in undo stack.\n   * Used to determine if we should auto-rollback on failure.\n   *\n   * Returns a detailed status to distinguish between:\n   * - 'ok': Transaction is still latest, safe to rollback\n   * - 'no_snapshot': No apply in progress\n   * - 'tm_unavailable': TransactionManager not available\n   * - 'stack_empty': Undo stack is empty (tx was already undone)\n   * - 'tx_changed': User made new edits or tx was merged\n   */\n  type ApplyTxStatus = 'ok' | 'no_snapshot' | 'tm_unavailable' | 'stack_empty' | 'tx_changed';\n\n  function checkApplyingTxStatus(): ApplyTxStatus {\n    const snapshot = state.applyingSnapshot;\n    if (!snapshot) return 'no_snapshot';\n\n    const tm = state.transactionManager;\n    if (!tm) return 'tm_unavailable';\n\n    const undoStack = tm.getUndoStack();\n    if (undoStack.length === 0) return 'stack_empty';\n\n    const latest = undoStack[undoStack.length - 1]!;\n\n    // Check both id and timestamp to handle merged transactions\n    if (latest.id !== snapshot.txId || latest.timestamp !== snapshot.txTimestamp) {\n      return 'tx_changed';\n    }\n\n    return 'ok';\n  }\n\n  /**\n   * Attempt to rollback the applying transaction on failure.\n   * Returns a descriptive error message based on rollback result.\n   *\n   * Rollback is only attempted when:\n   * - The transaction is still the latest in undo stack\n   * - No new edits were made during the apply operation\n   */\n  function attemptRollbackOnFailure(originalError: string): string {\n    const status = checkApplyingTxStatus();\n\n    // Cannot rollback: TM not available or no snapshot\n    if (status === 'no_snapshot' || status === 'tm_unavailable') {\n      console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, unable to revert (${status})`);\n      return `${originalError} (unable to revert)`;\n    }\n\n    // Stack is empty - tx was already undone (race condition or user action)\n    if (status === 'stack_empty') {\n      console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, stack empty (already reverted?)`);\n      return `${originalError} (already reverted)`;\n    }\n\n    // User made new edits during apply - don't rollback their work\n    if (status === 'tx_changed') {\n      console.warn(\n        `${WEB_EDITOR_V2_LOG_PREFIX} Apply failed but new edits detected, skipping auto-rollback`,\n      );\n      return `${originalError} (new edits detected, not reverted)`;\n    }\n\n    // Status is 'ok' - safe to attempt rollback\n    const tm = state.transactionManager!;\n    const undone = tm.undo();\n    if (undone) {\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed, changes auto-reverted`);\n      return `${originalError} (changes reverted)`;\n    }\n\n    // undo() returned null - likely locateElement() failed\n    console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Apply failed and auto-revert also failed`);\n    return `${originalError} (revert failed)`;\n  }\n\n  /**\n   * Apply the latest transaction to Agent (Apply to Code)\n   *\n   * Phase 2.10: On failure, automatically attempts to undo the transaction\n   * to revert DOM changes. The transaction moves to redo stack so user can retry.\n   */\n  async function applyLatestTransaction(): Promise<{ requestId?: string; sessionId?: string }> {\n    const tm = state.transactionManager;\n    if (!tm) {\n      throw new Error('Transaction manager not ready');\n    }\n\n    // Prevent concurrent apply operations\n    if (state.applyingSnapshot) {\n      throw new Error('Apply already in progress');\n    }\n\n    const undoStack = tm.getUndoStack();\n    const tx = undoStack.length > 0 ? undoStack[undoStack.length - 1] : null;\n    if (!tx) {\n      throw new Error('No changes to apply');\n    }\n\n    // Apply-to-Code currently supports only style/text transactions\n    if (tx.type !== 'style' && tx.type !== 'text') {\n      throw new Error(`Apply does not support \"${tx.type}\" transactions yet`);\n    }\n\n    // Snapshot the transaction for rollback tracking\n    state.applyingSnapshot = {\n      txId: tx.id,\n      txTimestamp: tx.timestamp,\n    };\n\n    // Markers indicating error was already processed by attemptRollbackOnFailure\n    const ROLLBACK_MARKERS = [\n      '(changes reverted)',\n      '(new edits detected',\n      '(revert failed)',\n      '(unable to revert)',\n      '(already reverted)',\n    ];\n\n    const isAlreadyProcessed = (err: unknown): boolean =>\n      err instanceof Error && ROLLBACK_MARKERS.some((m) => err.message.includes(m));\n\n    try {\n      const resp = await sendTransactionToAgent(tx);\n      const r = resp as {\n        success?: unknown;\n        requestId?: unknown;\n        sessionId?: unknown;\n        error?: unknown;\n      } | null;\n\n      if (r && r.success === true) {\n        const requestId = typeof r.requestId === 'string' ? r.requestId : undefined;\n        const sessionId = typeof r.sessionId === 'string' ? r.sessionId : undefined;\n\n        // Start tracking execution status if we have a requestId\n        if (requestId && sessionId && state.executionTracker) {\n          state.executionTracker.track(requestId, sessionId);\n        }\n\n        // Start HMR consistency verification (Phase 4.8)\n        state.hmrConsistencyVerifier?.start({\n          tx,\n          requestId,\n          sessionId,\n          element: state.selectedElement,\n        });\n\n        return { requestId, sessionId };\n      }\n\n      // Agent returned failure response - attempt rollback\n      const errorMsg = typeof r?.error === 'string' ? r.error : 'Agent request failed';\n      throw new Error(attemptRollbackOnFailure(errorMsg));\n    } catch (error) {\n      // Re-throw if already processed by attemptRollbackOnFailure\n      if (isAlreadyProcessed(error)) {\n        throw error;\n      }\n\n      // Network error or other unprocessed exception - attempt rollback\n      const originalMsg = error instanceof Error ? error.message : String(error);\n      throw new Error(attemptRollbackOnFailure(originalMsg));\n    } finally {\n      // Clear snapshot regardless of outcome\n      state.applyingSnapshot = null;\n    }\n  }\n\n  /**\n   * Apply all applicable transactions to Agent (batch Apply to Code)\n   *\n   * Phase 1.4: Aggregates the undo stack by element and sends a single batch request.\n   * Unlike applyLatestTransaction, this does NOT auto-rollback on failure.\n   */\n  async function applyAllTransactions(): Promise<{ requestId?: string; sessionId?: string }> {\n    const tm = state.transactionManager;\n    if (!tm) {\n      throw new Error('Transaction manager not ready');\n    }\n\n    // Prevent concurrent apply operations\n    if (state.applyingSnapshot) {\n      throw new Error('Apply already in progress');\n    }\n\n    const undoStack = tm.getUndoStack();\n    if (undoStack.length === 0) {\n      throw new Error('No changes to apply');\n    }\n\n    // Block unsupported transaction types\n    for (const tx of undoStack) {\n      if (tx.type === 'move') {\n        throw new Error('Apply does not support reorder operations yet');\n      }\n      if (tx.type === 'structure') {\n        throw new Error('Apply does not support structure operations yet');\n      }\n      if (tx.type !== 'style' && tx.type !== 'text' && tx.type !== 'class') {\n        throw new Error(`Apply does not support \"${tx.type}\" transactions`);\n      }\n    }\n\n    const elements = aggregateTransactionsByElement(undoStack);\n    if (elements.length === 0) {\n      throw new Error('No net changes to apply');\n    }\n\n    // Snapshot latest transaction for concurrency tracking\n    const latestTx = undoStack[undoStack.length - 1]!;\n    state.applyingSnapshot = {\n      txId: latestTx.id,\n      txTimestamp: latestTx.timestamp,\n    };\n\n    try {\n      if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n        throw new Error('Chrome runtime API not available');\n      }\n\n      const payload: WebEditorApplyBatchPayload = {\n        tabId: 0, // Will be filled by background script\n        elements,\n        excludedKeys: [], // TODO: Read from storage if exclude feature is implemented\n        pageUrl: window.location.href,\n      };\n\n      const resp = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY_BATCH,\n        payload,\n      });\n\n      const r = resp as {\n        success?: unknown;\n        requestId?: unknown;\n        sessionId?: unknown;\n        error?: unknown;\n      } | null;\n\n      if (r && r.success === true) {\n        const requestId = typeof r.requestId === 'string' ? r.requestId : undefined;\n        const sessionId = typeof r.sessionId === 'string' ? r.sessionId : undefined;\n\n        // Start tracking execution status if we have a requestId\n        if (requestId && sessionId && state.executionTracker) {\n          state.executionTracker.track(requestId, sessionId);\n        }\n\n        // Clear transaction history after successful apply\n        // This prevents undo/redo since changes are now committed to code\n        tm.clear();\n\n        // Deselect current element after successful apply\n        // This clears the selection chip in the UI\n        handleDeselect();\n\n        return { requestId, sessionId };\n      }\n\n      const errorMsg = typeof r?.error === 'string' ? r.error : 'Agent request failed';\n      throw new Error(errorMsg);\n    } finally {\n      state.applyingSnapshot = null;\n    }\n  }\n\n  /**\n   * Revert a specific element to its baseline state (Phase 2 - Selective Undo).\n   * Creates compensating transactions so the user can undo the revert.\n   */\n  async function revertElement(\n    elementKey: WebEditorElementKey,\n  ): Promise<WebEditorRevertElementResponse> {\n    const key = String(elementKey ?? '').trim();\n    if (!key) {\n      return { success: false, error: 'elementKey is required' };\n    }\n\n    const tm = state.transactionManager;\n    if (!tm) {\n      return { success: false, error: 'Transaction manager not ready' };\n    }\n\n    if (state.applyingSnapshot) {\n      return { success: false, error: 'Cannot revert while Apply is in progress' };\n    }\n\n    try {\n      const undoStack = tm.getUndoStack();\n      const summaries = aggregateTransactionsByElement(undoStack);\n      const summary = summaries.find((s) => s.elementKey === key);\n\n      if (!summary) {\n        return { success: false, error: 'Element not found in current changes' };\n      }\n\n      const element = locateElement(summary.locator);\n      if (!element || !element.isConnected) {\n        return { success: false, error: 'Failed to locate element for revert' };\n      }\n\n      const reverted: NonNullable<WebEditorRevertElementResponse['reverted']> = {};\n      let didRevert = false;\n\n      // Revert class first so subsequent locators are based on baseline classes.\n      const classChanges = summary.netEffect.classChanges;\n      if (classChanges) {\n        const baselineClasses = Array.isArray(classChanges.before) ? classChanges.before : [];\n        const beforeClasses = (() => {\n          try {\n            const list = (element as HTMLElement).classList;\n            if (list && typeof list[Symbol.iterator] === 'function') {\n              return Array.from(list).filter(Boolean);\n            }\n          } catch {\n            // Fallback for non-HTMLElement\n          }\n\n          const raw = element.getAttribute('class') ?? '';\n          return raw\n            .split(/\\s+/)\n            .map((t) => t.trim())\n            .filter(Boolean);\n        })();\n\n        const tx = tm.recordClass(element, beforeClasses, baselineClasses);\n        if (tx) {\n          reverted.class = true;\n          didRevert = true;\n        }\n      }\n\n      // Revert text content\n      const textChange = summary.netEffect.textChange;\n      if (textChange) {\n        const baselineText = String(textChange.before ?? '');\n        const beforeText = element.textContent ?? '';\n\n        if (beforeText !== baselineText) {\n          element.textContent = baselineText;\n          const tx = tm.recordText(element, beforeText, baselineText);\n          if (tx) {\n            reverted.text = true;\n            didRevert = true;\n          }\n        }\n      }\n\n      // Revert styles\n      const styleChanges = summary.netEffect.styleChanges;\n      if (styleChanges) {\n        const before = styleChanges.before ?? {};\n        const after = styleChanges.after ?? {};\n\n        const properties = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]))\n          .map((p) => String(p ?? '').trim())\n          .filter(Boolean);\n\n        if (properties.length > 0) {\n          const handle = tm.beginMultiStyle(element, properties);\n          if (handle) {\n            handle.set(before);\n            const tx = handle.commit({ merge: false });\n            if (tx) {\n              reverted.style = true;\n              didRevert = true;\n            }\n          }\n        }\n      }\n\n      if (!didRevert) {\n        return { success: false, error: 'No changes were reverted' };\n      }\n\n      // Ensure property panel reflects reverted values immediately\n      state.propertyPanel?.refresh();\n\n      return { success: true, reverted };\n    } catch (error) {\n      console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Revert element failed:`, error);\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : String(error),\n      };\n    }\n  }\n\n  /**\n   * Clear current selection (called from sidepanel after send).\n   * Triggers handleDeselect which broadcasts null selection to sidepanel.\n   */\n  function clearSelection(): void {\n    if (!state.selectedElement) {\n      // Already deselected\n      return;\n    }\n\n    // Use EventController to properly transition to hover mode\n    // This triggers onDeselect callback → handleDeselect → broadcastSelectionChanged(null)\n    if (state.eventController) {\n      state.eventController.setMode('hover');\n\n      // Edge case: if setMode('hover') didn't trigger deselect (e.g., already in hover mode\n      // but selectedElement was set programmatically), manually call handleDeselect\n      if (state.selectedElement) {\n        handleDeselect();\n      }\n    } else {\n      // Fallback if eventController not available: directly call handleDeselect\n      handleDeselect();\n    }\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Selection cleared (from sidepanel)`);\n  }\n\n  /**\n   * Handle transaction apply errors\n   */\n  function handleTransactionError(error: unknown): void {\n    console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Transaction apply error:`, error);\n  }\n\n  /**\n   * Start the editor\n   */\n  function start(): void {\n    if (state.active) {\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already active`);\n      return;\n    }\n\n    try {\n      // Mount Shadow DOM host\n      state.shadowHost = mountShadowHost({});\n\n      // Initialize Canvas Overlay\n      const elements = state.shadowHost.getElements();\n      if (!elements?.overlayRoot) {\n        throw new Error('Shadow host overlayRoot not available');\n      }\n      state.canvasOverlay = createCanvasOverlay({\n        container: elements.overlayRoot,\n      });\n\n      // Initialize Performance Monitor (Phase 5.3) - disabled by default\n      state.perfMonitor = createPerfMonitor({\n        container: elements.overlayRoot,\n        fpsUiIntervalMs: 500,\n        memorySampleIntervalMs: 1000,\n      });\n\n      // Register hotkey: Ctrl/Cmd + Shift + P toggles perf monitor\n      const perfHotkeyHandler = (event: KeyboardEvent): void => {\n        // Ignore key repeats to avoid rapid toggles when holding the shortcut\n        if (event.repeat) return;\n\n        const isMod = event.metaKey || event.ctrlKey;\n        if (!isMod) return;\n        if (!event.shiftKey) return;\n        if (event.altKey) return;\n\n        const key = (event.key || '').toLowerCase();\n        if (key !== 'p') return;\n\n        const monitor = state.perfMonitor;\n        if (!monitor) return;\n\n        monitor.toggle();\n\n        // Prevent browser shortcuts (e.g., print dialog)\n        event.preventDefault();\n        event.stopPropagation();\n        event.stopImmediatePropagation();\n      };\n\n      const hotkeyOptions: AddEventListenerOptions = { capture: true, passive: false };\n      window.addEventListener('keydown', perfHotkeyHandler, hotkeyOptions);\n      state.perfHotkeyCleanup = () => {\n        window.removeEventListener('keydown', perfHotkeyHandler, hotkeyOptions);\n      };\n\n      // Initialize Selection Engine for intelligent element picking\n      state.selectionEngine = createSelectionEngine({\n        isOverlayElement: state.shadowHost.isOverlayElement,\n      });\n\n      // Initialize Position Tracker for scroll/resize synchronization\n      state.positionTracker = createPositionTracker({\n        onPositionUpdate: handlePositionUpdate,\n      });\n\n      // Initialize Transaction Manager for undo/redo support\n      // Use isEventFromUi (not isOverlayElement) to properly check event source\n      state.transactionManager = createTransactionManager({\n        enableKeyBindings: true,\n        // Include both Shadow UI events and events from editing element\n        // This prevents Ctrl/Cmd+Z from triggering global undo while editing text\n        isEventFromEditorUi: (event) => {\n          if (state.shadowHost?.isEventFromUi(event)) return true;\n          // Also ignore events from the editing element (allow native contentEditable undo)\n          const session = editSession;\n          if (session?.element) {\n            try {\n              const path = typeof event.composedPath === 'function' ? event.composedPath() : null;\n              if (path?.some((node) => node === session.element)) return true;\n            } catch {\n              // Fallback\n              const target = event.target;\n              if (target instanceof Node && session.element.contains(target)) return true;\n            }\n          }\n          return false;\n        },\n        onChange: handleTransactionChange,\n        onApplyError: handleTransactionError,\n      });\n\n      // Initialize Resize Handles Controller (Phase 4.9)\n      state.handlesController = createHandlesController({\n        container: elements.overlayRoot,\n        canvasOverlay: state.canvasOverlay,\n        transactionManager: state.transactionManager,\n        positionTracker: state.positionTracker,\n      });\n\n      // Initialize Drag Reorder Controller (Phase 2.4-2.6)\n      state.dragReorderController = createDragReorderController({\n        isOverlayElement: state.shadowHost.isOverlayElement,\n        uiRoot: elements.uiRoot,\n        canvasOverlay: state.canvasOverlay,\n        positionTracker: state.positionTracker,\n        transactionManager: state.transactionManager,\n      });\n\n      // Initialize Event Controller for interaction handling\n      // Wire up SelectionEngine's findBestTargetFromEvent for Shadow DOM-aware selection (click only)\n      // Hover uses fast elementFromPoint for 60FPS performance\n      state.eventController = createEventController({\n        isOverlayElement: state.shadowHost.isOverlayElement,\n        onHover: handleHover,\n        onSelect: handleSelect,\n        onDeselect: handleDeselect,\n        onStartEdit: startEdit,\n        findTargetForSelect: (_x, _y, modifiers, event) =>\n          state.selectionEngine?.findBestTargetFromEvent(event, modifiers) ?? null,\n        getSelectedElement: () => state.selectedElement,\n        onStartDrag: (ev) => state.dragReorderController?.onDragStart(ev) ?? false,\n        onDragMove: (ev) => state.dragReorderController?.onDragMove(ev),\n        onDragEnd: (ev) => state.dragReorderController?.onDragEnd(ev),\n        onDragCancel: (ev) => state.dragReorderController?.onDragCancel(ev),\n      });\n\n      // Initialize ExecutionTracker for tracking Agent execution status (Phase 3.10)\n      state.executionTracker = createExecutionTracker({\n        onStatusChange: (execState: ExecutionState) => {\n          // Map execution status to toolbar status (only used when HMR verifier is not active)\n          // When verifier is active, it controls toolbar status after execution completes\n          const verifierPhase = state.hmrConsistencyVerifier?.getSnapshot().phase ?? 'idle';\n          const verifierActive = verifierPhase !== 'idle';\n\n          // Only update toolbar directly if verifier is not handling it\n          if (!verifierActive || execState.status !== 'completed') {\n            const statusMap: Record<string, string> = {\n              pending: 'applying',\n              starting: 'starting',\n              running: 'running',\n              locating: 'locating',\n              applying: 'applying',\n              completed: 'completed',\n              failed: 'failed',\n              error: 'failed', // Server may return 'error', treat same as 'failed'\n              timeout: 'timeout',\n              cancelled: 'cancelled',\n            };\n            type ToolbarStatusType = Parameters<NonNullable<typeof state.toolbar>['setStatus']>[0];\n            const toolbarStatus = (statusMap[execState.status] ?? 'running') as ToolbarStatusType;\n            state.toolbar?.setStatus(toolbarStatus, execState.message);\n          }\n\n          // Forward to HMR consistency verifier (Phase 4.8)\n          state.hmrConsistencyVerifier?.onExecutionStatus(execState);\n        },\n      });\n\n      // Initialize HMR Consistency Verifier (Phase 4.8)\n      state.hmrConsistencyVerifier = createHmrConsistencyVerifier({\n        transactionManager: state.transactionManager,\n        getSelectedElement: () => state.selectedElement,\n        onReselect: (element) => handleSelect(element, DEFAULT_MODIFIERS),\n        onDeselect: handleDeselect,\n        setToolbarStatus: (status, message) => state.toolbar?.setStatus(status, message),\n        isOverlayElement: state.shadowHost?.isOverlayElement,\n        selectionEngine: state.selectionEngine ?? undefined,\n      });\n\n      // Initialize Toolbar UI\n      state.toolbar = createToolbar({\n        container: elements.uiRoot,\n        dock: 'top',\n        initialPosition: state.toolbarPosition,\n        onPositionChange: (position) => {\n          state.toolbarPosition = position;\n        },\n        getApplyBlockReason: () => {\n          const tm = state.transactionManager;\n          if (!tm) return undefined;\n\n          const undoStack = tm.getUndoStack();\n          if (undoStack.length === 0) return undefined;\n\n          // Check all transactions for unsupported types (Phase 1.4)\n          // NOTE: We only do O(n) type checking here, NOT aggregation.\n          // Full net effect check happens in applyAllTransactions() to avoid\n          // performance issues during frequent merge events.\n          for (const tx of undoStack) {\n            if (tx.type === 'move') {\n              return 'Apply does not support reorder operations yet';\n            }\n            if (tx.type === 'structure') {\n              return 'Apply does not support structure operations yet';\n            }\n            if (tx.type !== 'style' && tx.type !== 'text' && tx.type !== 'class') {\n              return `Apply does not support \"${tx.type}\" transactions`;\n            }\n          }\n\n          return undefined;\n        },\n        getSelectedElement: () => state.selectedElement,\n        onStructure: (data) => {\n          const target = state.selectedElement;\n          if (!target) return;\n\n          const tm = state.transactionManager;\n          if (!tm) return;\n\n          const tx = tm.applyStructure(target, data);\n          if (!tx) return;\n\n          // Update selection based on action type\n          // For wrap/stack: select the new wrapper\n          // For unwrap: select the unwrapped child\n          // For duplicate: select the clone\n          // For delete: deselect\n          if (data.action === 'delete') {\n            handleDeselect();\n          } else {\n            // The transaction's targetLocator points to the new selection target\n            // For wrap/stack: wrapper\n            // For unwrap: child\n            // For duplicate: clone\n            const newTarget = locateElement(tx.targetLocator);\n            if (newTarget && newTarget.isConnected) {\n              handleSelect(newTarget, DEFAULT_MODIFIERS);\n            }\n          }\n        },\n        onApply: applyAllTransactions,\n        onUndo: () => state.transactionManager?.undo(),\n        onRedo: () => state.transactionManager?.redo(),\n        onRequestClose: () => stop(),\n      });\n\n      // Initialize toolbar history display\n      state.toolbar.setHistory(\n        state.transactionManager.getUndoStack().length,\n        state.transactionManager.getRedoStack().length,\n      );\n\n      // Initialize Breadcrumbs UI (shows selected element ancestry)\n      state.breadcrumbs = createBreadcrumbs({\n        container: elements.uiRoot,\n        dock: 'top',\n        onSelect: (element) => {\n          // When a breadcrumb is clicked, select that ancestor element\n          if (element.isConnected) {\n            handleSelect(element, DEFAULT_MODIFIERS);\n          }\n        },\n      });\n\n      // Initialize Props Bridge (Phase 7)\n      state.propsBridge = createPropsBridge({});\n\n      // Initialize Design Tokens Service (Phase 5.3)\n      state.tokensService = createDesignTokensService();\n\n      // Initialize Property Panel (Phase 3)\n      state.propertyPanel = createPropertyPanel({\n        container: elements.uiRoot,\n        transactionManager: state.transactionManager,\n        propsBridge: state.propsBridge,\n        tokensService: state.tokensService,\n        initialPosition: state.propertyPanelPosition,\n        onPositionChange: (position) => {\n          state.propertyPanelPosition = position;\n        },\n        defaultTab: 'design',\n        onSelectElement: (element) => {\n          // When an element is selected from Components tree\n          if (element.isConnected) {\n            handleSelect(element, DEFAULT_MODIFIERS);\n          }\n        },\n        onRequestClose: () => stop(),\n      });\n\n      // Clamp floating UI positions on window resize (session-only persistence)\n      let uiResizeRafId: number | null = null;\n\n      const clampFloatingUi = (): void => {\n        const toolbarPos = state.toolbarPosition;\n        const panelPos = state.propertyPanelPosition;\n\n        if (state.toolbar && toolbarPos) {\n          state.toolbar.setPosition(toolbarPos);\n        }\n        if (state.propertyPanel && panelPos) {\n          state.propertyPanel.setPosition(panelPos);\n        }\n      };\n\n      const onWindowResize = (): void => {\n        if (!state.active) return;\n        if (uiResizeRafId !== null) return;\n        uiResizeRafId = window.requestAnimationFrame(() => {\n          uiResizeRafId = null;\n          clampFloatingUi();\n        });\n      };\n\n      window.addEventListener('resize', onWindowResize, { passive: true });\n      state.uiResizeCleanup = () => {\n        window.removeEventListener('resize', onWindowResize);\n        if (uiResizeRafId !== null) {\n          window.cancelAnimationFrame(uiResizeRafId);\n          uiResizeRafId = null;\n        }\n      };\n\n      // Ensure restored positions are visible on first render\n      clampFloatingUi();\n\n      state.active = true;\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Started`);\n    } catch (error) {\n      // Cleanup on failure (reverse order)\n      state.uiResizeCleanup?.();\n      state.uiResizeCleanup = null;\n      state.propertyPanel?.dispose();\n      state.propertyPanel = null;\n      state.tokensService?.dispose();\n      state.tokensService = null;\n      state.propsBridge?.dispose();\n      state.propsBridge = null;\n      state.breadcrumbs?.dispose();\n      state.breadcrumbs = null;\n      state.toolbar?.dispose();\n      state.toolbar = null;\n      state.eventController?.dispose();\n      state.eventController = null;\n      state.dragReorderController?.dispose();\n      state.dragReorderController = null;\n      state.handlesController?.dispose();\n      state.handlesController = null;\n      state.transactionManager?.dispose();\n      state.transactionManager = null;\n      state.positionTracker?.dispose();\n      state.positionTracker = null;\n      state.selectionEngine?.dispose();\n      state.selectionEngine = null;\n      state.perfHotkeyCleanup?.();\n      state.perfHotkeyCleanup = null;\n      state.perfMonitor?.dispose();\n      state.perfMonitor = null;\n      state.canvasOverlay?.dispose();\n      state.canvasOverlay = null;\n      state.shadowHost?.dispose();\n      state.shadowHost = null;\n      state.hoveredElement = null;\n      state.selectedElement = null;\n      state.applyingSnapshot = null;\n      state.active = false;\n\n      console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to start:`, error);\n    }\n  }\n\n  /**\n   * Stop the editor\n   */\n  function stop(): void {\n    if (!state.active) {\n      return;\n    }\n\n    state.active = false;\n\n    // Cancel pending debounced broadcasts (Phase 1.4)\n    if (txChangedBroadcastTimer !== null) {\n      window.clearTimeout(txChangedBroadcastTimer);\n      txChangedBroadcastTimer = null;\n    }\n\n    try {\n      // Cleanup in reverse order of initialization\n\n      // Commit any in-progress text edit before cleanup\n      if (editSession) {\n        commitEdit();\n      }\n\n      // Cleanup resize listener for floating UI\n      state.uiResizeCleanup?.();\n      state.uiResizeCleanup = null;\n\n      // Cleanup Property Panel (Phase 3)\n      state.propertyPanel?.dispose();\n      state.propertyPanel = null;\n\n      // Cleanup Design Tokens Service (Phase 5.3)\n      state.tokensService?.dispose();\n      state.tokensService = null;\n\n      // Cleanup Props Bridge (Phase 7) - best effort cleanup\n      void state.propsBridge?.cleanup();\n      state.propsBridge = null;\n\n      // Cleanup Breadcrumbs UI\n      state.breadcrumbs?.dispose();\n      state.breadcrumbs = null;\n\n      // Cleanup Toolbar UI\n      state.toolbar?.dispose();\n      state.toolbar = null;\n\n      // Cleanup Event Controller (stops event interception)\n      state.eventController?.dispose();\n      state.eventController = null;\n\n      // Cleanup Drag Reorder Controller\n      state.dragReorderController?.dispose();\n      state.dragReorderController = null;\n\n      // Cleanup Resize Handles Controller (Phase 4.9)\n      state.handlesController?.dispose();\n      state.handlesController = null;\n\n      // Cleanup Execution Tracker (Phase 3.10)\n      state.executionTracker?.dispose();\n      state.executionTracker = null;\n\n      // Cleanup HMR Consistency Verifier (Phase 4.8)\n      state.hmrConsistencyVerifier?.dispose();\n      state.hmrConsistencyVerifier = null;\n\n      // Cleanup Transaction Manager (clears history)\n      state.transactionManager?.dispose();\n      state.transactionManager = null;\n\n      // Cleanup Position Tracker (stops scroll/resize monitoring)\n      state.positionTracker?.dispose();\n      state.positionTracker = null;\n\n      // Cleanup Selection Engine\n      state.selectionEngine?.dispose();\n      state.selectionEngine = null;\n\n      // Cleanup Performance Monitor (Phase 5.3)\n      state.perfHotkeyCleanup?.();\n      state.perfHotkeyCleanup = null;\n      state.perfMonitor?.dispose();\n      state.perfMonitor = null;\n\n      // Cleanup Canvas Overlay\n      state.canvasOverlay?.dispose();\n      state.canvasOverlay = null;\n\n      // Cleanup Shadow DOM host\n      state.shadowHost?.dispose();\n      state.shadowHost = null;\n\n      // Clear element references and apply state\n      state.hoveredElement = null;\n      state.selectedElement = null;\n      state.applyingSnapshot = null;\n\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Stopped`);\n    } catch (error) {\n      console.error(`${WEB_EDITOR_V2_LOG_PREFIX} Error during cleanup:`, error);\n\n      // Force cleanup\n      state.propertyPanel = null;\n      state.propsBridge = null;\n      state.breadcrumbs = null;\n      state.toolbar = null;\n      state.eventController = null;\n      state.dragReorderController = null;\n      state.handlesController = null;\n      state.transactionManager = null;\n      state.positionTracker = null;\n      state.selectionEngine = null;\n      state.perfHotkeyCleanup = null;\n      state.perfMonitor = null;\n      state.canvasOverlay = null;\n      state.shadowHost = null;\n      state.hoveredElement = null;\n      state.selectedElement = null;\n      state.applyingSnapshot = null;\n    } finally {\n      // Always broadcast clear state to sidepanel (removes chips)\n      broadcastEditorCleared();\n    }\n  }\n\n  /**\n   * Toggle the editor on/off\n   */\n  function toggle(): boolean {\n    if (state.active) {\n      stop();\n    } else {\n      start();\n    }\n    return state.active;\n  }\n\n  /**\n   * Get current editor state\n   */\n  function getState(): WebEditorState {\n    return {\n      active: state.active,\n      version: WEB_EDITOR_V2_VERSION,\n    };\n  }\n\n  return {\n    start,\n    stop,\n    toggle,\n    getState,\n    revertElement,\n    clearSelection,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/element-key.ts",
    "content": "/**\n * Element Key Utilities (Phase 1.2)\n *\n * Generates stable, per-page-lifecycle identifiers for DOM elements.\n *\n * Design goals:\n * - Stable within a single page lifetime (until refresh/reinjection)\n * - Insensitive to class list mutations\n * - Suitable for grouping transactions by element (AgentChat chips)\n * - Supports Shadow DOM context prefixing\n * - Reserves a frame context prefix for future iframe support\n */\n\nimport type { WebEditorElementKey } from '../../../common/web-editor-types';\n\n// =============================================================================\n// State\n// =============================================================================\n\n/** WeakMap cache for stable element keys */\nconst elementKeyCache = new WeakMap<Element, WebEditorElementKey>();\n\n/** WeakMap cache for shadow host stable identifiers */\nconst shadowHostKeyCache = new WeakMap<Element, string>();\n\n/** Auto-increment counter for elements without ID */\nlet autoKeyCounter = 0;\n\n/** Auto-increment counter for shadow hosts without ID */\nlet shadowHostCounter = 0;\n\n/** Cached frame context (computed once per execution context) */\nlet cachedFrameContext: string | undefined;\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Priority order for fallback label attributes */\nconst LABEL_ATTR_PRIORITY = [\n  'data-testid',\n  'data-test-id',\n  'data-test',\n  'data-qa',\n  'data-cy',\n  'name',\n  'aria-label',\n  'title',\n  'alt',\n] as const;\n\n/** Maximum length for attribute values in labels */\nconst MAX_LABEL_ATTR_VALUE_LENGTH = 48;\n\n/** Maximum length for text content in labels */\nconst MAX_TEXT_LABEL_LENGTH = 64;\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\n/**\n * Normalize element tag name to lowercase\n */\nfunction normalizeTagName(element: Element): string {\n  const raw = element?.tagName ? String(element.tagName) : '';\n  const tag = raw.toLowerCase().trim();\n  return tag || 'unknown';\n}\n\n/**\n * Normalize attribute value to trimmed string\n */\nfunction normalizeAttrValue(value: unknown): string {\n  return typeof value === 'string' ? value.trim() : '';\n}\n\n/**\n * Normalize text by collapsing whitespace\n */\nfunction normalizeText(value: string): string {\n  return String(value ?? '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\n/**\n * Truncate string with ellipsis if exceeds max length\n */\nfunction truncate(value: string, maxLength: number): string {\n  const str = String(value ?? '');\n  if (str.length <= maxLength) return str;\n  return str.slice(0, Math.max(0, maxLength - 1)).trimEnd() + '…';\n}\n\n/**\n * Normalize shadow host chain array\n */\nfunction normalizeShadowHostChain(shadowHostChain?: readonly string[]): string[] | undefined {\n  if (!Array.isArray(shadowHostChain) || shadowHostChain.length === 0) {\n    return undefined;\n  }\n  const normalized = shadowHostChain.map((s) => String(s ?? '').trim()).filter(Boolean);\n  return normalized.length > 0 ? normalized : undefined;\n}\n\n/**\n * Get frame context prefix for iframe isolation.\n * Cached for performance.\n */\nfunction getFrameContextPrefix(): string {\n  if (cachedFrameContext !== undefined) return cachedFrameContext;\n\n  let context = '';\n  try {\n    const frameEl = window.frameElement;\n    if (frameEl instanceof HTMLIFrameElement) {\n      const tag = normalizeTagName(frameEl);\n      const id = normalizeAttrValue(frameEl.id || frameEl.getAttribute('id'));\n      if (id) {\n        context = `${tag}#${id}`;\n      } else {\n        const name = normalizeAttrValue(frameEl.name || frameEl.getAttribute('name'));\n        if (name) {\n          context = `${tag}[name=\"${truncate(name, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n        } else {\n          const src = normalizeAttrValue(frameEl.getAttribute('src') || frameEl.src);\n          context = src ? `${tag}[src=\"${truncate(src, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]` : tag;\n        }\n      }\n    }\n  } catch {\n    // Cross-origin frame access may throw\n    context = '';\n  }\n\n  cachedFrameContext = context;\n  return context;\n}\n\n/**\n * Generate a stable identifier for a shadow host element.\n * Uses WeakMap caching to ensure stability across class changes.\n */\nfunction getStableShadowHostKey(host: Element): string {\n  const cached = shadowHostKeyCache.get(host);\n  if (cached) return cached;\n\n  const tag = normalizeTagName(host);\n  const id = normalizeAttrValue(host.id || host.getAttribute('id'));\n  const key = id ? `${tag}#${id}` : `${tag}_h${++shadowHostCounter}`;\n\n  shadowHostKeyCache.set(host, key);\n  return key;\n}\n\n/**\n * Compute Shadow DOM context prefix by walking up the shadow host chain.\n * Uses stable host identifiers (not selectors) to avoid class sensitivity.\n *\n * Note: The provided shadowHostChain parameter is intentionally NOT used\n * for key generation because it may contain class-based selectors.\n * Instead, we derive stable host keys directly from the DOM.\n */\nfunction computeShadowContextPrefix(\n  element: Element,\n  _shadowHostChain?: readonly string[],\n): string {\n  // Build stable host chain by walking up shadow roots\n  // Each host gets a stable key via WeakMap (not selector-based)\n  const hosts: string[] = [];\n  let current: Element = element;\n\n  while (true) {\n    let root: unknown;\n    try {\n      root = current.getRootNode?.();\n    } catch {\n      root = null;\n    }\n\n    if (!(root instanceof ShadowRoot)) break;\n    const host = root.host;\n    if (!(host instanceof Element)) break;\n\n    // Use stable host key (WeakMap cached, class-insensitive)\n    hosts.unshift(getStableShadowHostKey(host));\n    current = host;\n  }\n\n  return hosts.length > 0 ? hosts.join('>') : '';\n}\n\n/**\n * Find the best attribute for labeling an element\n */\nfunction readBestLabelAttribute(element: Element): { attr: string; value: string } | null {\n  for (const attr of LABEL_ATTR_PRIORITY) {\n    const value = normalizeAttrValue(element.getAttribute(attr));\n    if (value) return { attr, value };\n  }\n  return null;\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Generate a stable element key within the current page lifecycle.\n *\n * Key strategy:\n * - Prefer deterministic `tag#id` when id exists\n * - Otherwise assign a stable incremental key via WeakMap caching\n * - Prefix with Shadow DOM host chain and (reserved) iframe context\n *\n * @param element - The target DOM element\n * @param shadowHostChain - Optional shadow host selector chain from outer to inner\n * @returns A stable, unique key for the element\n */\nexport function generateStableElementKey(\n  element: Element,\n  shadowHostChain?: readonly string[],\n): WebEditorElementKey {\n  // Return cached key if available\n  const cached = elementKeyCache.get(element);\n  if (cached) return cached;\n\n  // Generate base key from tag and id\n  const tag = normalizeTagName(element);\n  const id = normalizeAttrValue(element.id || element.getAttribute('id'));\n  const baseKey = id ? `${tag}#${id}` : `${tag}_${++autoKeyCounter}`;\n\n  // Build full key with context prefixes\n  const parts: string[] = [];\n\n  const frame = getFrameContextPrefix();\n  if (frame) parts.push(`frame:${frame}`);\n\n  const shadow = computeShadowContextPrefix(element, shadowHostChain);\n  if (shadow) parts.push(`shadow:${shadow}`);\n\n  parts.push(baseKey);\n\n  const fullKey = parts.join('|');\n  elementKeyCache.set(element, fullKey);\n  return fullKey;\n}\n\n/**\n * Generate a human-friendly label for UI rendering (Chips/tooltips).\n *\n * This label is best-effort and may change if element text/attrs change.\n * It should NOT be used for element identification - use generateStableElementKey instead.\n *\n * @param element - The target DOM element\n * @returns A human-readable label for the element\n */\nexport function generateElementLabel(element: Element): string {\n  const tag = normalizeTagName(element);\n\n  // Prefer ID if available\n  const id = normalizeAttrValue(element.id || element.getAttribute('id'));\n  if (id) return `${tag}#${id}`;\n\n  // Try common labeling attributes\n  const bestAttr = readBestLabelAttribute(element);\n  if (bestAttr) {\n    return `${tag}[${bestAttr.attr}=\"${truncate(bestAttr.value, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n  }\n\n  // Try role attribute\n  const role = normalizeAttrValue(element.getAttribute('role'));\n  if (role) {\n    return `${tag}[role=\"${truncate(role, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n  }\n\n  // Special handling for input elements\n  if (element instanceof HTMLInputElement) {\n    const type = normalizeAttrValue(element.getAttribute('type') || element.type);\n    if (type && type !== 'text') {\n      return `${tag}[type=\"${truncate(type, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n    }\n    const placeholder = normalizeAttrValue(\n      element.getAttribute('placeholder') || element.placeholder,\n    );\n    if (placeholder) {\n      return `${tag}[placeholder=\"${truncate(placeholder, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n    }\n  }\n\n  // Special handling for iframes\n  if (element instanceof HTMLIFrameElement) {\n    const src = normalizeAttrValue(element.getAttribute('src') || element.src);\n    if (src) {\n      return `${tag}[src=\"${truncate(src, MAX_LABEL_ATTR_VALUE_LENGTH)}\"]`;\n    }\n  }\n\n  // Fallback to text content\n  const text = normalizeText(element.textContent ?? '');\n  if (text) return `${tag}(\"${truncate(text, MAX_TEXT_LABEL_LENGTH)}\")`;\n\n  // Last resort: just the tag name\n  return tag;\n}\n\n/**\n * Generate a full label including shadow context for tooltips.\n *\n * @param element - The target DOM element\n * @param shadowHostChain - Optional shadow host selector chain\n * @returns A full label with context for detailed display\n */\nexport function generateFullElementLabel(\n  element: Element,\n  shadowHostChain?: readonly string[],\n): string {\n  const baseLabel = generateElementLabel(element);\n  const shadow = computeShadowContextPrefix(element, shadowHostChain);\n\n  if (shadow) {\n    return `${shadow} >> ${baseLabel}`;\n  }\n\n  return baseLabel;\n}\n\n/**\n * Check if an element key was generated from an element with a stable ID.\n * Elements with IDs are more reliably identifiable across page reloads.\n *\n * Key format: [frame:xxx|][shadow:xxx|]baseKey\n * - ID-based baseKey: \"tag#id\" (contains '#')\n * - Auto-generated baseKey: \"tag_N\" (contains '_' followed by number)\n *\n * @param key - The element key to check\n * @returns True if the key is based on an element ID\n */\nexport function isStableIdBasedKey(key: WebEditorElementKey): boolean {\n  if (!key || typeof key !== 'string') return false;\n\n  // Extract the base key (last part after all prefixes)\n  const parts = key.split('|');\n  const baseKey = parts[parts.length - 1];\n\n  if (!baseKey) return false;\n\n  // ID-based keys have format \"tag#id\" (contains '#')\n  // Auto-generated keys have format \"tag_N\" (underscore followed by digit)\n  // We check for '#' presence AND ensure it's not just in a prefix\n  return baseKey.includes('#') && !baseKey.match(/_\\d+$/);\n}\n\n/**\n * Reset the element key counters for testing purposes.\n *\n * Note: This only resets the auto-increment counters and frame context cache.\n * WeakMap caches cannot be cleared programmatically, but their entries\n * are automatically garbage collected when elements are removed from DOM.\n *\n * For testing, you should create new DOM elements after calling this\n * to ensure they get fresh keys.\n */\nexport function resetElementKeyState(): void {\n  autoKeyCounter = 0;\n  shadowHostCounter = 0;\n  cachedFrameContext = undefined;\n  // WeakMap entries are automatically cleaned up when elements are GC'd\n  // For testing, create new elements to get fresh keys\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/event-controller.ts",
    "content": "/**\n * Event Controller\n *\n * Capture-phase event interceptor for Web Editor V2.\n *\n * Responsibilities:\n * - Intercept document-level pointer/mouse/keyboard events in capture phase\n * - Allow editor UI events (Shadow DOM) to pass through unmodified\n * - Block page interactions while editor is active\n * - Provide hover/selecting mode state machine\n * - Trigger callbacks for element hover, selection, and deselection\n *\n * Performance considerations:\n * - Uses rAF throttling for hover updates (elementFromPoint is expensive)\n * - Supports both PointerEvents (modern) and MouseEvents (fallback)\n * - Events are blocked via stopImmediatePropagation for complete isolation\n */\n\nimport { WEB_EDITOR_V2_DRAG_THRESHOLD_PX } from '../constants';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Mode of the event controller state machine */\nexport type EventControllerMode = 'hover' | 'selecting' | 'editing' | 'dragging';\n\n/** Keyboard modifiers state */\nexport interface EventModifiers {\n  alt: boolean;\n  shift: boolean;\n  ctrl: boolean;\n  meta: boolean;\n}\n\n/** Drag cancel reasons */\nexport type DragCancelReason =\n  | 'escape'\n  | 'pointercancel'\n  | 'mode_change'\n  | 'dispose'\n  | 'blur'\n  | 'visibilitychange';\n\n/** Drag start event data */\nexport interface DragStartEvent {\n  pointerId: number;\n  draggedElement: Element;\n  startClientX: number;\n  startClientY: number;\n  clientX: number;\n  clientY: number;\n  modifiers: EventModifiers;\n}\n\n/** Drag move event data */\nexport interface DragMoveEvent {\n  pointerId: number;\n  clientX: number;\n  clientY: number;\n}\n\n/** Drag end event data */\nexport type DragEndEvent = DragMoveEvent;\n\n/** Drag cancel event data */\nexport interface DragCancelEvent {\n  reason: DragCancelReason;\n}\n\n/** Options for creating the event controller */\nexport interface EventControllerOptions {\n  /** Check if a DOM node belongs to the editor overlay */\n  isOverlayElement: (node: unknown) => boolean;\n  /** Called when hovering over an element (null when hovering over nothing) */\n  onHover: (element: Element | null) => void;\n  /** Called when an element is selected via click */\n  onSelect: (element: Element, modifiers: EventModifiers) => void;\n  /** Called when selection is cancelled (ESC key or mode change) */\n  onDeselect: () => void;\n  /**\n   * Called when user double-clicks an element to start editing.\n   * Return true to enter `editing` mode, false to stay in current mode.\n   */\n  onStartEdit?: (element: Element, modifiers: EventModifiers) => boolean;\n  /**\n   * Optional custom target finder for selection (click).\n   * If not provided, uses simple elementFromPoint.\n   * Only used for selection, not hover (for performance).\n   *\n   * The event parameter enables Shadow DOM-aware selection via composedPath().\n   */\n  findTargetForSelect?: (\n    x: number,\n    y: number,\n    modifiers: EventModifiers,\n    event: PointerEvent | MouseEvent,\n  ) => Element | null;\n  /**\n   * Get the currently selected element (used to gate drag start in selecting mode).\n   */\n  getSelectedElement?: () => Element | null;\n  /**\n   * Called when drag starts (after movement threshold is exceeded).\n   * Return true to enter `dragging` mode.\n   */\n  onStartDrag?: (event: DragStartEvent) => boolean;\n  /** Called for pointer moves while dragging */\n  onDragMove?: (event: DragMoveEvent) => void;\n  /** Called when drag ends (pointerup) */\n  onDragEnd?: (event: DragEndEvent) => void;\n  /** Called when drag is cancelled (ESC/pointercancel/dispose) */\n  onDragCancel?: (event: DragCancelEvent) => void;\n}\n\n/** Event controller public interface */\nexport interface EventController {\n  /** Get current interaction mode */\n  getMode(): EventControllerMode;\n  /** Set interaction mode programmatically */\n  setMode(mode: EventControllerMode): void;\n  /** Cleanup all event listeners */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Common capture-phase listener options */\nconst CAPTURE_OPTIONS: AddEventListenerOptions = {\n  capture: true,\n  passive: false,\n};\n\n/** Events to completely block on document (page interaction prevention) */\nconst BLOCKED_POINTER_EVENTS = [\n  'pointerup',\n  'pointercancel',\n  'pointerover',\n  'pointerout',\n  'pointerenter',\n  'pointerleave',\n] as const;\n\nconst BLOCKED_MOUSE_EVENTS = [\n  'mouseup',\n  'click',\n  'dblclick',\n  'contextmenu',\n  'auxclick',\n  'mouseover',\n  'mouseout',\n  'mouseenter',\n  'mouseleave',\n] as const;\n\nconst BLOCKED_KEYBOARD_EVENTS = ['keyup', 'keypress'] as const;\n\nconst BLOCKED_TOUCH_EVENTS = ['touchstart', 'touchmove', 'touchend', 'touchcancel'] as const;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create an event controller for managing editor interactions.\n *\n * The controller operates in four modes:\n * - `hover`: Mouse movement triggers onHover callbacks, click transitions to selecting\n * - `selecting`: An element is selected, ESC key returns to hover mode\n * - `editing`: Text editing mode for the selected element (Phase 2.7)\n * - `dragging`: Drag reorder mode for the selected element (Phase 2.4-2.6)\n */\nexport function createEventController(options: EventControllerOptions): EventController {\n  const {\n    isOverlayElement,\n    onHover,\n    onSelect,\n    onDeselect,\n    onStartEdit,\n    findTargetForSelect,\n    getSelectedElement,\n    onStartDrag,\n    onDragMove,\n    onDragEnd,\n    onDragCancel,\n  } = options;\n  const disposer = new Disposer();\n\n  // Feature detection for PointerEvents\n  const hasPointerEvents = typeof PointerEvent !== 'undefined';\n\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  let mode: EventControllerMode = 'hover';\n  let lastHoveredElement: Element | null = null;\n  /** Element currently being edited (Phase 2.7) */\n  let editingElement: Element | null = null;\n\n  // ==========================================================================\n  // Drag State (Phase 2.4-2.6)\n  // ==========================================================================\n\n  interface DragCandidate {\n    pointerId: number;\n    startClientX: number;\n    startClientY: number;\n    modifiers: EventModifiers;\n    selectedElement: Element;\n    /** True if this candidate was created by a PointerEvent (not a fallback MouseEvent) */\n    isPointerEventOrigin: boolean;\n  }\n\n  let dragCandidate: DragCandidate | null = null;\n  let draggingPointerId: number | null = null;\n  /** True if the current dragging session was initiated by PointerEvent */\n  let draggingIsPointerOrigin = false;\n  /** Flag to suppress mode_change cancel when we're intentionally leaving dragging */\n  let suppressModeChangeDragCancel = false;\n\n  // Pointer position tracking for rAF-throttled hover updates\n  let hasPointerPosition = false;\n  let lastClientX = 0;\n  let lastClientY = 0;\n\n  // Single rAF management (avoids Disposer array growth)\n  let hoverRafId: number | null = null;\n\n  // ==========================================================================\n  // Helpers\n  // ==========================================================================\n\n  /**\n   * Check if an event originated from the editor UI (Shadow DOM safe)\n   */\n  function isEventFromEditorUi(event: Event): boolean {\n    try {\n      if (typeof event.composedPath === 'function') {\n        return event.composedPath().some((node) => isOverlayElement(node));\n      }\n    } catch {\n      // Fallback to target check\n    }\n    return isOverlayElement(event.target);\n  }\n\n  /**\n   * Check if an event originated from the current editing element (Shadow DOM safe).\n   * Used to allow native interactions (typing, selection) inside the editing element.\n   */\n  function isEventFromEditingElement(event: Event): boolean {\n    const el = editingElement;\n    if (!el) return false;\n\n    try {\n      if (typeof event.composedPath === 'function') {\n        return event.composedPath().some((node) => node === el);\n      }\n    } catch {\n      // Fallback to target check\n    }\n\n    const target = event.target;\n    return target instanceof Node && el.contains(target);\n  }\n\n  /**\n   * Block an event from reaching the page\n   */\n  function blockPageEvent(event: Event): void {\n    if (event.cancelable) {\n      event.preventDefault();\n    }\n    event.stopImmediatePropagation();\n    event.stopPropagation();\n  }\n\n  /** Default modifiers (all false) */\n  const defaultModifiers: EventModifiers = {\n    alt: false,\n    shift: false,\n    ctrl: false,\n    meta: false,\n  };\n\n  /**\n   * Extract modifiers from an event\n   */\n  function extractModifiers(event: MouseEvent | KeyboardEvent): EventModifiers {\n    return {\n      alt: event.altKey,\n      shift: event.shiftKey,\n      ctrl: event.ctrlKey,\n      meta: event.metaKey,\n    };\n  }\n\n  /**\n   * Get pointer ID from event (PointerEvent has pointerId, MouseEvent uses 0)\n   */\n  function getEventPointerId(event: PointerEvent | MouseEvent): number {\n    return event instanceof PointerEvent ? event.pointerId : 0;\n  }\n\n  /**\n   * Check if we should process this event as the primary pointer.\n   * On browsers with PointerEvents, mouse events fire after pointer events;\n   * we use mouse listeners only for blocking, not for interaction logic.\n   */\n  function shouldProcessAsPrimaryPointer(event: PointerEvent | MouseEvent): boolean {\n    if (hasPointerEvents && !(event instanceof PointerEvent)) return false;\n    return true;\n  }\n\n  /**\n   * Check if an event originated from within a specific element (Shadow DOM safe)\n   */\n  function isEventWithinElement(event: Event, element: Element): boolean {\n    try {\n      if (typeof event.composedPath === 'function') {\n        return event.composedPath().some((node) => node === element);\n      }\n    } catch {\n      // Fallback\n    }\n\n    const target = event.target;\n    return target instanceof Node && element.contains(target);\n  }\n\n  /**\n   * Clear all drag-related state\n   */\n  function clearDragState(): void {\n    dragCandidate = null;\n    draggingPointerId = null;\n    draggingIsPointerOrigin = false;\n  }\n\n  /**\n   * Cancel the current dragging session\n   */\n  function cancelDragging(reason: DragCancelReason): void {\n    if (mode !== 'dragging') return;\n\n    clearDragState();\n\n    try {\n      onDragCancel?.({ reason });\n    } catch {\n      // Best-effort\n    }\n\n    suppressModeChangeDragCancel = true;\n    setMode('selecting');\n  }\n\n  /**\n   * End the current dragging session (successful drop)\n   */\n  function endDragging(pointerId: number, clientX: number, clientY: number): void {\n    if (mode !== 'dragging') return;\n    if (draggingPointerId === null || draggingPointerId !== pointerId) return;\n\n    clearDragState();\n\n    try {\n      onDragEnd?.({ pointerId, clientX, clientY });\n    } catch {\n      // Best-effort\n    }\n\n    suppressModeChangeDragCancel = true;\n    setMode('selecting');\n  }\n\n  /**\n   * Get the topmost element at a viewport coordinate (fast, for hover).\n   * Uses simple elementFromPoint to maintain 60FPS hover performance.\n   */\n  function getTargetElementAtFast(clientX: number, clientY: number): Element | null {\n    if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {\n      return null;\n    }\n\n    const element = document.elementFromPoint(clientX, clientY);\n    if (!element) return null;\n\n    // Skip if element is part of the editor overlay\n    if (isOverlayElement(element)) return null;\n\n    return element;\n  }\n\n  /**\n   * Get the best target element for selection (can be slower, uses intelligent picking).\n   * Uses custom findTargetForSelect if provided, otherwise falls back to fast method.\n   *\n   * The event parameter is passed to enable Shadow DOM-aware selection via composedPath().\n   */\n  function getTargetElementForSelection(\n    event: PointerEvent | MouseEvent,\n    clientX: number,\n    clientY: number,\n    modifiers: EventModifiers,\n  ): Element | null {\n    if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) {\n      return null;\n    }\n\n    // Use intelligent target finder if provided (e.g., SelectionEngine)\n    if (findTargetForSelect) {\n      const target = findTargetForSelect(clientX, clientY, modifiers, event);\n      // Defensive check: ensure result is not an overlay element\n      if (target && isOverlayElement(target)) return null;\n      return target;\n    }\n\n    // Fallback: simple elementFromPoint\n    return getTargetElementAtFast(clientX, clientY);\n  }\n\n  // ==========================================================================\n  // Hover Logic (rAF throttled)\n  // ==========================================================================\n\n  /**\n   * Cancel any pending hover rAF\n   */\n  function cancelHoverRaf(): void {\n    if (hoverRafId !== null) {\n      cancelAnimationFrame(hoverRafId);\n      hoverRafId = null;\n    }\n  }\n  // Register cleanup for disposal\n  disposer.add(cancelHoverRaf);\n\n  /**\n   * Commit the hover update by finding element at current pointer position\n   * Allowed in both 'hover' and 'selecting' modes to show hover highlight while element is selected\n   */\n  function commitHoverUpdate(forceUpdate = false): void {\n    hoverRafId = null;\n\n    if (disposer.isDisposed) return;\n    // Allow hover updates in both hover and selecting modes\n    if (mode !== 'hover' && mode !== 'selecting') return;\n    if (!hasPointerPosition) return;\n\n    // Use fast method for hover (60FPS performance)\n    const nextElement = getTargetElementAtFast(lastClientX, lastClientY);\n\n    // Skip if same element (pointer identity check), unless forced\n    if (!forceUpdate && nextElement === lastHoveredElement) return;\n\n    lastHoveredElement = nextElement;\n    onHover(nextElement);\n  }\n\n  /**\n   * Schedule a hover update on the next animation frame\n   */\n  function scheduleHoverUpdate(forceUpdate = false): void {\n    // If already pending, don't schedule another\n    if (hoverRafId !== null) return;\n    if (disposer.isDisposed) return;\n\n    // Use rAF to throttle elementFromPoint calls to once per frame\n    // This prevents performance degradation from high-frequency pointer events\n    hoverRafId = requestAnimationFrame(() => {\n      commitHoverUpdate(forceUpdate);\n    });\n  }\n\n  // ==========================================================================\n  // Mode Management\n  // ==========================================================================\n\n  /**\n   * Set the interaction mode.\n   *\n   * State cleanup invariants:\n   * - Leaving `editing`: clear editingElement\n   * - Leaving `selecting`: clear dragCandidate\n   * - Leaving `dragging`: clear all drag state (candidate + pointer + origin flag)\n   * - Entering `hover`: trigger onDeselect and resume hover tracking\n   */\n  function setMode(nextMode: EventControllerMode): void {\n    if (disposer.isDisposed) return;\n    if (mode === nextMode) return;\n\n    const prevMode = mode;\n    mode = nextMode;\n\n    // Leaving editing mode always clears the tracked editing element\n    if (prevMode === 'editing' && nextMode !== 'editing') {\n      editingElement = null;\n    }\n\n    // Leaving selecting mode: clear drag candidate (but not full drag state)\n    if (prevMode === 'selecting' && nextMode !== 'selecting') {\n      dragCandidate = null;\n    }\n\n    // Leaving dragging mode: notify and reset all drag state\n    if (prevMode === 'dragging' && nextMode !== 'dragging') {\n      const shouldNotify = !suppressModeChangeDragCancel;\n      suppressModeChangeDragCancel = false;\n      clearDragState(); // Clears dragCandidate, draggingPointerId, draggingIsPointerOrigin\n      if (shouldNotify) {\n        try {\n          onDragCancel?.({ reason: 'mode_change' });\n        } catch {\n          // Best-effort\n        }\n      }\n    } else {\n      suppressModeChangeDragCancel = false;\n    }\n\n    // Entering an interaction mode (selecting/editing/dragging) from hover\n    if (prevMode === 'hover' && nextMode !== 'hover') {\n      cancelHoverRaf();\n      lastHoveredElement = null;\n    }\n\n    // Exiting interaction mode back to hover - notify and force resume hover tracking\n    if (nextMode === 'hover' && prevMode !== 'hover') {\n      // Reset lastHoveredElement to force onHover callback even if pointer is on same element\n      lastHoveredElement = null;\n      // Also ensure drag state is clean when returning to hover\n      clearDragState();\n      onDeselect();\n      if (hasPointerPosition) {\n        // Force update to re-highlight element under pointer\n        scheduleHoverUpdate(true);\n      }\n    }\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  /**\n   * Handle pointer/mouse move for hover tracking\n   */\n  function handlePointerMove(event: PointerEvent | MouseEvent): void {\n    // Allow native interactions inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) {\n      return;\n    }\n    // If event is from editor UI, clear hover highlight and return\n    if (isEventFromEditorUi(event)) {\n      if (mode === 'hover' && lastHoveredElement !== null) {\n        lastHoveredElement = null;\n        onHover(null);\n      }\n      return;\n    }\n    blockPageEvent(event);\n\n    // Update tracked position\n    lastClientX = event.clientX;\n    lastClientY = event.clientY;\n    hasPointerPosition = true;\n\n    // Dragging: forward pointer moves (only from matching event type)\n    if (mode === 'dragging' && shouldProcessAsPrimaryPointer(event)) {\n      const pointerId = getEventPointerId(event);\n      const isPointerEvent = event instanceof PointerEvent;\n\n      // Ensure event type matches the origin (prevent Pointer/Mouse conflict)\n      if (draggingIsPointerOrigin !== isPointerEvent) return;\n\n      if (draggingPointerId !== null && pointerId === draggingPointerId) {\n        onDragMove?.({ pointerId, clientX: event.clientX, clientY: event.clientY });\n      }\n      return;\n    }\n\n    // Drag candidate: enter dragging when threshold is exceeded\n    if (mode === 'selecting' && dragCandidate && shouldProcessAsPrimaryPointer(event)) {\n      const pointerId = getEventPointerId(event);\n      if (pointerId !== dragCandidate.pointerId) return;\n\n      // Ensure event type matches the origin (prevent Pointer/Mouse conflict)\n      const isPointerEvent = event instanceof PointerEvent;\n      if (dragCandidate.isPointerEventOrigin !== isPointerEvent) return;\n\n      const dx = event.clientX - dragCandidate.startClientX;\n      const dy = event.clientY - dragCandidate.startClientY;\n      if (Math.hypot(dx, dy) < WEB_EDITOR_V2_DRAG_THRESHOLD_PX) return;\n\n      const startEvent: DragStartEvent = {\n        pointerId,\n        draggedElement: dragCandidate.selectedElement,\n        startClientX: dragCandidate.startClientX,\n        startClientY: dragCandidate.startClientY,\n        clientX: event.clientX,\n        clientY: event.clientY,\n        modifiers: dragCandidate.modifiers,\n      };\n\n      const wasPointerOrigin = dragCandidate.isPointerEventOrigin;\n      dragCandidate = null;\n\n      const started = onStartDrag?.(startEvent) ?? false;\n      if (!started) return;\n\n      draggingPointerId = pointerId;\n      draggingIsPointerOrigin = wasPointerOrigin;\n      setMode('dragging');\n      onDragMove?.({ pointerId, clientX: event.clientX, clientY: event.clientY });\n      return;\n    }\n\n    // Process hover in both hover and selecting modes\n    // This allows showing hover highlight on other elements while one is selected\n    if (mode !== 'hover' && mode !== 'selecting') return;\n    scheduleHoverUpdate();\n  }\n\n  /**\n   * Handle pointer/mouse down for element selection\n   */\n  function handlePointerDown(event: PointerEvent | MouseEvent): void {\n    // Allow native interactions inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) return;\n    if (isEventFromEditorUi(event)) return;\n    blockPageEvent(event);\n\n    // Update tracked position\n    lastClientX = event.clientX;\n    lastClientY = event.clientY;\n    hasPointerPosition = true;\n\n    // Left-click only\n    if (event.button !== 0) return;\n\n    // Extract modifiers for intelligent selection\n    const modifiers = extractModifiers(event);\n\n    // In selecting mode: handle click for reselection or drag preparation\n    if (mode === 'selecting') {\n      if (!shouldProcessAsPrimaryPointer(event)) return;\n\n      const selected = getSelectedElement?.() ?? null;\n\n      // Always try to find the best target element first (enables child selection & drill-in/up)\n      const target = getTargetElementForSelection(event, event.clientX, event.clientY, modifiers);\n\n      // If target is different from current selection, reselect (including child elements)\n      if (target && target !== selected) {\n        dragCandidate = null;\n        onSelect(target, modifiers);\n        return;\n      }\n\n      // Target is the same as current selection (or no valid target):\n      // prepare drag candidate if clicking within selection subtree\n      if (\n        onStartDrag &&\n        selected &&\n        selected.isConnected &&\n        isEventWithinElement(event, selected)\n      ) {\n        const isPointerOrigin = event instanceof PointerEvent;\n\n        dragCandidate = {\n          pointerId: getEventPointerId(event),\n          startClientX: event.clientX,\n          startClientY: event.clientY,\n          modifiers,\n          selectedElement: selected,\n          isPointerEventOrigin: isPointerOrigin,\n        };\n      }\n      return;\n    }\n\n    // Ignore additional pointerdowns while dragging\n    if (mode === 'dragging') {\n      return;\n    }\n\n    // While editing: clicking outside commits and transitions to selecting\n    if (mode === 'editing') {\n      const target = getTargetElementForSelection(event, event.clientX, event.clientY, modifiers);\n      setMode('selecting');\n      if (target) {\n        onSelect(target, modifiers);\n      }\n      return;\n    }\n\n    // Only process in hover mode\n    if (mode !== 'hover') return;\n\n    // Use intelligent selection for click (can afford more computation)\n    // Pass event to enable Shadow DOM-aware selection via composedPath()\n    const target = getTargetElementForSelection(event, event.clientX, event.clientY, modifiers);\n    if (!target) return;\n\n    // Transition to selecting mode\n    setMode('selecting');\n    onSelect(target, modifiers);\n  }\n\n  /**\n   * Handle double-click for entering edit mode (Phase 2.7)\n   */\n  function handleDoubleClick(event: MouseEvent): void {\n    // Allow native text selection inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) {\n      return;\n    }\n    if (isEventFromEditorUi(event)) return;\n    blockPageEvent(event);\n\n    if (event.button !== 0) return;\n    if (!onStartEdit) return;\n\n    const modifiers = extractModifiers(event);\n    const target = getTargetElementForSelection(event, event.clientX, event.clientY, modifiers);\n    if (!target) return;\n\n    const started = onStartEdit(target, modifiers);\n    if (!started) return;\n\n    editingElement = target;\n    setMode('editing');\n  }\n\n  /**\n   * Handle keydown for ESC cancellation\n   */\n  function handleKeyDown(event: KeyboardEvent): void {\n    // Allow native typing inside the editing element (editor handles Escape via blur)\n    if (mode === 'editing' && isEventFromEditingElement(event)) {\n      return;\n    }\n    if (isEventFromEditorUi(event)) return;\n    blockPageEvent(event);\n\n    if (event.key === 'Escape') {\n      // ESC cancels dragging first (but keeps selection)\n      if (mode === 'dragging') {\n        cancelDragging('escape');\n        return;\n      }\n\n      // ESC key cancels selection\n      if (mode === 'selecting') {\n        dragCandidate = null;\n        setMode('hover');\n      }\n    }\n  }\n\n  /**\n   * Handle pointerup/mouseup for ending drag\n   */\n  function handlePointerUp(event: PointerEvent | MouseEvent): void {\n    // Allow native interactions inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) return;\n    if (isEventFromEditorUi(event)) return;\n    blockPageEvent(event);\n\n    if (!shouldProcessAsPrimaryPointer(event)) {\n      return;\n    }\n\n    const pointerId = getEventPointerId(event);\n    const isPointerEvent = event instanceof PointerEvent;\n\n    // Clear candidate on pointerup (only if event type matches)\n    if (\n      mode === 'selecting' &&\n      dragCandidate &&\n      dragCandidate.pointerId === pointerId &&\n      dragCandidate.isPointerEventOrigin === isPointerEvent\n    ) {\n      dragCandidate = null;\n    }\n\n    // End dragging on pointerup (only if event type matches)\n    if (mode === 'dragging' && draggingIsPointerOrigin === isPointerEvent) {\n      endDragging(pointerId, event.clientX, event.clientY);\n    }\n  }\n\n  /**\n   * Handle pointercancel for cancelling drag.\n   * Note: pointercancel is a PointerEvent-only event, so we only process\n   * drag state that was initiated by PointerEvents.\n   */\n  function handlePointerCancel(event: PointerEvent): void {\n    // Allow native interactions inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) return;\n    if (isEventFromEditorUi(event)) return;\n    blockPageEvent(event);\n\n    const pointerId = event.pointerId;\n\n    // Clear candidate on cancel (only if it was created by PointerEvent)\n    if (\n      dragCandidate &&\n      dragCandidate.pointerId === pointerId &&\n      dragCandidate.isPointerEventOrigin\n    ) {\n      dragCandidate = null;\n    }\n\n    if (mode !== 'dragging') return;\n    // Only cancel if the dragging was initiated by PointerEvent\n    if (!draggingIsPointerOrigin) return;\n    if (draggingPointerId === null || draggingPointerId !== pointerId) return;\n\n    cancelDragging('pointercancel');\n  }\n\n  /**\n   * Generic blocker for events that should never reach the page\n   */\n  function handleBlockedEvent(event: Event): void {\n    if (isEventFromEditorUi(event)) return;\n    // Allow native interactions inside the editing element\n    if (mode === 'editing' && isEventFromEditingElement(event)) return;\n\n    // Route dblclick to the handler instead of just blocking\n    if (event.type === 'dblclick') {\n      handleDoubleClick(event as MouseEvent);\n      return;\n    }\n\n    // Route pointerup/mouseup to end drag candidate / dragging session\n    if (event.type === 'pointerup' || event.type === 'mouseup') {\n      handlePointerUp(event as PointerEvent | MouseEvent);\n      return;\n    }\n\n    // Route pointercancel to cancel dragging session\n    if (event.type === 'pointercancel') {\n      handlePointerCancel(event as PointerEvent);\n      return;\n    }\n\n    blockPageEvent(event);\n  }\n\n  // ==========================================================================\n  // Event Registration\n  // ==========================================================================\n\n  // Register pointer events (modern browsers)\n  if (hasPointerEvents) {\n    disposer.listen(document, 'pointermove', handlePointerMove, CAPTURE_OPTIONS);\n    disposer.listen(document, 'pointerdown', handlePointerDown, CAPTURE_OPTIONS);\n\n    for (const eventType of BLOCKED_POINTER_EVENTS) {\n      disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS);\n    }\n  }\n\n  // Register mouse events (fallback for older browsers, or when pointer events are unavailable)\n  // Note: On modern browsers with PointerEvents, mouse events still fire after pointer events,\n  // so we always register them to ensure complete blocking\n  disposer.listen(document, 'mousemove', handlePointerMove, CAPTURE_OPTIONS);\n  disposer.listen(document, 'mousedown', handlePointerDown, CAPTURE_OPTIONS);\n\n  for (const eventType of BLOCKED_MOUSE_EVENTS) {\n    disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS);\n  }\n\n  // Register keyboard events\n  disposer.listen(document, 'keydown', handleKeyDown, CAPTURE_OPTIONS);\n\n  for (const eventType of BLOCKED_KEYBOARD_EVENTS) {\n    disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS);\n  }\n\n  // Register touch events (prevent touch interactions on mobile)\n  for (const eventType of BLOCKED_TOUCH_EVENTS) {\n    disposer.listen(document, eventType, handleBlockedEvent, CAPTURE_OPTIONS);\n  }\n\n  // ==========================================================================\n  // Window/Page Focus Events (cancel dragging on blur/visibility change)\n  // ==========================================================================\n\n  /**\n   * Cancel dragging when window loses focus.\n   * This prevents the UI from getting stuck with pointer-events: none\n   * if the user switches to another application mid-drag.\n   */\n  function handleWindowBlur(): void {\n    // Clear drag candidate in selecting mode\n    if (mode === 'selecting' && dragCandidate) {\n      dragCandidate = null;\n    }\n    // Cancel active dragging\n    if (mode === 'dragging') {\n      cancelDragging('blur');\n    }\n  }\n\n  /**\n   * Cancel dragging when page becomes hidden.\n   * This handles cases like switching browser tabs.\n   */\n  function handleVisibilityChange(): void {\n    if (document.visibilityState !== 'visible') {\n      // Clear drag candidate in selecting mode\n      if (mode === 'selecting' && dragCandidate) {\n        dragCandidate = null;\n      }\n      // Cancel active dragging\n      if (mode === 'dragging') {\n        cancelDragging('visibilitychange');\n      }\n    }\n  }\n\n  disposer.listen(window, 'blur', handleWindowBlur);\n  disposer.listen(document, 'visibilitychange', handleVisibilityChange);\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  // Cleanup drag state on dispose\n  disposer.add(() => {\n    if (mode === 'dragging') {\n      try {\n        onDragCancel?.({ reason: 'dispose' });\n      } catch {\n        // Best-effort\n      }\n    }\n    clearDragState();\n  });\n\n  return {\n    getMode: () => mode,\n    setMode,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/execution-tracker.ts",
    "content": "/**\n * Execution Tracker (Phase 3.10)\n *\n * Tracks Agent execution status for Apply operations via background polling.\n * Provides real-time feedback on execution progress without requiring SSE.\n *\n * Design:\n * - Uses message passing to background for status queries\n * - Lightweight polling approach (avoids complexity of SSE in content script)\n * - Disposer pattern for cleanup\n */\n\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * Execution status phases.\n * Note: 'error' is included for compatibility with AgentStatusEvent from server.\n * Both 'error' and 'failed' are treated as terminal failure states.\n */\nexport type ExecutionStatus =\n  | 'pending'\n  | 'starting'\n  | 'running'\n  | 'locating'\n  | 'applying'\n  | 'completed'\n  | 'failed'\n  | 'error' // Agent server uses 'error', we accept both\n  | 'timeout'\n  | 'cancelled';\n\n/** Execution state */\nexport interface ExecutionState {\n  requestId: string;\n  sessionId: string;\n  status: ExecutionStatus;\n  message?: string;\n  startedAt: number;\n  updatedAt: number;\n  result?: {\n    success: boolean;\n    summary?: string;\n    error?: string;\n  };\n}\n\n/** Status update callback */\nexport type StatusCallback = (state: ExecutionState) => void;\n\n/** Tracker options */\nexport interface ExecutionTrackerOptions {\n  /** Polling interval in ms (default: 2000) */\n  pollInterval?: number;\n  /** Timeout for execution in ms (default: 120000 = 2 min) */\n  timeout?: number;\n  /** Callback when status changes */\n  onStatusChange?: StatusCallback;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_POLL_INTERVAL = 2000;\nconst DEFAULT_TIMEOUT = 120000;\n\n// Terminal statuses that stop polling\n// Note: 'error' is included for compatibility with AgentStatusEvent\nconst TERMINAL_STATUSES: ExecutionStatus[] = [\n  'completed',\n  'failed',\n  'error',\n  'timeout',\n  'cancelled',\n];\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isTerminalStatus(status: ExecutionStatus): boolean {\n  return TERMINAL_STATUSES.includes(status);\n}\n\nfunction getStatusMessage(status: ExecutionStatus): string {\n  switch (status) {\n    case 'pending':\n      return 'Waiting...';\n    case 'starting':\n      return 'Starting Agent...';\n    case 'running':\n      return 'Running...';\n    case 'locating':\n      return 'Locating code...';\n    case 'applying':\n      return 'Applying changes...';\n    case 'completed':\n      return 'Completed';\n    case 'failed':\n    case 'error': // Agent server uses 'error', treat same as 'failed'\n      return 'Failed';\n    case 'timeout':\n      return 'Timed out';\n    case 'cancelled':\n      return 'Cancelled';\n    default:\n      return '';\n  }\n}\n\n// =============================================================================\n// ExecutionTracker Class\n// =============================================================================\n\nexport class ExecutionTracker {\n  private disposer = new Disposer();\n  private executions = new Map<string, ExecutionState>();\n  private pollTimers = new Map<string, number>();\n  private pollInterval: number;\n  private timeout: number;\n  private onStatusChange?: StatusCallback;\n\n  constructor(options: ExecutionTrackerOptions = {}) {\n    this.pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL;\n    this.timeout = options.timeout ?? DEFAULT_TIMEOUT;\n    this.onStatusChange = options.onStatusChange;\n\n    this.disposer.add(() => this.stopAllPolling());\n  }\n\n  /**\n   * Track a new execution by requestId\n   */\n  track(requestId: string, sessionId: string): ExecutionState {\n    const now = Date.now();\n    const state: ExecutionState = {\n      requestId,\n      sessionId,\n      status: 'pending',\n      message: getStatusMessage('pending'),\n      startedAt: now,\n      updatedAt: now,\n    };\n\n    this.executions.set(requestId, state);\n    this.startPolling(requestId);\n\n    return state;\n  }\n\n  /**\n   * Get current state for a request\n   */\n  getState(requestId: string): ExecutionState | undefined {\n    return this.executions.get(requestId);\n  }\n\n  /**\n   * Cancel tracking for a request.\n   * Sends a real cancel request to the background to abort the execution on the server.\n   * @returns Promise that resolves when cancel is complete (or fails silently)\n   */\n  async cancel(requestId: string): Promise<void> {\n    const state = this.executions.get(requestId);\n    if (!state) return;\n\n    // Stop polling immediately\n    this.stopPolling(requestId);\n\n    // Don't cancel if already in terminal state\n    if (isTerminalStatus(state.status)) return;\n\n    // Update local state immediately for responsive UI\n    this.updateState(requestId, {\n      status: 'cancelled',\n      message: 'Cancelling...',\n    });\n\n    // Send cancel request to background\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_CANCEL_EXECUTION,\n        payload: {\n          sessionId: state.sessionId,\n          requestId: state.requestId,\n        },\n      });\n\n      // Update message based on response\n      if (response?.success) {\n        this.updateState(requestId, {\n          status: 'cancelled',\n          message: 'Cancelled by user',\n        });\n      } else {\n        // Cancel request failed, but we still mark as cancelled locally\n        console.warn('[ExecutionTracker] Cancel request failed:', response?.error);\n        this.updateState(requestId, {\n          status: 'cancelled',\n          message: 'Cancelled (server may still be running)',\n        });\n      }\n    } catch (error) {\n      // Network/extension error, still mark as cancelled locally\n      console.warn('[ExecutionTracker] Cancel request error:', error);\n      this.updateState(requestId, {\n        status: 'cancelled',\n        message: 'Cancelled by user',\n      });\n    }\n  }\n\n  /**\n   * Manually update status (for background message handler)\n   */\n  updateFromBackground(\n    requestId: string,\n    update: {\n      status: ExecutionStatus;\n      message?: string;\n      result?: ExecutionState['result'];\n    },\n  ): void {\n    this.updateState(requestId, update);\n  }\n\n  /**\n   * Clean up\n   */\n  dispose(): void {\n    this.disposer.dispose();\n    this.executions.clear();\n  }\n\n  // ===========================================================================\n  // Private Methods\n  // ===========================================================================\n\n  private startPolling(requestId: string): void {\n    // Check for timeout\n    const timeoutTimer = window.setTimeout(() => {\n      const state = this.executions.get(requestId);\n      if (state && !isTerminalStatus(state.status)) {\n        this.updateState(requestId, {\n          status: 'timeout',\n          message: 'Execution timed out',\n        });\n        this.stopPolling(requestId);\n      }\n    }, this.timeout);\n\n    this.disposer.add(() => window.clearTimeout(timeoutTimer));\n\n    // Start polling\n    const poll = async () => {\n      const state = this.executions.get(requestId);\n      if (!state || isTerminalStatus(state.status)) {\n        this.stopPolling(requestId);\n        return;\n      }\n\n      try {\n        const result = await this.queryStatus(requestId, state.sessionId);\n        if (result) {\n          this.updateState(requestId, result);\n          if (isTerminalStatus(result.status)) {\n            this.stopPolling(requestId);\n            return;\n          }\n        }\n      } catch {\n        // Ignore polling errors, will retry\n      }\n\n      // Schedule next poll if not disposed\n      if (!this.disposer.isDisposed) {\n        const timer = window.setTimeout(poll, this.pollInterval);\n        this.pollTimers.set(requestId, timer);\n      }\n    };\n\n    // Initial poll after a short delay\n    const initialTimer = window.setTimeout(poll, 500);\n    this.pollTimers.set(requestId, initialTimer);\n  }\n\n  private stopPolling(requestId: string): void {\n    const timer = this.pollTimers.get(requestId);\n    if (timer !== undefined) {\n      window.clearTimeout(timer);\n      this.pollTimers.delete(requestId);\n    }\n  }\n\n  private stopAllPolling(): void {\n    for (const timer of this.pollTimers.values()) {\n      window.clearTimeout(timer);\n    }\n    this.pollTimers.clear();\n  }\n\n  private updateState(\n    requestId: string,\n    update: Partial<Pick<ExecutionState, 'status' | 'message' | 'result'>>,\n  ): void {\n    const state = this.executions.get(requestId);\n    if (!state) return;\n\n    const newState: ExecutionState = {\n      ...state,\n      ...update,\n      updatedAt: Date.now(),\n    };\n\n    // Auto-generate message if not provided\n    if (update.status && !update.message) {\n      newState.message = getStatusMessage(update.status);\n    }\n\n    this.executions.set(requestId, newState);\n    this.onStatusChange?.(newState);\n  }\n\n  private async queryStatus(\n    requestId: string,\n    sessionId: string,\n  ): Promise<{\n    status: ExecutionStatus;\n    message?: string;\n    result?: ExecutionState['result'];\n  } | null> {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_STATUS_QUERY,\n        requestId,\n        sessionId,\n      });\n\n      if (response?.status) {\n        return {\n          status: response.status as ExecutionStatus,\n          message: response.message,\n          result: response.result,\n        };\n      }\n    } catch {\n      // Extension context invalidated or other error\n    }\n\n    return null;\n  }\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create an ExecutionTracker instance\n */\nexport function createExecutionTracker(options?: ExecutionTrackerOptions): ExecutionTracker {\n  return new ExecutionTracker(options);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/hmr-consistency.ts",
    "content": "/**\n * HMR Consistency Verifier (Phase 4.8)\n *\n * Verifies that visual edits remain consistent after HMR (Hot Module Replacement).\n *\n * Problem: User edits → Apply to Code → Agent modifies source → HMR → DOM may change\n * Solution: After execution completes, wait for DOM to stabilize, then verify:\n * - Target element still exists (or can be re-identified)\n * - Style/text values match expectations\n *\n * Design:\n * - Quiet-window strategy: Wait until DOM mutations settle before verifying\n * - Multi-tier element resolution: current → strict → relaxed → geometric\n * - Computed style comparison (format-agnostic)\n *\n * State machine phases:\n * - idle: No active verification\n * - executing: Apply sent, waiting for Agent completion\n * - settling: Agent completed, waiting for HMR to stabilize\n * - verifying: Running verification checks\n */\n\nimport type { ElementLocator, Transaction } from '@/common/web-editor-types';\nimport type { ExecutionState } from './execution-tracker';\nimport type { TransactionChangeEvent, TransactionManager } from './transaction-manager';\nimport type { ToolbarStatus } from '../ui/toolbar';\nimport type { SelectionEngine } from '../selection/selection-engine';\nimport { Disposer } from '../utils/disposables';\nimport { computeDomPath, computeFingerprint, locateElement } from './locator';\nimport {\n  compareComputed,\n  normalizeText,\n  readComputedMap,\n  type CompareComputedResult,\n} from './css-compare';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Verification phase in the state machine */\nexport type HmrConsistencyPhase = 'idle' | 'executing' | 'settling' | 'verifying';\n\n/** Final outcome of verification */\nexport type HmrConsistencyOutcome = 'verified' | 'mismatch' | 'lost' | 'uncertain' | 'skipped';\n\n/** Confidence level for element resolution */\nexport type MatchConfidence = 'high' | 'medium' | 'low';\n\n/** Source of element resolution */\nexport type ResolveSource = 'current' | 'strict' | 'relaxed' | 'geometric';\n\n/** Resolved target element with metadata */\nexport interface HmrResolvedTarget {\n  readonly element: Element;\n  readonly source: ResolveSource;\n  readonly confidence: MatchConfidence;\n  readonly score?: number;\n}\n\n/** Text comparison diff */\nexport interface HmrTextDiff {\n  readonly expected: string;\n  readonly actual: string;\n  readonly match: boolean;\n}\n\n/** Complete verification result */\nexport interface HmrConsistencyResult {\n  readonly outcome: HmrConsistencyOutcome;\n  readonly reason?: string;\n  readonly requestId?: string;\n  readonly sessionId?: string;\n  readonly txId: string;\n  readonly txTimestamp: number;\n  readonly txType: Transaction['type'];\n  readonly resolved?: Omit<HmrResolvedTarget, 'element'>;\n  readonly style?: CompareComputedResult;\n  readonly text?: HmrTextDiff;\n  readonly signals: {\n    readonly hadRelevantMutation: boolean;\n    readonly hadElementDisconnect: boolean;\n  };\n  readonly timing: {\n    readonly startedAt: number;\n    readonly executionCompletedAt?: number;\n    readonly finalizedAt: number;\n  };\n}\n\n/** Current state snapshot */\nexport interface HmrConsistencySnapshot {\n  readonly phase: HmrConsistencyPhase;\n  readonly activeRequestId?: string;\n  readonly activeTxId?: string;\n  readonly lastResult: HmrConsistencyResult | null;\n}\n\n/** Arguments for starting a verification session */\nexport interface StartHmrConsistencyArgs {\n  /** The applied transaction (style or text) */\n  readonly tx: Transaction;\n  /** Request ID from Agent for correlation */\n  readonly requestId?: string;\n  /** Agent session ID */\n  readonly sessionId?: string;\n  /** Element at Apply time (current selection) */\n  readonly element: Element | null;\n}\n\n/** Verifier configuration options */\nexport interface HmrConsistencyVerifierOptions {\n  /** Transaction manager for checking tx stack state */\n  readonly transactionManager: TransactionManager;\n\n  /** Get current selected element */\n  readonly getSelectedElement?: () => Element | null;\n  /** Called when verifier wants to reselect a resolved element */\n  readonly onReselect?: (element: Element) => void;\n  /** Called when verifier wants to clear selection */\n  readonly onDeselect?: () => void;\n\n  /** Set toolbar status */\n  readonly setToolbarStatus?: (status: ToolbarStatus, message?: string) => void;\n  /** Called when verification completes */\n  readonly onResult?: (result: HmrConsistencyResult) => void;\n\n  /** Filter for editor overlay elements */\n  readonly isOverlayElement?: (node: unknown) => boolean;\n  /** Selection engine for geometric fallback */\n  readonly selectionEngine?: SelectionEngine;\n\n  /** Quiet window duration (ms) - wait for mutations to settle */\n  readonly quietWindowMs?: number;\n  /** Maximum time to wait for HMR (ms) */\n  readonly settleDeadlineMs?: number;\n  /** Time after which no HMR signal means uncertain (ms) */\n  readonly noSignalDeadlineMs?: number;\n\n  /** Max elements to scan in relaxed locate */\n  readonly relaxedLocateMaxElements?: number;\n  /** Max candidates for geometric fallback */\n  readonly geometricMaxCandidates?: number;\n}\n\n/** Public verifier interface */\nexport interface HmrConsistencyVerifier {\n  /** Start tracking a new Apply operation */\n  start(args: StartHmrConsistencyArgs): void;\n  /** Handle execution status update from ExecutionTracker */\n  onExecutionStatus(state: ExecutionState): void;\n  /** Handle transaction change (user edit/undo/redo) */\n  onTransactionChange(event: TransactionChangeEvent): void;\n  /** Handle selection change */\n  onSelectionChange(element: Element | null): void;\n  /** Get current state snapshot */\n  getSnapshot(): HmrConsistencySnapshot;\n  /** Cleanup */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_QUIET_WINDOW_MS = 300;\nconst DEFAULT_SETTLE_DEADLINE_MS = 8000;\nconst DEFAULT_NO_SIGNAL_DEADLINE_MS = 2000;\nconst DEFAULT_RELAXED_LOCATE_MAX_ELEMENTS = 200;\nconst DEFAULT_GEOMETRIC_MAX_CANDIDATES = 16;\n\n/** MutationObserver options for head (CSS changes) */\nconst HEAD_MUTATION_OPTIONS: MutationObserverInit = {\n  childList: true,\n  subtree: true,\n  characterData: true,\n  attributes: true,\n};\n\n/** MutationObserver options for DOM (structure, text, and relevant attribute changes) */\nconst DOM_MUTATION_OPTIONS: MutationObserverInit = {\n  childList: true,\n  subtree: true,\n  attributes: true,\n  attributeFilter: ['class', 'style', 'id'],\n  characterData: true, // Needed for text content changes\n};\n\n// Note: 'error' is included for compatibility with AgentStatusEvent from server\nconst TERMINAL_EXEC_STATUSES = new Set(['completed', 'failed', 'error', 'timeout', 'cancelled']);\n\n// Scoring thresholds for resolution confidence\nconst RELAXED_CONFIDENCE_THRESHOLD = 8;\nconst GEOMETRIC_CONFIDENCE_THRESHOLD = 6;\n\n// =============================================================================\n// Internal Session State\n// =============================================================================\n\ninterface SessionState {\n  readonly key: string;\n  phase: HmrConsistencyPhase;\n\n  // Correlation\n  readonly requestId?: string;\n  readonly sessionId?: string;\n\n  // Transaction info\n  readonly txId: string;\n  readonly txTimestamp: number;\n  readonly txType: Transaction['type'];\n  readonly locator: ElementLocator;\n\n  // Original state\n  originalElement: Element | null;\n  readonly expectedStyle: { properties: string[]; computed: Record<string, string> } | null;\n  readonly expectedText: string | null;\n  readonly anchorRect: DOMRect | null;\n  readonly anchorCenter: { x: number; y: number } | null;\n\n  // Timing\n  startedAt: number;\n  executionCompletedAt: number | null;\n\n  // Signals\n  signals: {\n    hadRelevantMutation: boolean;\n    hadElementDisconnect: boolean;\n  };\n\n  // Flags\n  flags: {\n    verifying: boolean;\n    selectionChanged: boolean;\n    suppressNextSelectionChange: boolean;\n  };\n\n  // Timers\n  timers: {\n    quietTimer: number | null;\n    deadlineTimer: number | null;\n    noSignalTimer: number | null;\n  };\n\n  // Resources\n  readonly disposer: Disposer;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction safeReadRect(element: Element): DOMRect | null {\n  try {\n    const r = element.getBoundingClientRect();\n    if (!Number.isFinite(r.left) || !Number.isFinite(r.top)) return null;\n    if (!Number.isFinite(r.width) || !Number.isFinite(r.height)) return null;\n    return r;\n  } catch {\n    return null;\n  }\n}\n\nfunction rectCenter(rect: DOMRect): { x: number; y: number } {\n  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };\n}\n\nfunction safeElementsFromPoint(x: number, y: number): Element[] {\n  try {\n    const els = document.elementsFromPoint(x, y);\n    return Array.isArray(els) ? els.filter((e): e is Element => e instanceof Element) : [];\n  } catch {\n    try {\n      const el = document.elementFromPoint(x, y);\n      return el ? [el] : [];\n    } catch {\n      return [];\n    }\n  }\n}\n\nfunction safeQuerySelectorAll(root: ParentNode, selector: string, maxCount: number): Element[] {\n  try {\n    const list = root.querySelectorAll(selector);\n    const out: Element[] = [];\n    const limit = Math.min(maxCount, list.length);\n    for (let i = 0; i < limit; i++) out.push(list[i]!);\n    return out;\n  } catch {\n    return [];\n  }\n}\n\nfunction isHtmlOrBody(element: Element): boolean {\n  const tag = element.tagName?.toUpperCase?.() ?? '';\n  return tag === 'HTML' || tag === 'BODY';\n}\n\nfunction isValidCandidate(\n  element: Element,\n  isOverlayElement?: (node: unknown) => boolean,\n): boolean {\n  if (!element.isConnected) return false;\n  if (isHtmlOrBody(element)) return false;\n  if (isOverlayElement?.(element)) return false;\n  return true;\n}\n\n/** Parsed fingerprint structure */\ninterface ParsedFingerprint {\n  tag: string;\n  id?: string;\n  classes: string[];\n  text?: string;\n}\n\nfunction parseFingerprint(raw: string): ParsedFingerprint {\n  const parts = String(raw ?? '')\n    .trim()\n    .split('|')\n    .filter(Boolean);\n  const tag = parts[0] || 'unknown';\n\n  let id: string | undefined;\n  let classes: string[] = [];\n  let text: string | undefined;\n\n  for (const part of parts.slice(1)) {\n    if (part.startsWith('id=')) id = part.slice(3) || undefined;\n    else if (part.startsWith('class=')) classes = part.slice(6).split('.').filter(Boolean);\n    else if (part.startsWith('text=')) text = part.slice(5) || undefined;\n  }\n\n  return { tag, id, classes, text };\n}\n\nfunction intersectCount(a: readonly string[], b: readonly string[]): number {\n  if (!a.length || !b.length) return 0;\n  const set = new Set(b);\n  return a.filter((item) => set.has(item)).length;\n}\n\nfunction commonPrefixLength(a: readonly number[], b: readonly number[]): number {\n  const n = Math.min(a.length, b.length);\n  let i = 0;\n  while (i < n && a[i] === b[i]) i++;\n  return i;\n}\n\n/** Score an element candidate against expected fingerprint */\nfunction scoreCandidate(params: {\n  element: Element;\n  expected: ParsedFingerprint;\n  locator: ElementLocator;\n  anchorCenter?: { x: number; y: number } | null;\n}): number {\n  const { element, expected, locator, anchorCenter } = params;\n  const candidateFp = parseFingerprint(computeFingerprint(element));\n\n  // Tag must match\n  if (candidateFp.tag !== expected.tag) return -Infinity;\n\n  // ID must match if expected has one\n  if (expected.id && candidateFp.id !== expected.id) return -Infinity;\n\n  let score = 0;\n\n  // Strong ID anchor\n  if (expected.id) score += 12;\n\n  // Class overlap (soft match)\n  score += Math.min(8, intersectCount(expected.classes, candidateFp.classes) * 2);\n\n  // Text hint (soft match)\n  if (expected.text) {\n    const expectedText = normalizeText(expected.text);\n    const actualText = normalizeText(element.textContent ?? '');\n    if (actualText.includes(expectedText)) score += 4;\n  }\n\n  // DOM path similarity\n  if (locator.path?.length) {\n    const path = computeDomPath(element);\n    const prefix = commonPrefixLength(locator.path, path);\n    score += (prefix / locator.path.length) * 6;\n    if (prefix === locator.path.length && path.length === locator.path.length) score += 2;\n  }\n\n  // Geometry similarity\n  if (anchorCenter && Number.isFinite(anchorCenter.x)) {\n    const rect = safeReadRect(element);\n    if (rect) {\n      const c = rectCenter(rect);\n      const dist = Math.hypot(c.x - anchorCenter.x, c.y - anchorCenter.y);\n      score += Math.max(0, 6 - dist / 50);\n    }\n  }\n\n  return score;\n}\n\n/** Relaxed locate: scan selectors without uniqueness constraint, score candidates */\nfunction relaxedLocate(params: {\n  locator: ElementLocator;\n  expected: ParsedFingerprint;\n  isOverlayElement?: (node: unknown) => boolean;\n  maxElements: number;\n  anchorCenter?: { x: number; y: number } | null;\n}): { element: Element; score: number } | null {\n  const { locator, expected, isOverlayElement, maxElements, anchorCenter } = params;\n\n  let best: { element: Element; score: number } | null = null;\n  let scanned = 0;\n\n  // Fast-path: ID anchor\n  if (expected.id) {\n    const byId = document.getElementById(expected.id);\n    if (byId && isValidCandidate(byId, isOverlayElement)) {\n      const score = scoreCandidate({ element: byId, expected, locator, anchorCenter });\n      if (Number.isFinite(score)) best = { element: byId, score };\n    }\n  }\n\n  // Try each selector (may return multiple matches)\n  for (const selector of locator.selectors ?? []) {\n    if (scanned >= maxElements) break;\n    const remaining = maxElements - scanned;\n    if (remaining <= 0) break;\n\n    const elements = safeQuerySelectorAll(document, selector, remaining);\n    for (const element of elements) {\n      scanned++;\n      if (!isValidCandidate(element, isOverlayElement)) continue;\n\n      const score = scoreCandidate({ element, expected, locator, anchorCenter });\n      if (!Number.isFinite(score)) continue;\n\n      if (!best || score > best.score) {\n        best = { element, score };\n      }\n    }\n  }\n\n  return best;\n}\n\n/** Geometric locate: find elements at anchor point and score them */\nfunction geometricLocate(params: {\n  expected: ParsedFingerprint;\n  locator: ElementLocator;\n  anchorCenter: { x: number; y: number };\n  isOverlayElement?: (node: unknown) => boolean;\n  selectionEngine?: SelectionEngine;\n  maxCandidates: number;\n}): { element: Element; score: number } | null {\n  const { expected, locator, anchorCenter, isOverlayElement, selectionEngine, maxCandidates } =\n    params;\n\n  const candidates: Element[] = [];\n\n  // Try selection engine first (has smarter candidate ranking)\n  if (selectionEngine) {\n    try {\n      for (const c of selectionEngine.getCandidatesAtPoint(anchorCenter.x, anchorCenter.y)) {\n        candidates.push(c.element);\n        if (candidates.length >= maxCandidates) break;\n      }\n    } catch {\n      // Fall through to elementsFromPoint\n    }\n  }\n\n  // Fallback to elementsFromPoint\n  if (candidates.length === 0) {\n    for (const el of safeElementsFromPoint(anchorCenter.x, anchorCenter.y)) {\n      candidates.push(el);\n      if (candidates.length >= maxCandidates) break;\n    }\n  }\n\n  let best: { element: Element; score: number } | null = null;\n  const seen = new Set<Element>();\n\n  for (const element of candidates) {\n    if (seen.has(element)) continue;\n    seen.add(element);\n\n    if (!isValidCandidate(element, isOverlayElement)) continue;\n    const score = scoreCandidate({ element, expected, locator, anchorCenter });\n    if (!Number.isFinite(score)) continue;\n\n    if (!best || score > best.score) best = { element, score };\n  }\n\n  return best;\n}\n\n/** Collect style properties from a transaction */\nfunction collectStyleProperties(tx: Transaction): string[] {\n  const set = new Set<string>();\n  for (const key of Object.keys(tx.before.styles ?? {})) set.add(key);\n  for (const key of Object.keys(tx.after.styles ?? {})) set.add(key);\n  return Array.from(set).filter(Boolean);\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create an HMR Consistency Verifier.\n *\n * Usage:\n * 1. Call `start()` when Apply succeeds\n * 2. Forward `ExecutionTracker.onStatusChange` to `onExecutionStatus()`\n * 3. Forward `TransactionManager.onChange` to `onTransactionChange()`\n * 4. Forward selection changes to `onSelectionChange()`\n * 5. Results are emitted via `options.onResult` callback\n */\nexport function createHmrConsistencyVerifier(\n  options: HmrConsistencyVerifierOptions,\n): HmrConsistencyVerifier {\n  const disposer = new Disposer();\n  const now = () => Date.now();\n\n  // Configuration\n  const quietWindowMs = Math.max(0, options.quietWindowMs ?? DEFAULT_QUIET_WINDOW_MS);\n  const settleDeadlineMs = Math.max(0, options.settleDeadlineMs ?? DEFAULT_SETTLE_DEADLINE_MS);\n  const noSignalDeadlineMs = Math.max(\n    0,\n    options.noSignalDeadlineMs ?? DEFAULT_NO_SIGNAL_DEADLINE_MS,\n  );\n  const relaxedLocateMaxElements = Math.max(\n    1,\n    options.relaxedLocateMaxElements ?? DEFAULT_RELAXED_LOCATE_MAX_ELEMENTS,\n  );\n  const geometricMaxCandidates = Math.max(\n    1,\n    options.geometricMaxCandidates ?? DEFAULT_GEOMETRIC_MAX_CANDIDATES,\n  );\n\n  // State\n  let sessionSeq = 0;\n  let active: SessionState | null = null;\n  let lastResult: HmrConsistencyResult | null = null;\n\n  // Cleanup on dispose\n  disposer.add(() => finalizeActive('skipped', 'disposed'));\n\n  // ==========================================================================\n  // Utilities\n  // ==========================================================================\n\n  function setToolbar(status: ToolbarStatus, message?: string): void {\n    options.setToolbarStatus?.(status, message);\n  }\n\n  function buildResult(\n    session: SessionState | null,\n    params: {\n      outcome: HmrConsistencyOutcome;\n      reason?: string;\n      resolved?: Omit<HmrResolvedTarget, 'element'>;\n      style?: CompareComputedResult;\n      text?: HmrTextDiff;\n    },\n  ): HmrConsistencyResult {\n    const finalizedAt = now();\n    return {\n      outcome: params.outcome,\n      reason: params.reason,\n      requestId: session?.requestId,\n      sessionId: session?.sessionId,\n      txId: session?.txId ?? '',\n      txTimestamp: session?.txTimestamp ?? 0,\n      txType: session?.txType ?? 'style',\n      resolved: params.resolved,\n      style: params.style,\n      text: params.text,\n      signals: {\n        hadRelevantMutation: session?.signals.hadRelevantMutation ?? false,\n        hadElementDisconnect: session?.signals.hadElementDisconnect ?? false,\n      },\n      timing: {\n        startedAt: session?.startedAt ?? finalizedAt,\n        executionCompletedAt: session?.executionCompletedAt ?? undefined,\n        finalizedAt,\n      },\n    };\n  }\n\n  function emitAndStore(result: HmrConsistencyResult): void {\n    lastResult = result;\n    options.onResult?.(result);\n  }\n\n  function clearTimers(s: SessionState): void {\n    if (s.timers.quietTimer !== null) {\n      window.clearTimeout(s.timers.quietTimer);\n      s.timers.quietTimer = null;\n    }\n    if (s.timers.deadlineTimer !== null) {\n      window.clearTimeout(s.timers.deadlineTimer);\n      s.timers.deadlineTimer = null;\n    }\n    if (s.timers.noSignalTimer !== null) {\n      window.clearTimeout(s.timers.noSignalTimer);\n      s.timers.noSignalTimer = null;\n    }\n  }\n\n  function finalizeActive(\n    outcome: HmrConsistencyOutcome,\n    reason?: string,\n    extra?: {\n      resolved?: Omit<HmrResolvedTarget, 'element'>;\n      style?: CompareComputedResult;\n      text?: HmrTextDiff;\n      toolbar?: { status: ToolbarStatus; message?: string } | null;\n    },\n  ): void {\n    const s = active;\n    if (!s) return;\n\n    // Build result BEFORE clearing session state\n    const result = buildResult(s, {\n      outcome,\n      reason,\n      resolved: extra?.resolved,\n      style: extra?.style,\n      text: extra?.text,\n    });\n\n    clearTimers(s);\n    s.disposer.dispose();\n    active = null;\n\n    // Only reset toolbar if verifier had taken control (settling/verifying phase)\n    // Otherwise let the original status remain (e.g., failed/timeout from execution)\n    const hadTakenControl = s.phase === 'settling' || s.phase === 'verifying';\n    if (extra?.toolbar) {\n      setToolbar(extra.toolbar.status, extra.toolbar.message);\n    } else if (outcome === 'skipped' && hadTakenControl) {\n      setToolbar('idle');\n    }\n\n    emitAndStore(result);\n  }\n\n  function isLatestTxStillSame(txId: string, txTimestamp: number): boolean {\n    try {\n      const undo = options.transactionManager.getUndoStack();\n      if (undo.length === 0) return false;\n      const latest = undo[undo.length - 1]!;\n      return latest.id === txId && latest.timestamp === txTimestamp;\n    } catch {\n      return false;\n    }\n  }\n\n  // ==========================================================================\n  // DOM Observation\n  // ==========================================================================\n\n  function isMutationFromOverlay(record: MutationRecord): boolean {\n    if (!options.isOverlayElement) return false;\n\n    const t = record.target;\n    if (t instanceof Element && options.isOverlayElement(t)) return true;\n\n    if (record.type === 'childList') {\n      const nodes = [...record.addedNodes, ...record.removedNodes];\n      return nodes.some((n) => n instanceof Element && options.isOverlayElement?.(n));\n    }\n\n    return false;\n  }\n\n  function isDomMutationRelevant(record: MutationRecord, target: Element | null): boolean {\n    if (!target) return false;\n    if (isMutationFromOverlay(record)) return false;\n\n    const recTarget = record.target;\n\n    // Handle characterData mutations (text node changes)\n    if (record.type === 'characterData') {\n      if (recTarget instanceof Text) {\n        const parent = recTarget.parentElement;\n        if (parent && (parent === target || parent.contains(target) || target.contains(parent))) {\n          return true;\n        }\n      }\n      return false;\n    }\n\n    if (!(recTarget instanceof Element)) return false;\n\n    if (record.type === 'attributes') {\n      try {\n        return recTarget === target || recTarget.contains(target) || target.contains(recTarget);\n      } catch {\n        return false;\n      }\n    }\n\n    if (record.type === 'childList') {\n      try {\n        if (recTarget === target || recTarget.contains(target) || target.contains(recTarget))\n          return true;\n      } catch {\n        // Fall through\n      }\n\n      // Check if target itself or any ancestor of target was removed\n      for (const n of record.removedNodes) {\n        if (n === target) return true;\n        // Check if removed node contains target (ancestor removal)\n        if (n instanceof Element) {\n          try {\n            if (n.contains(target)) return true;\n          } catch {\n            // Fall through\n          }\n        }\n      }\n    }\n\n    return false;\n  }\n\n  function scheduleVerify(s: SessionState, reason: string): void {\n    if (s.disposer.isDisposed) return;\n    if (s.phase !== 'settling') return;\n\n    if (s.timers.quietTimer !== null) {\n      window.clearTimeout(s.timers.quietTimer);\n      s.timers.quietTimer = null;\n    }\n\n    s.timers.quietTimer = window.setTimeout(() => {\n      s.timers.quietTimer = null;\n      void runVerify(`quiet:${reason}`);\n    }, quietWindowMs);\n  }\n\n  function markMutationSignal(s: SessionState): void {\n    s.signals.hadRelevantMutation = true;\n    scheduleVerify(s, 'mutation');\n  }\n\n  function enterSettling(s: SessionState): void {\n    if (s.disposer.isDisposed) return;\n    if (s.phase === 'settling' || s.phase === 'verifying') return;\n\n    s.phase = 'settling';\n    s.executionCompletedAt = now();\n    setToolbar('verifying', 'Waiting for HMR…');\n\n    // Observe head for CSS injection\n    const head = document.head;\n    if (head) {\n      s.disposer.observeMutation(\n        head,\n        () => {\n          if (active?.key !== s.key || active.phase !== 'settling') return;\n          markMutationSignal(active);\n        },\n        HEAD_MUTATION_OPTIONS,\n      );\n    }\n\n    // Observe DOM for structure changes\n    const targetRootNode = s.originalElement?.getRootNode?.();\n    const domTarget =\n      targetRootNode instanceof ShadowRoot\n        ? targetRootNode\n        : (document.body ?? document.documentElement);\n\n    if (domTarget) {\n      s.disposer.observeMutation(\n        domTarget,\n        (records) => {\n          if (active?.key !== s.key || active.phase !== 'settling') return;\n\n          // Mark disconnect signal\n          const el = active.originalElement;\n          if (el && !el.isConnected) active.signals.hadElementDisconnect = true;\n\n          // Filter for relevant mutations\n          if (records.some((r) => isDomMutationRelevant(r, el))) {\n            markMutationSignal(active);\n          }\n        },\n        DOM_MUTATION_OPTIONS,\n      );\n    }\n\n    // Deadline timer\n    s.timers.deadlineTimer = window.setTimeout(() => {\n      if (active?.key !== s.key) return;\n      finalizeActive('uncertain', 'timeout waiting for HMR', {\n        toolbar: { status: 'uncertain', message: 'Uncertain (timeout)' },\n      });\n    }, settleDeadlineMs);\n\n    // No-signal timer\n    s.timers.noSignalTimer = window.setTimeout(() => {\n      if (active?.key !== s.key || active.phase !== 'settling') return;\n      void runVerify('no_signal');\n    }, noSignalDeadlineMs);\n\n    // Initial verification attempt\n    scheduleVerify(s, 'initial');\n  }\n\n  // ==========================================================================\n  // Element Resolution\n  // ==========================================================================\n\n  function resolveTarget(s: SessionState): HmrResolvedTarget | null {\n    const isOvl = options.isOverlayElement;\n\n    // 1. Current element (cheapest)\n    const current = s.originalElement;\n    if (current && isValidCandidate(current, isOvl)) {\n      return { element: current, source: 'current', confidence: 'high' };\n    }\n\n    // 2. Strict locate (unique selector + fingerprint)\n    const strict = locateElement(s.locator);\n    if (strict && isValidCandidate(strict, isOvl)) {\n      return { element: strict, source: 'strict', confidence: 'high' };\n    }\n\n    // 3. Relaxed locate (non-unique + scoring)\n    const expected = parseFingerprint(s.locator.fingerprint);\n    const relaxedBest = relaxedLocate({\n      locator: s.locator,\n      expected,\n      isOverlayElement: isOvl,\n      maxElements: relaxedLocateMaxElements,\n      anchorCenter: s.anchorCenter,\n    });\n\n    if (relaxedBest && isValidCandidate(relaxedBest.element, isOvl)) {\n      if (relaxedBest.score >= RELAXED_CONFIDENCE_THRESHOLD) {\n        return {\n          element: relaxedBest.element,\n          source: 'relaxed',\n          confidence: 'medium',\n          score: relaxedBest.score,\n        };\n      }\n    }\n\n    // 4. Geometric fallback (point-based)\n    if (s.anchorCenter) {\n      const geoBest = geometricLocate({\n        expected,\n        locator: s.locator,\n        anchorCenter: s.anchorCenter,\n        isOverlayElement: isOvl,\n        selectionEngine: options.selectionEngine,\n        maxCandidates: geometricMaxCandidates,\n      });\n\n      if (geoBest && isValidCandidate(geoBest.element, isOvl)) {\n        if (geoBest.score >= GEOMETRIC_CONFIDENCE_THRESHOLD) {\n          return {\n            element: geoBest.element,\n            source: 'geometric',\n            confidence: 'low',\n            score: geoBest.score,\n          };\n        }\n      }\n    }\n\n    return null;\n  }\n\n  function maybeReselect(s: SessionState, element: Element): void {\n    if (!options.onReselect) return;\n    const selected = options.getSelectedElement?.() ?? null;\n    if (selected === element) return;\n\n    s.flags.suppressNextSelectionChange = true;\n    options.onReselect(element);\n  }\n\n  // ==========================================================================\n  // Verification\n  // ==========================================================================\n\n  function verifyStyle(\n    s: SessionState,\n    element: Element,\n  ): { ok: boolean; style?: CompareComputedResult } {\n    const spec = s.expectedStyle;\n    if (!spec?.computed) return { ok: false };\n\n    const actual = readComputedMap(element, spec.properties);\n    const result = compareComputed(spec.computed, actual);\n    return { ok: true, style: result };\n  }\n\n  function verifyText(s: SessionState, element: Element): { ok: boolean; text?: HmrTextDiff } {\n    const expected = s.expectedText;\n    if (expected === null) return { ok: false };\n    const actual = normalizeText(element.textContent ?? '');\n    return { ok: true, text: { expected, actual, match: expected === actual } };\n  }\n\n  async function runVerify(trigger: string): Promise<void> {\n    const s = active;\n    if (!s || s.disposer.isDisposed || s.phase !== 'settling' || s.flags.verifying) return;\n\n    s.flags.verifying = true;\n    s.phase = 'verifying';\n    setToolbar('verifying', 'Verifying…');\n\n    try {\n      // Check if tx still valid\n      if (!isLatestTxStillSame(s.txId, s.txTimestamp)) {\n        finalizeActive('skipped', 'skipped: new edits detected');\n        return;\n      }\n\n      // Check if selection changed\n      if (s.flags.selectionChanged) {\n        finalizeActive('skipped', 'skipped: selection changed');\n        return;\n      }\n\n      // Update disconnect signal\n      if (s.originalElement && !s.originalElement.isConnected) {\n        s.signals.hadElementDisconnect = true;\n      }\n\n      // Resolve target\n      const resolved = resolveTarget(s);\n      if (!resolved) {\n        finalizeActive('lost', `lost: unable to locate target (${trigger})`, {\n          toolbar: { status: 'lost', message: 'Target lost' },\n        });\n        return;\n      }\n\n      maybeReselect(s, resolved.element);\n\n      const resolvedMeta = {\n        source: resolved.source,\n        confidence: resolved.confidence,\n        score: resolved.score,\n      };\n      const hasHmrSignal = s.signals.hadRelevantMutation || s.signals.hadElementDisconnect;\n\n      // Low confidence resolution (geometric) should only produce uncertain results\n      // to avoid false positives/negatives from wrong element identification\n      if (resolved.confidence === 'low') {\n        finalizeActive('uncertain', `uncertain: low confidence resolution (${resolved.source})`, {\n          resolved: resolvedMeta,\n          toolbar: { status: 'uncertain', message: 'Uncertain (low confidence)' },\n        });\n        return;\n      }\n\n      // Verify based on transaction type\n      if (s.txType === 'style') {\n        const check = verifyStyle(s, resolved.element);\n        if (!check.ok || !check.style) {\n          finalizeActive('uncertain', 'uncertain: missing computed baseline', {\n            resolved: resolvedMeta,\n            toolbar: { status: 'uncertain', message: 'Uncertain (no baseline)' },\n          });\n          return;\n        }\n\n        const mismatches = check.style.diffs.filter((d) => !d.match);\n        if (mismatches.length > 0) {\n          finalizeActive('mismatch', `mismatch: ${mismatches.length} property mismatch`, {\n            resolved: resolvedMeta,\n            style: check.style,\n            toolbar: { status: 'mismatch', message: `Mismatch (${mismatches.length})` },\n          });\n          return;\n        }\n\n        if (!hasHmrSignal) {\n          finalizeActive('uncertain', 'uncertain: no HMR signal observed', {\n            resolved: resolvedMeta,\n            style: check.style,\n            toolbar: { status: 'uncertain', message: 'Uncertain (no HMR signal)' },\n          });\n          return;\n        }\n\n        finalizeActive('verified', 'verified', {\n          resolved: resolvedMeta,\n          style: check.style,\n          toolbar: { status: 'verified', message: 'Verified' },\n        });\n        return;\n      }\n\n      if (s.txType === 'text') {\n        const check = verifyText(s, resolved.element);\n        if (!check.ok || !check.text) {\n          finalizeActive('uncertain', 'uncertain: missing text baseline', {\n            resolved: resolvedMeta,\n            toolbar: { status: 'uncertain', message: 'Uncertain (no baseline)' },\n          });\n          return;\n        }\n\n        if (!check.text.match) {\n          finalizeActive('mismatch', 'mismatch: text differs from expected', {\n            resolved: resolvedMeta,\n            text: check.text,\n            toolbar: { status: 'mismatch', message: 'Mismatch (text)' },\n          });\n          return;\n        }\n\n        if (!hasHmrSignal) {\n          finalizeActive('uncertain', 'uncertain: no HMR signal observed', {\n            resolved: resolvedMeta,\n            text: check.text,\n            toolbar: { status: 'uncertain', message: 'Uncertain (no HMR signal)' },\n          });\n          return;\n        }\n\n        finalizeActive('verified', 'verified', {\n          resolved: resolvedMeta,\n          text: check.text,\n          toolbar: { status: 'verified', message: 'Verified' },\n        });\n        return;\n      }\n\n      // Unsupported tx type\n      finalizeActive('skipped', `skipped: tx type \"${s.txType}\" not supported`);\n    } finally {\n      if (active?.key === s.key) {\n        active.flags.verifying = false;\n      }\n    }\n  }\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function start(args: StartHmrConsistencyArgs): void {\n    const { tx, requestId, sessionId, element } = args;\n\n    // Only verify style and text transactions\n    if (tx.type !== 'style' && tx.type !== 'text') return;\n\n    // Supersede existing session\n    finalizeActive('skipped', 'skipped: superseded by new apply');\n\n    const key = `hmr_${now().toString(36)}_${(++sessionSeq).toString(36)}`;\n    const sessionDisposer = new Disposer();\n    const el = element?.isConnected ? element : null;\n\n    const anchorRect = el ? safeReadRect(el) : null;\n    const anchorCenter = anchorRect ? rectCenter(anchorRect) : null;\n\n    let expectedStyle: SessionState['expectedStyle'] = null;\n    let expectedText: SessionState['expectedText'] = null;\n\n    if (tx.type === 'style') {\n      const properties = collectStyleProperties(tx);\n      const computed = el ? readComputedMap(el, properties) : null;\n      expectedStyle = computed ? { properties, computed } : null;\n    } else if (tx.type === 'text') {\n      const baseline = el ? (el.textContent ?? '') : (tx.after.text ?? '');\n      expectedText = normalizeText(baseline);\n    }\n\n    active = {\n      key,\n      phase: 'executing',\n      requestId: requestId?.trim() || undefined,\n      sessionId: sessionId?.trim() || undefined,\n      txId: tx.id,\n      txTimestamp: tx.timestamp,\n      txType: tx.type,\n      locator: tx.targetLocator,\n      originalElement: el,\n      expectedStyle,\n      expectedText,\n      anchorRect,\n      anchorCenter,\n      startedAt: now(),\n      executionCompletedAt: null,\n      signals: { hadRelevantMutation: false, hadElementDisconnect: false },\n      flags: { verifying: false, selectionChanged: false, suppressNextSelectionChange: false },\n      timers: { quietTimer: null, deadlineTimer: null, noSignalTimer: null },\n      disposer: sessionDisposer,\n    };\n\n    // If tx already changed, skip immediately\n    if (!isLatestTxStillSame(tx.id, tx.timestamp)) {\n      finalizeActive('skipped', 'skipped: transaction no longer latest');\n    }\n  }\n\n  function onExecutionStatus(state: ExecutionState): void {\n    const s = active;\n    if (!s || s.disposer.isDisposed) return;\n\n    // Correlate by requestId\n    if (s.requestId && state.requestId !== s.requestId) return;\n\n    if (!TERMINAL_EXEC_STATUSES.has(state.status)) return;\n\n    if (state.status === 'completed') {\n      enterSettling(s);\n    } else {\n      finalizeActive('skipped', `skipped: execution ${state.status}`);\n    }\n  }\n\n  function onTransactionChange(_event: TransactionChangeEvent): void {\n    const s = active;\n    if (!s || s.disposer.isDisposed) return;\n\n    if (!isLatestTxStillSame(s.txId, s.txTimestamp)) {\n      finalizeActive('skipped', 'skipped: new edits detected');\n    }\n  }\n\n  function onSelectionChange(element: Element | null): void {\n    const s = active;\n    if (!s || s.disposer.isDisposed) return;\n\n    if (s.flags.suppressNextSelectionChange) {\n      s.flags.suppressNextSelectionChange = false;\n      return;\n    }\n\n    const expected = s.originalElement;\n\n    // Handle deselection (null) - user cleared selection\n    if (element === null && expected) {\n      s.flags.selectionChanged = true;\n      finalizeActive('skipped', 'skipped: selection cleared');\n      return;\n    }\n\n    // Handle selection change to different element\n    if (expected && element && element !== expected) {\n      s.flags.selectionChanged = true;\n      finalizeActive('skipped', 'skipped: selection changed');\n    }\n  }\n\n  function getSnapshot(): HmrConsistencySnapshot {\n    const s = active;\n    return {\n      phase: s?.phase ?? 'idle',\n      activeRequestId: s?.requestId,\n      activeTxId: s?.txId,\n      lastResult,\n    };\n  }\n\n  function dispose(): void {\n    disposer.dispose();\n    setToolbar('idle');\n  }\n\n  return {\n    start,\n    onExecutionStatus,\n    onTransactionChange,\n    onSelectionChange,\n    getSnapshot,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/locator.ts",
    "content": "/**\n * Element Locator Utilities\n *\n * Generates and resolves CSS-based element locators for the Transaction System.\n *\n * Design goals:\n * - Generate stable, unique CSS selectors for DOM elements\n * - Support Shadow DOM boundaries with host chain traversal\n * - Provide fallback strategies when primary selector fails\n * - Compute structural fingerprints for fuzzy matching\n */\n\nimport type { ElementLocator } from '@/common/web-editor-types';\nimport { findDebugSource } from './debug-source';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for CSS selector generation */\nexport interface SelectorGenerationOptions {\n  /** Root node for uniqueness checking (defaults to element's root) */\n  root?: Document | ShadowRoot;\n  /** Maximum number of selector candidates to generate */\n  maxCandidates?: number;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Maximum candidate selectors to generate */\nconst DEFAULT_MAX_CANDIDATES = 5;\n\n/** Maximum text length for fingerprint */\nconst FINGERPRINT_TEXT_MAX_LENGTH = 32;\n\n/** Maximum classes to include in fingerprint */\nconst FINGERPRINT_MAX_CLASSES = 8;\n\n/** Priority ordered data attributes for unique identification */\nconst UNIQUE_DATA_ATTRS = [\n  'data-testid',\n  'data-test-id',\n  'data-test',\n  'data-qa',\n  'data-cy',\n  'name',\n  'title',\n  'alt',\n  'aria-label', // Phase 2.9: added for better accessibility-based matching\n] as const;\n\n/** Maximum class combinations to try for uniqueness */\nconst MAX_CLASS_COMBO_DEPTH = 3;\n\n/** Data attributes eligible for ancestor anchors (Phase 2.9) */\nconst ANCHOR_DATA_ATTRS = [\n  'data-testid',\n  'data-test-id',\n  'data-test',\n  'data-qa',\n  'data-cy',\n] as const;\n\n/** Maximum number of class names to consider for selector generation */\nconst MAX_SELECTOR_CLASS_COUNT = 24;\n\n/** Maximum ancestor depth to search for an anchor selector */\nconst MAX_ANCHOR_DEPTH = 20;\n\n// =============================================================================\n// CSS Escape Utility\n// =============================================================================\n\n/**\n * Escape a string for use in CSS selector.\n * Uses native CSS.escape if available, otherwise a spec-compliant polyfill.\n */\nfunction cssEscape(value: string): string {\n  // Try native CSS.escape\n  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {\n    return CSS.escape(value);\n  }\n\n  // Polyfill based on CSSOM spec\n  const str = String(value);\n  const len = str.length;\n  if (len === 0) return '';\n\n  let result = '';\n  const firstCodeUnit = str.charCodeAt(0);\n\n  for (let i = 0; i < len; i++) {\n    const codeUnit = str.charCodeAt(i);\n\n    // Null character -> replacement character\n    if (codeUnit === 0x0000) {\n      result += '\\uFFFD';\n      continue;\n    }\n\n    // Control characters and special numeric positions\n    if (\n      (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||\n      codeUnit === 0x007f ||\n      (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||\n      (i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)\n    ) {\n      result += `\\\\${codeUnit.toString(16)} `;\n      continue;\n    }\n\n    // Single hyphen at start\n    if (i === 0 && len === 1 && codeUnit === 0x002d) {\n      result += `\\\\${str.charAt(i)}`;\n      continue;\n    }\n\n    // Safe ASCII characters (alphanumeric, hyphen, underscore)\n    const isAsciiAlnum =\n      (codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9\n      (codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z\n      (codeUnit >= 0x0061 && codeUnit <= 0x007a); // a-z\n    const isSafe = isAsciiAlnum || codeUnit === 0x002d || codeUnit === 0x005f;\n\n    if (isSafe) {\n      result += str.charAt(i);\n    } else {\n      result += `\\\\${str.charAt(i)}`;\n    }\n  }\n\n  return result;\n}\n\n// =============================================================================\n// Query Helpers\n// =============================================================================\n\n/**\n * Get the query root for an element (Document or ShadowRoot)\n */\nfunction getQueryRoot(element: Element): Document | ShadowRoot {\n  const root = element.getRootNode?.();\n  return root instanceof ShadowRoot ? root : document;\n}\n\n/**\n * Safely execute querySelector, returning null on invalid selectors\n */\nfunction safeQuerySelector(root: ParentNode, selector: string): Element | null {\n  try {\n    return root.querySelector(selector);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Check if a selector matches exactly one element in the root\n */\nfunction isUnique(root: ParentNode, selector: string): boolean {\n  try {\n    return root.querySelectorAll(selector).length === 1;\n  } catch {\n    return false;\n  }\n}\n\n// =============================================================================\n// Selector Generation Strategies\n// =============================================================================\n\n/**\n * Try to build a unique ID-based selector\n */\nfunction tryIdSelector(element: Element, root: ParentNode): string | null {\n  const id = element.id?.trim();\n  if (!id) return null;\n\n  const selector = `#${cssEscape(id)}`;\n  return isUnique(root, selector) ? selector : null;\n}\n\n/**\n * Collect unique data-attribute selector candidates (ordered by priority).\n * Phase 2.9: Returns multiple candidates instead of just the first.\n */\nfunction collectDataAttrSelectors(element: Element, root: ParentNode, max: number): string[] {\n  const out: string[] = [];\n  if (max <= 0) return out;\n\n  const tag = element.tagName.toLowerCase();\n\n  for (const attr of UNIQUE_DATA_ATTRS) {\n    if (out.length >= max) break;\n    const value = element.getAttribute(attr)?.trim();\n    if (!value) continue;\n\n    // Try attribute alone\n    const attrOnly = `[${attr}=\"${cssEscape(value)}\"]`;\n    if (isUnique(root, attrOnly)) {\n      out.push(attrOnly);\n      continue;\n    }\n\n    // Try with tag prefix\n    const withTag = `${tag}${attrOnly}`;\n    if (isUnique(root, withTag)) {\n      out.push(withTag);\n    }\n  }\n\n  return out;\n}\n\n/**\n * Try to build a unique data-attribute selector (single best)\n */\nfunction tryDataAttrSelector(element: Element, root: ParentNode): string | null {\n  return collectDataAttrSelectors(element, root, 1)[0] ?? null;\n}\n\n/**\n * Collect unique class-based selector candidates.\n * Phase 2.9: Produces multiple variants (single class, tag+class, combinations) with early stop.\n */\nfunction collectClassSelectors(element: Element, root: ParentNode, max: number): string[] {\n  const out: string[] = [];\n  if (max <= 0) return out;\n\n  const tag = element.tagName.toLowerCase();\n  const classes = Array.from(element.classList)\n    .filter((c) => c && /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(c))\n    .slice(0, MAX_SELECTOR_CLASS_COUNT);\n\n  if (classes.length === 0) return out;\n\n  const uniqueSingle = new Map<string, boolean>();\n\n  // Try single class\n  for (const cls of classes) {\n    if (out.length >= max) return out;\n    const sel = `.${cssEscape(cls)}`;\n    const unique = isUnique(root, sel);\n    uniqueSingle.set(cls, unique);\n    if (unique) out.push(sel);\n  }\n\n  // Try tag + single class (only when the class alone isn't unique)\n  for (const cls of classes) {\n    if (out.length >= max) return out;\n    if (uniqueSingle.get(cls) === true) continue;\n    const sel = `${tag}.${cssEscape(cls)}`;\n    if (isUnique(root, sel)) out.push(sel);\n  }\n\n  // Try class combinations (pairs and triple) among the first few classes\n  const limit = Math.min(classes.length, MAX_CLASS_COMBO_DEPTH);\n  for (let i = 0; i < limit; i++) {\n    for (let j = i + 1; j < limit; j++) {\n      if (out.length >= max) return out;\n      const a = classes[i];\n      const b = classes[j];\n      const pair = `.${cssEscape(a)}.${cssEscape(b)}`;\n      if (isUnique(root, pair)) {\n        out.push(pair);\n        continue;\n      }\n      const withTag = `${tag}${pair}`;\n      if (isUnique(root, withTag)) out.push(withTag);\n    }\n  }\n\n  // Try triple combination if we have enough classes and room\n  if (limit >= 3 && out.length < max) {\n    const triple = `.${cssEscape(classes[0])}.${cssEscape(classes[1])}.${cssEscape(classes[2])}`;\n    if (isUnique(root, triple)) {\n      out.push(triple);\n    } else {\n      const withTag = `${tag}${triple}`;\n      if (out.length < max && isUnique(root, withTag)) out.push(withTag);\n    }\n  }\n\n  return out;\n}\n\n/**\n * Try to build a unique class-based selector (single best)\n */\nfunction tryClassSelector(element: Element, root: ParentNode): string | null {\n  return collectClassSelectors(element, root, 1)[0] ?? null;\n}\n\n/**\n * Build a structural path selector using nth-of-type\n */\nfunction buildPathSelector(element: Element, root: Document | ShadowRoot): string {\n  const segments: string[] = [];\n  let current: Element | null = element;\n\n  // Determine stop condition based on root type\n  const isDocument = root instanceof Document;\n\n  while (current && current.nodeType === Node.ELEMENT_NODE) {\n    const tag = current.tagName.toLowerCase();\n\n    // Stop at body for document, or at shadow root boundary\n    if (isDocument && tag === 'body') break;\n\n    let selector = tag;\n\n    // Find parent context\n    const parent: Element | null = current.parentElement;\n    const parentNode = current.parentNode;\n\n    // Get siblings from appropriate parent\n    let siblings: Element[];\n    if (parent) {\n      siblings = Array.from(parent.children);\n    } else if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {\n      siblings = Array.from(parentNode.children);\n    } else {\n      siblings = [];\n    }\n\n    // Add nth-of-type if there are siblings with same tag\n    const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName);\n    if (sameTagSiblings.length > 1) {\n      const index = sameTagSiblings.indexOf(current) + 1;\n      selector += `:nth-of-type(${index})`;\n    }\n\n    segments.unshift(selector);\n    current = parent;\n\n    // Stop if we've reached the root's direct children\n    if (!parent && parentNode === root) break;\n  }\n\n  const path = segments.join(' > ');\n  return isDocument ? `body > ${path}` : path || '*';\n}\n\n// =============================================================================\n// Anchor + Relative Path (Phase 2.9)\n// =============================================================================\n\n/**\n * Build a relative path selector from an ancestor to a target within the same root.\n */\nfunction buildRelativePathSelector(\n  ancestor: Element,\n  target: Element,\n  root: Document | ShadowRoot,\n): string | null {\n  const segments: string[] = [];\n  let current: Element | null = target;\n\n  for (let depth = 0; current && current !== ancestor && depth < MAX_ANCHOR_DEPTH; depth++) {\n    const tag = current.tagName.toLowerCase();\n    let selector = tag;\n\n    const parent: Element | null = current.parentElement;\n    const parentNode = current.parentNode;\n\n    let siblings: Element[];\n    if (parent) {\n      siblings = Array.from(parent.children);\n    } else if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {\n      siblings = Array.from(parentNode.children);\n    } else {\n      siblings = [];\n    }\n\n    const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName);\n    if (sameTagSiblings.length > 1) {\n      const index = sameTagSiblings.indexOf(current) + 1;\n      selector += `:nth-of-type(${index})`;\n    }\n\n    segments.unshift(selector);\n\n    if (!parent) {\n      // Reached the root boundary without finding the ancestor\n      if (parentNode === root) break;\n      break;\n    }\n\n    current = parent;\n  }\n\n  if (current !== ancestor) return null;\n  return segments.join(' > ') || null;\n}\n\n/**\n * Try to build a unique anchor selector for an ancestor (id or stable data-* only).\n */\nfunction tryAnchorSelector(element: Element, root: ParentNode): string | null {\n  const idSel = tryIdSelector(element, root);\n  if (idSel) return idSel;\n\n  const tag = element.tagName.toLowerCase();\n\n  for (const attr of ANCHOR_DATA_ATTRS) {\n    const value = element.getAttribute(attr)?.trim();\n    if (!value) continue;\n\n    const attrOnly = `[${attr}=\"${cssEscape(value)}\"]`;\n    if (isUnique(root, attrOnly)) return attrOnly;\n\n    const withTag = `${tag}${attrOnly}`;\n    if (isUnique(root, withTag)) return withTag;\n  }\n\n  return null;\n}\n\n/**\n * Build an \"anchor + relative path\" selector candidate.\n * Finds a unique ancestor (id or stable data-*) and appends a relative path from there.\n * This improves matching when the target itself doesn't have unique attributes.\n */\nfunction buildAnchorRelPathSelector(element: Element, root: Document | ShadowRoot): string | null {\n  let current: Element | null = element.parentElement;\n\n  for (let depth = 0; current && depth < MAX_ANCHOR_DEPTH; depth++) {\n    const tag = current.tagName.toUpperCase();\n    if (tag === 'HTML' || tag === 'BODY') break;\n\n    const anchor = tryAnchorSelector(current, root);\n    if (anchor) {\n      const rel = buildRelativePathSelector(current, element, root);\n      if (!rel) {\n        current = current.parentElement;\n        continue;\n      }\n\n      const composed = `${anchor} ${rel}`;\n      if (!isUnique(root, composed)) {\n        current = current.parentElement;\n        continue;\n      }\n\n      // Final verification: ensure the composed selector finds the exact element\n      const found = safeQuerySelector(root, composed);\n      if (found === element) return composed;\n    }\n\n    current = current.parentElement;\n  }\n\n  return null;\n}\n\n// =============================================================================\n// Shadow DOM Utilities\n// =============================================================================\n\n/**\n * Get selector chain for shadow host ancestors (from outer to inner)\n */\nexport function getShadowHostChain(element: Element): string[] | undefined {\n  const chain: string[] = [];\n  let current: Element = element;\n\n  while (true) {\n    const root = current.getRootNode?.();\n    if (!(root instanceof ShadowRoot)) break;\n\n    const host = root.host;\n    if (!(host instanceof Element)) break;\n\n    const hostRoot = getQueryRoot(host);\n    const hostSelector = generateCssSelector(host, { root: hostRoot });\n    if (!hostSelector) break;\n\n    chain.unshift(hostSelector);\n    current = host;\n  }\n\n  return chain.length > 0 ? chain : undefined;\n}\n\n// =============================================================================\n// Fingerprint Generation\n// =============================================================================\n\n/**\n * Normalize text content for fingerprinting\n */\nfunction normalizeText(text: string, maxLength: number): string {\n  return text.replace(/\\s+/g, ' ').trim().slice(0, maxLength);\n}\n\n/**\n * Compute a structural fingerprint for fuzzy element matching\n */\nexport function computeFingerprint(element: Element): string {\n  const parts: string[] = [];\n\n  // Tag name\n  const tag = element.tagName?.toLowerCase() ?? 'unknown';\n  parts.push(tag);\n\n  // ID if present\n  const id = element.id?.trim();\n  if (id) {\n    parts.push(`id=${id}`);\n  }\n\n  // Class names (limited)\n  const classes = Array.from(element.classList).slice(0, FINGERPRINT_MAX_CLASSES);\n  if (classes.length > 0) {\n    parts.push(`class=${classes.join('.')}`);\n  }\n\n  // Text content hint\n  const text = normalizeText(element.textContent ?? '', FINGERPRINT_TEXT_MAX_LENGTH);\n  if (text) {\n    parts.push(`text=${text}`);\n  }\n\n  return parts.join('|');\n}\n\n// =============================================================================\n// DOM Path Computation\n// =============================================================================\n\n/**\n * Compute the DOM tree path as child indices from root\n */\nexport function computeDomPath(element: Element): number[] {\n  const path: number[] = [];\n  let current: Element | null = element;\n\n  while (current) {\n    const parent: Element | null = current.parentElement;\n\n    if (parent) {\n      const siblings = Array.from(parent.children);\n      const index = siblings.indexOf(current);\n      if (index >= 0) path.unshift(index);\n      current = parent;\n      continue;\n    }\n\n    // Check for shadow root or document as parent\n    const parentNode = current.parentNode;\n    if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {\n      const children = Array.from(parentNode.children);\n      const index = children.indexOf(current);\n      if (index >= 0) path.unshift(index);\n    }\n\n    break;\n  }\n\n  return path;\n}\n\n// =============================================================================\n// Public API - Selector Generation\n// =============================================================================\n\n/**\n * Generate multiple CSS selector candidates for an element.\n * Phase 2.9 enhanced: Collects multiple candidates from each strategy.\n *\n * Candidates are ordered by preference: ID > data-attrs > classes > path > anchor+relPath\n */\nexport function generateSelectorCandidates(\n  element: Element,\n  options: SelectorGenerationOptions = {},\n): string[] {\n  const root = options.root ?? getQueryRoot(element);\n  const maxCandidates = Math.max(1, options.maxCandidates ?? DEFAULT_MAX_CANDIDATES);\n\n  const candidates: string[] = [];\n\n  const push = (selector: string | null, limit = maxCandidates): void => {\n    if (!selector) return;\n    if (candidates.length >= limit) return;\n    const s = selector.trim();\n    if (!s || candidates.includes(s)) return;\n    candidates.push(s);\n  };\n\n  // Pre-compute anchor+relPath candidate (intended as last fallback)\n  // Only compute if we have room for it (maxCandidates >= 5)\n  const anchorCandidate =\n    maxCandidates >= DEFAULT_MAX_CANDIDATES ? buildAnchorRelPathSelector(element, root) : null;\n\n  // Reserve space for path selector + optional anchor candidate\n  // But ensure headLimit is at least 1 to allow ID/attr/class to compete with path\n  const tailReserved = 1 + (anchorCandidate ? 1 : 0);\n  const headLimit = Math.max(1, maxCandidates - tailReserved);\n\n  // 1) ID selector (unique, highest priority)\n  push(tryIdSelector(element, root), headLimit);\n\n  // 2) Data attribute selectors (multiple candidates in priority order)\n  for (const sel of collectDataAttrSelectors(element, root, headLimit - candidates.length)) {\n    push(sel, headLimit);\n  }\n\n  // 3) Class selectors (multiple candidates with combinations)\n  for (const sel of collectClassSelectors(element, root, headLimit - candidates.length)) {\n    push(sel, headLimit);\n  }\n\n  // 4) Structural path selector (always included as fallback)\n  push(buildPathSelector(element, root));\n\n  // 5) Anchor + relative path selector (when available, last candidate)\n  push(anchorCandidate);\n\n  return candidates.slice(0, maxCandidates);\n}\n\n/**\n * Generate a single best CSS selector for an element\n */\nexport function generateCssSelector(\n  element: Element,\n  options: SelectorGenerationOptions = {},\n): string {\n  return generateSelectorCandidates(element, options)[0] ?? '';\n}\n\n// =============================================================================\n// Public API - Locator Creation & Resolution\n// =============================================================================\n\n/**\n * Create a complete ElementLocator for an element.\n * The locator contains multiple strategies for re-identification.\n */\nexport function createElementLocator(element: Element): ElementLocator {\n  const root = getQueryRoot(element);\n\n  // Extract debug source (React/Vue component file path)\n  // This is best-effort and returns undefined if not available\n  const debugSource = findDebugSource(element) ?? undefined;\n\n  return {\n    selectors: generateSelectorCandidates(element, { root, maxCandidates: DEFAULT_MAX_CANDIDATES }),\n    fingerprint: computeFingerprint(element),\n    path: computeDomPath(element),\n    shadowHostChain: getShadowHostChain(element),\n    debugSource,\n  };\n}\n\n/**\n * Safely check if a selector matches exactly one element\n */\nfunction isSelectorUnique(root: ParentNode, selector: string): boolean {\n  try {\n    return root.querySelectorAll(selector).length === 1;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Verify element matches the stored fingerprint\n */\nfunction verifyFingerprint(element: Element, fingerprint: string): boolean {\n  const currentFingerprint = computeFingerprint(element);\n  // Simple check: verify tag and id match at minimum\n  const storedParts = fingerprint.split('|');\n  const currentParts = currentFingerprint.split('|');\n\n  // At minimum, tag should match\n  if (storedParts[0] !== currentParts[0]) return false;\n\n  // If stored has id, current should have same id\n  const storedId = storedParts.find((p) => p.startsWith('id='));\n  const currentId = currentParts.find((p) => p.startsWith('id='));\n  if (storedId && storedId !== currentId) return false;\n\n  return true;\n}\n\n/**\n * Resolve an ElementLocator to a DOM element.\n * Traverses Shadow DOM boundaries and tries multiple selector candidates.\n * Includes uniqueness verification to avoid misidentification.\n */\nexport function locateElement(\n  locator: ElementLocator,\n  rootDocument: Document = document,\n): Element | null {\n  let doc: Document = rootDocument;\n\n  // Traverse iframe chain (Phase 4 - not implemented yet)\n  if (locator.frameChain?.length) {\n    for (const frameSelector of locator.frameChain) {\n      const frame = safeQuerySelector(doc, frameSelector);\n      if (!(frame instanceof HTMLIFrameElement)) return null;\n      const contentDoc = frame.contentDocument;\n      if (!contentDoc) return null;\n      doc = contentDoc;\n    }\n  }\n\n  // Start with document as query root\n  let queryRoot: Document | ShadowRoot = doc;\n\n  // Traverse Shadow DOM host chain\n  if (locator.shadowHostChain?.length) {\n    for (const hostSelector of locator.shadowHostChain) {\n      // Verify host selector is unique\n      if (!isSelectorUnique(queryRoot, hostSelector)) return null;\n\n      const host = safeQuerySelector(queryRoot, hostSelector);\n      if (!host) return null;\n\n      const shadowRoot = (host as HTMLElement).shadowRoot;\n      if (!shadowRoot) return null;\n\n      queryRoot = shadowRoot;\n    }\n  }\n\n  // Try each selector candidate with uniqueness and fingerprint verification\n  for (const selector of locator.selectors) {\n    // Check if selector still matches exactly one element\n    if (!isSelectorUnique(queryRoot, selector)) continue;\n\n    const element = safeQuerySelector(queryRoot, selector);\n    if (!element) continue;\n\n    // Verify fingerprint matches to catch \"same selector, different element\" cases\n    if (locator.fingerprint && !verifyFingerprint(element, locator.fingerprint)) {\n      continue;\n    }\n\n    return element;\n  }\n\n  return null;\n}\n\n/**\n * Generate a unique key for an ElementLocator (for comparison/caching)\n */\nexport function locatorKey(locator: ElementLocator): string {\n  const selectors = locator.selectors.join('|');\n  const shadow = locator.shadowHostChain?.join('>') ?? '';\n  const frame = locator.frameChain?.join('>') ?? '';\n  return `frame:${frame}|shadow:${shadow}|sel:${selectors}`;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/message-listener.ts",
    "content": "/**\n * Web Editor V2 Message Listener\n *\n * Handles chrome.runtime.onMessage communication with the background script.\n * Uses versioned action names (suffix _v2) to avoid conflicts with V1.\n */\n\nimport type {\n  ElementLocator,\n  WebEditorV2Api,\n  WebEditorV2Request,\n  WebEditorV2PingResponse,\n  WebEditorV2ToggleResponse,\n  WebEditorV2StartResponse,\n  WebEditorV2StopResponse,\n} from '@/common/web-editor-types';\nimport { WEB_EDITOR_V2_ACTIONS } from '@/common/web-editor-types';\nimport { locateElement } from './locator';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Function to remove the message listener */\nexport type RemoveMessageListener = () => void;\n\n/** Highlight element request from sidepanel */\ninterface WebEditorV2HighlightRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT;\n  mode: 'hover' | 'clear';\n  /** Full locator for Shadow DOM/iframe support */\n  locator?: ElementLocator;\n  /** Fallback selector for backward compatibility */\n  selector?: string;\n  elementKey?: string;\n}\n\n/** Highlight element response */\ninterface WebEditorV2HighlightResponse {\n  success: boolean;\n  error?: string;\n}\n\n/** Revert element request from sidepanel (Phase 2) */\ninterface WebEditorV2RevertRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.REVERT_ELEMENT;\n  elementKey: string;\n}\n\n/** Revert element response */\ninterface WebEditorV2RevertResponse {\n  success: boolean;\n  reverted?: {\n    style?: boolean;\n    text?: boolean;\n    class?: boolean;\n  };\n  error?: string;\n}\n\n/** Clear selection request from sidepanel (after send) */\ninterface WebEditorV2ClearSelectionRequest {\n  action: typeof WEB_EDITOR_V2_ACTIONS.CLEAR_SELECTION;\n}\n\n/** Clear selection response */\ninterface WebEditorV2ClearSelectionResponse {\n  success: boolean;\n}\n\n/** All possible V2 response types */\ntype WebEditorV2Response =\n  | WebEditorV2PingResponse\n  | WebEditorV2ToggleResponse\n  | WebEditorV2StartResponse\n  | WebEditorV2StopResponse\n  | WebEditorV2HighlightResponse\n  | WebEditorV2RevertResponse\n  | WebEditorV2ClearSelectionResponse;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Type guard to check if a request is a V2 editor request\n */\nfunction isV2Request(request: unknown): request is WebEditorV2Request {\n  if (!request || typeof request !== 'object') return false;\n\n  const action = (request as { action?: unknown }).action;\n  return (\n    action === WEB_EDITOR_V2_ACTIONS.PING ||\n    action === WEB_EDITOR_V2_ACTIONS.TOGGLE ||\n    action === WEB_EDITOR_V2_ACTIONS.START ||\n    action === WEB_EDITOR_V2_ACTIONS.STOP\n  );\n}\n\n/**\n * Type guard for highlight request\n */\nfunction isHighlightRequest(request: unknown): request is WebEditorV2HighlightRequest {\n  if (!request || typeof request !== 'object') return false;\n  const r = request as Record<string, unknown>;\n\n  if (r.action !== WEB_EDITOR_V2_ACTIONS.HIGHLIGHT_ELEMENT) return false;\n  if (r.mode !== 'hover' && r.mode !== 'clear') return false;\n\n  // Clear mode doesn't require locator/selector\n  if (r.mode === 'clear') return true;\n\n  // Hover mode requires either locator or selector\n  const hasSelector = typeof r.selector === 'string' && r.selector.trim().length > 0;\n  const hasLocator = r.locator !== null && typeof r.locator === 'object';\n  return hasSelector || hasLocator;\n}\n\n/**\n * Type guard for revert element request (Phase 2)\n */\nfunction isRevertRequest(request: unknown): request is WebEditorV2RevertRequest {\n  if (!request || typeof request !== 'object') return false;\n  const r = request as Record<string, unknown>;\n\n  return (\n    r.action === WEB_EDITOR_V2_ACTIONS.REVERT_ELEMENT &&\n    typeof r.elementKey === 'string' &&\n    r.elementKey.trim().length > 0\n  );\n}\n\n/**\n * Type guard for clear selection request\n */\nfunction isClearSelectionRequest(request: unknown): request is WebEditorV2ClearSelectionRequest {\n  if (!request || typeof request !== 'object') return false;\n  const r = request as Record<string, unknown>;\n  return r.action === WEB_EDITOR_V2_ACTIONS.CLEAR_SELECTION;\n}\n\n// =============================================================================\n// Highlight State Management\n// =============================================================================\n\n/** Currently highlighted element (for clearing on next hover or explicit clear) */\nlet currentHighlightElement: Element | null = null;\nlet currentHighlightOverlay: HTMLElement | null = null;\n\n/**\n * Clear any existing highlight overlay\n */\nfunction clearHighlight(): void {\n  if (currentHighlightOverlay && currentHighlightOverlay.parentNode) {\n    currentHighlightOverlay.parentNode.removeChild(currentHighlightOverlay);\n  }\n  currentHighlightOverlay = null;\n  currentHighlightElement = null;\n}\n\n/**\n * Create and show highlight overlay for an element\n */\nfunction showHighlight(element: Element): void {\n  // Clear previous highlight\n  clearHighlight();\n\n  const rect = element.getBoundingClientRect();\n  if (rect.width === 0 && rect.height === 0) {\n    // Element is not visible\n    return;\n  }\n\n  // Create overlay element\n  const overlay = document.createElement('div');\n  overlay.setAttribute('data-web-editor-highlight', 'true');\n  overlay.style.cssText = `\n    position: fixed;\n    top: ${rect.top}px;\n    left: ${rect.left}px;\n    width: ${rect.width}px;\n    height: ${rect.height}px;\n    background-color: rgba(59, 130, 246, 0.15);\n    border: 2px solid rgba(59, 130, 246, 0.8);\n    border-radius: 4px;\n    pointer-events: none;\n    z-index: 2147483646;\n    box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);\n    transition: all 0.15s ease;\n  `;\n\n  document.body.appendChild(overlay);\n  currentHighlightOverlay = overlay;\n  currentHighlightElement = element;\n}\n\n/**\n * Find element by CSS selector (fallback when locator-based resolution fails)\n */\nfunction findElementBySelector(selector: string): Element | null {\n  try {\n    return document.querySelector(selector);\n  } catch {\n    // Invalid selector\n    return null;\n  }\n}\n\n// =============================================================================\n// Message Listener\n// =============================================================================\n\n/**\n * Install the message listener for background communication.\n * Returns a function to remove the listener.\n *\n * Handles:\n * - PING: Check if editor is active\n * - TOGGLE/START/STOP: Control editor state\n * - HIGHLIGHT_ELEMENT: Highlight element from sidepanel hover\n * - REVERT_ELEMENT: Revert element to original state\n * - CLEAR_SELECTION: Clear current selection (from sidepanel after send)\n *\n * @param api The WebEditorV2Api instance to delegate commands to\n * @returns Function to remove the listener\n */\nexport function installMessageListener(api: WebEditorV2Api): RemoveMessageListener {\n  const listener = (\n    request: unknown,\n    _sender: chrome.runtime.MessageSender,\n    sendResponse: (response: WebEditorV2Response) => void,\n  ): boolean => {\n    // Handle highlight requests (can work even when editor is not active)\n    if (isHighlightRequest(request)) {\n      if (request.mode === 'clear') {\n        clearHighlight();\n        sendResponse({ success: true });\n      } else {\n        // mode === 'hover'\n        let element: Element | null = null;\n\n        // Prefer locator-based resolution (supports Shadow DOM host chain)\n        if (request.locator) {\n          try {\n            element = locateElement(request.locator);\n          } catch {\n            element = null;\n          }\n        }\n\n        // Fallback to selector (backward compatibility / degraded locators)\n        if (!element && typeof request.selector === 'string') {\n          element = findElementBySelector(request.selector);\n        }\n\n        if (element) {\n          showHighlight(element);\n          sendResponse({ success: true });\n        } else {\n          sendResponse({ success: false, error: 'Element not found' });\n        }\n      }\n      return false; // Synchronous\n    }\n\n    // Handle revert element requests (Phase 2 - Selective Undo)\n    if (isRevertRequest(request)) {\n      // Revert is async, so we return true and call sendResponse later\n      (async () => {\n        try {\n          const result = await api.revertElement(request.elementKey);\n          sendResponse(result);\n        } catch (error) {\n          sendResponse({\n            success: false,\n            error: error instanceof Error ? error.message : String(error),\n          });\n        }\n      })();\n      return true; // Async response\n    }\n\n    // Handle clear selection requests (from sidepanel after send)\n    if (isClearSelectionRequest(request)) {\n      api.clearSelection();\n      sendResponse({ success: true });\n      return false; // Synchronous\n    }\n\n    // Only handle V2 requests for other actions\n    if (!isV2Request(request)) {\n      return false;\n    }\n\n    switch (request.action) {\n      case WEB_EDITOR_V2_ACTIONS.PING: {\n        const response: WebEditorV2PingResponse = {\n          status: 'pong',\n          active: api.getState().active,\n          version: 2,\n        };\n        sendResponse(response);\n        return false; // Synchronous response\n      }\n\n      case WEB_EDITOR_V2_ACTIONS.TOGGLE: {\n        const response: WebEditorV2ToggleResponse = {\n          active: api.toggle(),\n        };\n        sendResponse(response);\n        return false;\n      }\n\n      case WEB_EDITOR_V2_ACTIONS.START: {\n        api.start();\n        const response: WebEditorV2StartResponse = {\n          active: true,\n        };\n        sendResponse(response);\n        return false;\n      }\n\n      case WEB_EDITOR_V2_ACTIONS.STOP: {\n        api.stop();\n        const response: WebEditorV2StopResponse = {\n          active: false,\n        };\n        sendResponse(response);\n        return false;\n      }\n\n      default:\n        // Should never reach here due to type guard\n        return false;\n    }\n  };\n\n  chrome.runtime.onMessage.addListener(listener);\n\n  return () => {\n    chrome.runtime.onMessage.removeListener(listener);\n    // Clean up any highlight when listener is removed\n    clearHighlight();\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/payload-builder.ts",
    "content": "/**\n * Payload Builder (Phase 1.8)\n *\n * Builds \"Apply to Code\" payload from Transactions for sending to the Agent.\n *\n * Design goals:\n * - Reuse existing BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY pipeline\n * - Extract React/Vue component debug info when available\n * - Build comprehensive style diff descriptions\n * - Detect tech stack (Tailwind, React, Vue) from DOM hints\n */\n\nimport type { DebugSource, ElementLocator, Transaction } from '@/common/web-editor-types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { locateElement } from './locator';\nimport { findReactDebugSource, findVueDebugSource } from './debug-source';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Instruction type for Apply payload */\nexport type ApplyInstructionType = 'update_text' | 'update_style';\n\n/** Element fingerprint for identification */\nexport interface ElementFingerprint {\n  tag: string;\n  id?: string;\n  classes: string[];\n  text?: string;\n}\n\n/** Style change instruction */\nexport interface ApplyInstruction {\n  type: ApplyInstructionType;\n  description: string;\n  text?: string;\n  style?: Record<string, string>;\n}\n\n/** Complete payload sent to background/Agent */\nexport interface ApplyPayload {\n  pageUrl: string;\n  targetFile?: string;\n  fingerprint: ElementFingerprint;\n  techStackHint?: string[];\n  instruction: ApplyInstruction;\n\n  // V2 extended fields (best-effort, optional)\n  locator?: ElementLocator;\n  selectorCandidates?: string[];\n  debugSource?: DebugSource;\n  operation?: {\n    type: 'update_style';\n    before: Record<string, string>;\n    after: Record<string, string>;\n    removed: string[];\n  };\n}\n\n/** Options for building payload */\nexport interface BuildPayloadOptions {\n  pageUrl?: string;\n  /** Pre-resolved element to avoid re-locating */\n  element?: Element | null;\n  /** Max selectors to include in description */\n  maxSelectorsInDescription?: number;\n}\n\n// =============================================================================\n// Helper Functions\n// =============================================================================\n\n/**\n * Safely access object as record\n */\nfunction asRecord(value: unknown): Record<string, unknown> | null {\n  if (value && typeof value === 'object') {\n    return value as Record<string, unknown>;\n  }\n  return null;\n}\n\n/**\n * Read optional string value\n */\nfunction readString(value: unknown): string | undefined {\n  if (typeof value === 'string') {\n    const trimmed = value.trim();\n    return trimmed || undefined;\n  }\n  return undefined;\n}\n\n/**\n * Read optional number value\n */\nfunction readNumber(value: unknown): number | undefined {\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return value;\n  }\n  const parsed = Number.parseInt(String(value), 10);\n  return Number.isFinite(parsed) ? parsed : undefined;\n}\n\n/**\n * Normalize text snippet for display\n */\nfunction normalizeText(text: string, maxLength: number): string {\n  return String(text ?? '')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .slice(0, maxLength);\n}\n\n// =============================================================================\n// Fingerprint Generation\n// =============================================================================\n\n/**\n * Build fingerprint from DOM element\n */\nfunction buildFingerprintFromElement(element: Element): ElementFingerprint {\n  const tag = element.tagName?.toLowerCase() ?? 'unknown';\n  const id = readString((element as HTMLElement).id);\n  const classes = Array.from(element.classList ?? []).slice(0, 24);\n  const text = readString(normalizeText(element.textContent ?? '', 96));\n\n  return { tag, id, classes, text };\n}\n\n/**\n * Build fingerprint from locator (fallback when element not available)\n */\nfunction buildFingerprintFromLocator(locator: ElementLocator): ElementFingerprint {\n  const raw = readString(locator.fingerprint) ?? '';\n  const parts = raw.split('|').filter(Boolean);\n\n  const tag = parts[0] || 'unknown';\n  let id: string | undefined;\n  let classes: string[] = [];\n  let text: string | undefined;\n\n  for (const part of parts.slice(1)) {\n    if (part.startsWith('id=')) {\n      id = readString(part.slice(3));\n    } else if (part.startsWith('class=')) {\n      classes = part.slice(6).split('.').filter(Boolean);\n    } else if (part.startsWith('text=')) {\n      text = readString(part.slice(5));\n    }\n  }\n\n  return { tag, id, classes, text };\n}\n\n// =============================================================================\n// Tech Stack Detection\n// =============================================================================\n\n/** Tailwind class patterns */\nconst TAILWIND_PATTERNS = [\n  /^bg-/,\n  /^text-/,\n  /^p[trblxy]?-/,\n  /^m[trblxy]?-/,\n  /^flex$/,\n  /^grid$/,\n  /^items-/,\n  /^justify-/,\n  /^gap-/,\n  /^rounded/,\n  /^shadow/,\n  /^border/,\n  /^w-/,\n  /^h-/,\n];\n\n/**\n * Detect if element uses Tailwind CSS\n */\nfunction detectTailwind(classes: string[]): boolean {\n  return classes.some((cls) => TAILWIND_PATTERNS.some((p) => p.test(cls)));\n}\n\n// =============================================================================\n// Component Hints Resolution\n// =============================================================================\n\ninterface ComponentHints {\n  targetFile?: string;\n  debugSource?: DebugSource;\n  techStackHint?: string[];\n}\n\n/**\n * Resolve component hints from element\n */\nfunction resolveComponentHints(element: Element): ComponentHints {\n  let targetFile: string | undefined;\n  let debugSource: DebugSource | undefined;\n  const hints = new Set<string>();\n\n  let node: Element | null = element;\n\n  for (let depth = 0; depth < 20 && node; depth++) {\n    // Try React\n    const react = findReactDebugSource(node);\n    if (react?.file) {\n      hints.add('React');\n      if (!targetFile && !react.file.includes('node_modules')) {\n        targetFile = react.file;\n        debugSource = react;\n        break;\n      }\n    }\n\n    // Try Vue\n    const vue = findVueDebugSource(node);\n    if (vue?.file) {\n      hints.add('Vue');\n      if (!targetFile && !vue.file.includes('node_modules')) {\n        targetFile = vue.file;\n        debugSource = vue;\n        break;\n      }\n    }\n\n    node = node.parentElement;\n  }\n\n  // Check for Tailwind\n  const classes = Array.from(element.classList ?? []).slice(0, 128);\n  if (detectTailwind(classes)) {\n    hints.add('Tailwind');\n  }\n\n  return {\n    targetFile,\n    debugSource,\n    techStackHint: hints.size > 0 ? Array.from(hints) : undefined,\n  };\n}\n\n// =============================================================================\n// Style Diff Computation\n// =============================================================================\n\ninterface StyleDiff {\n  before: Record<string, string>;\n  after: Record<string, string>;\n  set: Record<string, string>;\n  removed: string[];\n}\n\n/**\n * Compute style diff from transaction\n */\nfunction computeStyleDiff(tx: Transaction): StyleDiff | null {\n  const beforeRaw = tx.before.styles ?? {};\n  const afterRaw = tx.after.styles ?? {};\n\n  const keys = new Set([...Object.keys(beforeRaw), ...Object.keys(afterRaw)]);\n  if (keys.size === 0) return null;\n\n  const before: Record<string, string> = {};\n  const after: Record<string, string> = {};\n  const set: Record<string, string> = {};\n  const removed: string[] = [];\n\n  for (const key of keys) {\n    const b = String(beforeRaw[key] ?? '').trim();\n    const a = String(afterRaw[key] ?? '').trim();\n\n    if (b === a) continue;\n\n    before[key] = b;\n    after[key] = a;\n\n    if (a) {\n      set[key] = a;\n    } else {\n      removed.push(key);\n    }\n  }\n\n  if (Object.keys(before).length === 0 && Object.keys(after).length === 0) {\n    return null;\n  }\n\n  return { before, after, set, removed };\n}\n\n/**\n * Build human-readable style description\n */\nfunction buildStyleDescription(\n  locator: ElementLocator,\n  diff: StyleDiff,\n  maxSelectors: number,\n): string {\n  const selectors = (locator.selectors ?? []).filter(Boolean);\n  const selectorPreview = selectors.slice(0, maxSelectors).join(' | ');\n\n  const changes: string[] = [];\n  for (const [prop, nextVal] of Object.entries(diff.after)) {\n    const prevVal = diff.before[prop] ?? '';\n    if (nextVal) {\n      changes.push(`${prop}: \"${prevVal}\" -> \"${nextVal}\"`);\n    } else {\n      changes.push(`${prop}: remove (was \"${prevVal}\")`);\n    }\n  }\n\n  const selPart = selectorPreview ? `selectors: ${selectorPreview}` : 'selectors: (unavailable)';\n  return `Update element styles (${selPart}). ${changes.join('; ')}`;\n}\n\n/**\n * Build human-readable text update description (Phase 2.7)\n */\nfunction buildTextDescription(\n  locator: ElementLocator,\n  beforeText: string,\n  afterText: string,\n  maxSelectors: number,\n): string {\n  const selectors = (locator.selectors ?? []).filter(Boolean);\n  const selectorPreview = selectors.slice(0, maxSelectors).join(' | ');\n  const selPart = selectorPreview ? `selectors: ${selectorPreview}` : 'selectors: (unavailable)';\n\n  // Truncate text for preview\n  const beforePreview = beforeText.length > 96 ? beforeText.slice(0, 93) + '...' : beforeText;\n  const afterPreview = afterText.length > 96 ? afterText.slice(0, 93) + '...' : afterText;\n\n  return `Update element text (${selPart}). \"${beforePreview}\" -> \"${afterPreview}\"`;\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Build Apply payload from a Transaction\n * Supports style and text transactions (Phase 2.7)\n */\nexport function buildApplyPayload(\n  tx: Transaction,\n  options: BuildPayloadOptions = {},\n): ApplyPayload | null {\n  const pageUrl = readString(options.pageUrl ?? globalThis.location?.href) ?? '';\n  if (!pageUrl) return null;\n\n  const locator = tx.targetLocator;\n\n  // Resolve element\n  const element =\n    options.element !== undefined ? options.element : locateElement(locator, document);\n\n  // Build fingerprint\n  const fingerprint = element\n    ? buildFingerprintFromElement(element)\n    : buildFingerprintFromLocator(locator);\n\n  // Resolve component hints\n  const hints = element ? resolveComponentHints(element) : {};\n\n  const maxSelectors = Math.max(0, options.maxSelectorsInDescription ?? 3);\n\n  // Handle style transactions\n  if (tx.type === 'style') {\n    const diff = computeStyleDiff(tx);\n    if (!diff) return null;\n\n    const description = buildStyleDescription(locator, diff, maxSelectors);\n\n    const payload: ApplyPayload = {\n      pageUrl,\n      targetFile: hints.targetFile,\n      fingerprint,\n      techStackHint: hints.techStackHint,\n      instruction: {\n        type: 'update_style',\n        description,\n        style: Object.keys(diff.set).length > 0 ? diff.set : undefined,\n      },\n\n      // V2 extended fields\n      locator: hints.debugSource ? { ...locator, debugSource: hints.debugSource } : locator,\n      selectorCandidates: locator.selectors?.slice(0, 8),\n      debugSource: hints.debugSource,\n      operation: {\n        type: 'update_style',\n        before: diff.before,\n        after: diff.after,\n        removed: diff.removed,\n      },\n    };\n\n    return payload;\n  }\n\n  // Handle text transactions (Phase 2.7)\n  if (tx.type === 'text') {\n    const beforeText = String(tx.before.text ?? '');\n    const afterText = String(tx.after.text ?? '');\n    if (beforeText === afterText) return null;\n\n    const description = buildTextDescription(locator, beforeText, afterText, maxSelectors);\n\n    const payload: ApplyPayload = {\n      pageUrl,\n      targetFile: hints.targetFile,\n      fingerprint,\n      techStackHint: hints.techStackHint,\n      instruction: {\n        type: 'update_text',\n        description,\n        text: afterText,\n      },\n\n      // V2 extended fields\n      locator: hints.debugSource ? { ...locator, debugSource: hints.debugSource } : locator,\n      selectorCandidates: locator.selectors?.slice(0, 8),\n      debugSource: hints.debugSource,\n    };\n\n    return payload;\n  }\n\n  return null;\n}\n\n/**\n * Send Apply payload to background script\n */\nexport async function sendApplyPayload(payload: ApplyPayload): Promise<unknown> {\n  if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n    throw new Error('Chrome runtime API not available');\n  }\n\n  return chrome.runtime.sendMessage({\n    type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_APPLY,\n    payload,\n  });\n}\n\n/**\n * Build and send Transaction to Agent in one call\n */\nexport async function sendTransactionToAgent(\n  tx: Transaction,\n  options: BuildPayloadOptions = {},\n): Promise<unknown> {\n  const payload = buildApplyPayload(tx, options);\n  if (!payload) {\n    throw new Error('Unable to build payload from transaction');\n  }\n  return sendApplyPayload(payload);\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/perf-monitor.ts",
    "content": "/**\n * Performance Monitor (Phase 5.3)\n *\n * Lightweight FPS + JS heap monitor for debugging.\n *\n * Design:\n * - Disabled by default (no persistent rAF)\n * - Uses a single rAF loop only when enabled\n * - Updates UI at low frequency (FPS: 500ms, heap: 1s)\n * - Pauses automatically when the document is hidden\n *\n * Notes:\n * - Heap metrics rely on Chrome's non-standard `performance.memory` API.\n */\n\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating the perf monitor */\nexport interface PerfMonitorOptions {\n  /** Container element (should be overlayRoot from ShadowHost) */\n  container: HTMLElement;\n  /** UI update interval for FPS (ms). Default: 500 */\n  fpsUiIntervalMs?: number;\n  /** Sampling interval for heap memory (ms). Default: 1000 */\n  memorySampleIntervalMs?: number;\n}\n\n/** Perf monitor public interface */\nexport interface PerfMonitor {\n  /** Whether monitor is currently enabled */\n  isEnabled(): boolean;\n  /** Enable/disable monitor */\n  setEnabled(enabled: boolean): void;\n  /** Toggle monitor and return new state */\n  toggle(): boolean;\n  /** Cleanup */\n  dispose(): void;\n}\n\n/** Non-standard Chrome memory API shape */\ninterface PerformanceMemory {\n  usedJSHeapSize: number;\n  totalJSHeapSize: number;\n  jsHeapSizeLimit: number;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_FPS_UI_INTERVAL_MS = 500;\nconst DEFAULT_MEMORY_SAMPLE_INTERVAL_MS = 1000;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFiniteNumber(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value);\n}\n\nfunction bytesToMb(bytes: number): number {\n  return bytes / (1024 * 1024);\n}\n\nfunction formatMb(bytes: number, digits: number): string {\n  const mb = bytesToMb(bytes);\n  return Number.isFinite(mb) ? mb.toFixed(digits) : 'N/A';\n}\n\nfunction readPerformanceMemory(): PerformanceMemory | null {\n  try {\n    const perf = performance as unknown as { memory?: unknown };\n    const memory = perf.memory as Partial<PerformanceMemory> | undefined;\n    if (!memory) return null;\n\n    const used = memory.usedJSHeapSize;\n    const limit = memory.jsHeapSizeLimit;\n    const total = memory.totalJSHeapSize;\n\n    if (!isFiniteNumber(used)) return null;\n    if (!isFiniteNumber(limit)) return null;\n    if (!isFiniteNumber(total)) return null;\n\n    return {\n      usedJSHeapSize: used,\n      totalJSHeapSize: total,\n      jsHeapSizeLimit: limit,\n    };\n  } catch {\n    return null;\n  }\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a perf monitor HUD.\n *\n * The HUD is appended to `overlayRoot` and is `pointer-events: none`.\n * It is hidden by default and only starts rAF when enabled.\n */\nexport function createPerfMonitor(options: PerfMonitorOptions): PerfMonitor {\n  const disposer = new Disposer();\n  const container = options.container;\n\n  const fpsUiIntervalMs = Math.max(\n    100,\n    Math.floor(options.fpsUiIntervalMs ?? DEFAULT_FPS_UI_INTERVAL_MS),\n  );\n  const memorySampleIntervalMs = Math.max(\n    250,\n    Math.floor(options.memorySampleIntervalMs ?? DEFAULT_MEMORY_SAMPLE_INTERVAL_MS),\n  );\n\n  // ==========================================================================\n  // DOM\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-perf-hud';\n  root.hidden = true;\n  root.setAttribute('aria-hidden', 'true');\n\n  const fpsEl = document.createElement('div');\n  fpsEl.className = 'we-perf-hud-line';\n  fpsEl.textContent = 'FPS: --';\n\n  const heapEl = document.createElement('div');\n  heapEl.className = 'we-perf-hud-line';\n  heapEl.textContent = 'Heap: --';\n\n  root.append(fpsEl, heapEl);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  let enabled = false;\n  let rafId: number | null = null;\n\n  let frameCount = 0;\n  let lastFpsUiTime = 0;\n  let lastMemorySampleTime = 0;\n\n  let lastFpsText = fpsEl.textContent ?? '';\n  let lastHeapText = heapEl.textContent ?? '';\n\n  // ==========================================================================\n  // RAF Management\n  // ==========================================================================\n\n  function cancelRaf(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n  disposer.add(cancelRaf);\n\n  function setText(el: HTMLElement, next: string, cache: 'fps' | 'heap'): void {\n    if (cache === 'fps') {\n      if (next === lastFpsText) return;\n      lastFpsText = next;\n    } else {\n      if (next === lastHeapText) return;\n      lastHeapText = next;\n    }\n    el.textContent = next;\n  }\n\n  function updateHeapText(): void {\n    const memory = readPerformanceMemory();\n    if (!memory) {\n      setText(heapEl, 'Heap: N/A', 'heap');\n      return;\n    }\n\n    const used = formatMb(memory.usedJSHeapSize, 1);\n    const limit = formatMb(memory.jsHeapSizeLimit, 0);\n    setText(heapEl, `Heap: ${used} / ${limit} MB`, 'heap');\n  }\n\n  function resetSampling(now: number): void {\n    frameCount = 0;\n    lastFpsUiTime = now;\n    // Force an immediate memory sample on next frame\n    lastMemorySampleTime = now - memorySampleIntervalMs;\n    setText(fpsEl, 'FPS: --', 'fps');\n    updateHeapText();\n  }\n\n  function scheduleNextFrame(): void {\n    if (disposer.isDisposed) return;\n    if (!enabled) return;\n    if (rafId !== null) return;\n    if (document.visibilityState !== 'visible') return;\n\n    rafId = requestAnimationFrame(onFrame);\n  }\n\n  function onFrame(now: number): void {\n    rafId = null;\n    if (disposer.isDisposed) return;\n    if (!enabled) return;\n    if (document.visibilityState !== 'visible') return;\n\n    frameCount += 1;\n\n    const fpsElapsed = now - lastFpsUiTime;\n    if (fpsElapsed >= fpsUiIntervalMs) {\n      const fps = fpsElapsed > 0 ? (frameCount * 1000) / fpsElapsed : 0;\n      const rounded = Math.max(0, Math.round(fps));\n      setText(fpsEl, `FPS: ${rounded}`, 'fps');\n      frameCount = 0;\n      lastFpsUiTime = now;\n    }\n\n    if (now - lastMemorySampleTime >= memorySampleIntervalMs) {\n      lastMemorySampleTime = now;\n      updateHeapText();\n    }\n\n    scheduleNextFrame();\n  }\n\n  // ==========================================================================\n  // Visibility Handling\n  // ==========================================================================\n\n  function handleVisibilityChange(): void {\n    if (!enabled) return;\n\n    if (document.visibilityState !== 'visible') {\n      cancelRaf();\n      return;\n    }\n\n    // Resume with fresh sampling window to avoid low FPS spikes after tab hidden.\n    resetSampling(performance.now());\n    scheduleNextFrame();\n  }\n\n  disposer.listen(document, 'visibilitychange', handleVisibilityChange);\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setEnabled(next: boolean): void {\n    if (enabled === next) return;\n    enabled = next;\n\n    if (!enabled) {\n      root.hidden = true;\n      cancelRaf();\n      return;\n    }\n\n    root.hidden = false;\n\n    if (document.visibilityState !== 'visible') {\n      // Stay paused until visible again\n      return;\n    }\n\n    resetSampling(performance.now());\n    scheduleNextFrame();\n  }\n\n  function toggle(): boolean {\n    setEnabled(!enabled);\n    return enabled;\n  }\n\n  return {\n    isEnabled: () => enabled,\n    setEnabled,\n    toggle,\n    dispose: () => {\n      // Ensure rAF is stopped before cleanup\n      enabled = false;\n      root.hidden = true;\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/position-tracker.ts",
    "content": "/**\n * Position Tracker\n *\n * Keeps hover/selection rectangles in sync with viewport changes (scroll/resize).\n *\n * Design:\n * - Uses passive scroll/resize listeners to avoid blocking scrolling\n * - Coalesces updates with requestAnimationFrame so layout is read at most once per frame\n * - Drops references to elements that are no longer connected to the DOM\n * - Only emits updates when rect values actually change (with epsilon tolerance)\n *\n * Performance considerations:\n * - getBoundingClientRect() calls are batched to single rAF\n * - Sub-pixel jitter is filtered to reduce unnecessary redraws\n * - Passive listeners don't block smooth scrolling\n */\n\nimport type { ViewportRect } from '../overlay/canvas-overlay';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating the position tracker */\nexport interface PositionTrackerOptions {\n  /** Callback when tracked positions change */\n  onPositionUpdate: (rects: TrackedRects) => void;\n}\n\n/** Container for tracked element rectangles */\nexport interface TrackedRects {\n  /** Hover element rectangle (null if no hover) */\n  hover: ViewportRect | null;\n  /** Selection element rectangle (null if no selection) */\n  selection: ViewportRect | null;\n}\n\n/** Position tracker public interface */\nexport interface PositionTracker {\n  /** Set the element to track for hover */\n  setHoverElement(element: Element | null): void;\n  /** Set the element to track for selection */\n  setSelectionElement(element: Element | null): void;\n  /** Force a synchronous position update (useful for initialization) */\n  forceUpdate(): void;\n  /** Cleanup listeners and pending rAF */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Passive listener options for scroll/resize */\nconst PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };\n\n/** Sub-pixel threshold for rect comparison (avoids jitter-induced redraws) */\nconst RECT_EPSILON = 0.5;\n\n/**\n * MutationObserver options for selection sync.\n * Keep lightweight: only observe childList changes in subtree.\n * Attribute changes are not observed as they rarely affect element position/removal.\n */\nconst SELECTION_MUTATION_OPTIONS: MutationObserverInit = {\n  childList: true,\n  subtree: true,\n};\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Convert DOMRect to ViewportRect, returning null for invalid values\n */\nfunction toViewportRect(domRect: DOMRectReadOnly): ViewportRect | null {\n  const { left, top, width, height } = domRect;\n\n  if (\n    !Number.isFinite(left) ||\n    !Number.isFinite(top) ||\n    !Number.isFinite(width) ||\n    !Number.isFinite(height)\n  ) {\n    return null;\n  }\n\n  // Ensure non-negative dimensions\n  return {\n    left,\n    top,\n    width: Math.max(0, width),\n    height: Math.max(0, height),\n  };\n}\n\n/**\n * Check if two numbers are approximately equal\n */\nfunction approximatelyEqual(a: number, b: number): boolean {\n  return Math.abs(a - b) < RECT_EPSILON;\n}\n\n/**\n * Check if two ViewportRects are approximately equal\n */\nfunction rectApproximatelyEqual(a: ViewportRect | null, b: ViewportRect | null): boolean {\n  if (a === b) return true;\n  if (!a || !b) return false;\n\n  return (\n    approximatelyEqual(a.left, b.left) &&\n    approximatelyEqual(a.top, b.top) &&\n    approximatelyEqual(a.width, b.width) &&\n    approximatelyEqual(a.height, b.height)\n  );\n}\n\n/**\n * Check if two TrackedRects are approximately equal\n */\nfunction trackedRectsEqual(a: TrackedRects, b: TrackedRects): boolean {\n  return (\n    rectApproximatelyEqual(a.hover, b.hover) && rectApproximatelyEqual(a.selection, b.selection)\n  );\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a position tracker for monitoring element positions.\n *\n * The tracker automatically updates when the viewport scrolls or resizes,\n * and notifies via callback when tracked element positions change.\n */\nexport function createPositionTracker(options: PositionTrackerOptions): PositionTracker {\n  const { onPositionUpdate } = options;\n  const disposer = new Disposer();\n\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  let hoverElement: Element | null = null;\n  let selectionElement: Element | null = null;\n  let lastRects: TrackedRects = { hover: null, selection: null };\n\n  // Single rAF slot for coalescing updates\n  let rafId: number | null = null;\n\n  // ==========================================================================\n  // RAF Management\n  // ==========================================================================\n\n  function cancelRaf(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n  disposer.add(cancelRaf);\n\n  function scheduleUpdate(): void {\n    if (disposer.isDisposed) return;\n    if (rafId !== null) return;\n\n    rafId = requestAnimationFrame(() => {\n      rafId = null;\n      updateIfChanged();\n    });\n  }\n\n  // ==========================================================================\n  // Selection Observers (Phase 2.8)\n  // ==========================================================================\n  //\n  // Observers are only active while an element is selected.\n  // - ResizeObserver: watches selected element size changes\n  // - MutationObserver: watches DOM structure changes to keep selection rect in sync\n  //\n  // Design decisions:\n  // - Only observe selection (not hover) because hover changes too frequently\n  // - MutationObserver on document.body with childList+subtree catches most DOM changes\n  // - rAF coalescing in scheduleUpdate() prevents observer storms from causing issues\n\n  /** Disposer for selection-specific observers (recreated when selection changes) */\n  let selectionObservers = new Disposer();\n  disposer.add(() => selectionObservers.dispose());\n\n  /**\n   * Reset selection observers when selection changes.\n   * Disconnects old observers and sets up new ones for the current selection.\n   */\n  function resetSelectionObservers(): void {\n    // Disconnect previous observers\n    selectionObservers.dispose();\n    selectionObservers = new Disposer();\n\n    const target = selectionElement;\n    if (!target) return;\n\n    // ResizeObserver: watch selected element size changes\n    // This catches CSS transitions, flex/grid reflow, content changes, etc.\n    selectionObservers.observeResize(target, () => {\n      if (disposer.isDisposed) return;\n      // Guard against stale observer callbacks\n      if (selectionElement !== target) return;\n      scheduleUpdate();\n    });\n\n    // MutationObserver: watch for structural DOM changes\n    // This catches element removal, reparenting, reordering, etc.\n    const mutationCallback = () => {\n      if (disposer.isDisposed) return;\n      // Guard against stale observer callbacks\n      if (selectionElement !== target) return;\n      scheduleUpdate();\n    };\n\n    // If the element is inside a Shadow DOM, observe that shadow root\n    // (MutationObserver doesn't cross shadow boundaries)\n    const rootNode = target.getRootNode?.();\n    if (rootNode instanceof ShadowRoot) {\n      selectionObservers.observeMutation(rootNode, mutationCallback, SELECTION_MUTATION_OPTIONS);\n    }\n\n    // Always observe document.body to catch cross-tree changes and removals\n    const body = document.body ?? document.documentElement;\n    if (body) {\n      selectionObservers.observeMutation(body, mutationCallback, SELECTION_MUTATION_OPTIONS);\n    }\n  }\n\n  // ==========================================================================\n  // Position Computation\n  // ==========================================================================\n\n  /**\n   * Get element if still connected to DOM, null otherwise\n   */\n  function resolveConnected(element: Element | null): Element | null {\n    if (!element) return null;\n    return element.isConnected ? element : null;\n  }\n\n  /**\n   * Safely read element's viewport rect\n   */\n  function readElementRect(element: Element | null): ViewportRect | null {\n    if (!element) return null;\n    try {\n      return toViewportRect(element.getBoundingClientRect());\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Compute current rects for all tracked elements\n   */\n  function computeRects(): TrackedRects {\n    // Resolve elements, dropping stale references\n    const resolvedHover = resolveConnected(hoverElement);\n    const resolvedSelection = resolveConnected(selectionElement);\n\n    // Clear stale element references\n    if (hoverElement && !resolvedHover) {\n      hoverElement = null;\n    }\n    if (selectionElement && !resolvedSelection) {\n      selectionElement = null;\n      // Clean up observers when selection element is disconnected\n      resetSelectionObservers();\n    }\n\n    // Optimization: if both point to same element, read rect once\n    if (resolvedHover && resolvedSelection && resolvedHover === resolvedSelection) {\n      const rect = readElementRect(resolvedHover);\n      return { hover: rect, selection: rect };\n    }\n\n    return {\n      hover: readElementRect(resolvedHover),\n      selection: readElementRect(resolvedSelection),\n    };\n  }\n\n  /**\n   * Update rects and notify if changed\n   */\n  function updateIfChanged(): void {\n    if (disposer.isDisposed) return;\n\n    const nextRects = computeRects();\n    if (trackedRectsEqual(nextRects, lastRects)) return;\n\n    lastRects = nextRects;\n    onPositionUpdate(nextRects);\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  function handleViewportChange(): void {\n    // Fast-path: nothing to track and nothing rendered\n    if (!hoverElement && !selectionElement && !lastRects.hover && !lastRects.selection) {\n      return;\n    }\n    scheduleUpdate();\n  }\n\n  // ==========================================================================\n  // Event Registration\n  // ==========================================================================\n\n  // Listen for scroll on window (captures most scrolling scenarios)\n  disposer.listen(window, 'scroll', handleViewportChange, PASSIVE_LISTENER);\n\n  // Capture scroll events from scrollable containers (scroll doesn't bubble,\n  // so we use capture phase to intercept scroll events from nested containers)\n  disposer.listen(document, 'scroll', handleViewportChange, { ...PASSIVE_LISTENER, capture: true });\n\n  // Listen for resize\n  disposer.listen(window, 'resize', handleViewportChange, PASSIVE_LISTENER);\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setHoverElement(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (hoverElement === element) return;\n    hoverElement = element;\n    scheduleUpdate();\n  }\n\n  function setSelectionElement(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (selectionElement === element) return;\n    selectionElement = element;\n    // Reset observers for new selection (or clear if null)\n    resetSelectionObservers();\n    scheduleUpdate();\n  }\n\n  function forceUpdate(): void {\n    if (disposer.isDisposed) return;\n    cancelRaf();\n    updateIfChanged();\n  }\n\n  return {\n    setHoverElement,\n    setSelectionElement,\n    forceUpdate,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/props-bridge.ts",
    "content": "/**\n * Props Bridge - ISOLATED World Communication Layer\n *\n * Bridges the Web Editor V2 UI (ISOLATED world) and the Props Agent (MAIN world)\n * using CustomEvent-based messaging.\n *\n * Design notes:\n * - Uses requestId + pending map for request/response correlation\n * - Implements timeout to prevent hanging UI if agent is missing\n * - Returns structured results with both success/error state and partial data\n *\n * @module props-bridge\n */\n\nimport type { DebugSource, ElementLocator } from '@/common/web-editor-types';\n\n// =============================================================================\n// Types - Hook Status\n// =============================================================================\n\n/**\n * React DevTools Hook detection status\n */\nexport type HookStatus =\n  | 'READY' // Hook exists with editable renderer\n  | 'HOOK_PRESENT_NO_RENDERERS' // Hook exists but no renderers registered\n  | 'RENDERERS_NO_EDITING' // Renderers exist but no overrideProps (production build)\n  | 'HOOK_MISSING'; // No hook present\n\n/**\n * Detected framework type\n */\nexport type FrameworkType = 'react' | 'vue' | 'unknown';\n\n/**\n * Agent capabilities for the current element/framework\n */\nexport interface PropsCapabilities {\n  canRead: boolean;\n  canWrite: boolean;\n  canWriteHooks: boolean;\n}\n\n// =============================================================================\n// Types - Props Path & Value\n// =============================================================================\n\nexport type PropPathSegment = string | number;\nexport type PropPath = PropPathSegment[];\n\n/**\n * Primitive values that can be edited\n */\nexport type EditablePropValue = string | number | boolean | null | undefined;\n\n/**\n * Wire format for prop values (undefined is encoded specially)\n */\nexport type EncodedPropValue = Exclude<EditablePropValue, undefined> | { $we: 'undefined' };\n\n// =============================================================================\n// Types - Serialized Values\n// =============================================================================\n\ninterface SerializedValueBase {\n  kind: string;\n}\n\nexport type SerializedValue =\n  | ({ kind: 'null' } & SerializedValueBase)\n  | ({ kind: 'undefined' } & SerializedValueBase)\n  | ({ kind: 'boolean'; value: boolean } & SerializedValueBase)\n  | ({\n      kind: 'number';\n      value?: number;\n      special?: 'NaN' | 'Infinity' | '-Infinity';\n    } & SerializedValueBase)\n  | ({\n      kind: 'string';\n      value: string;\n      truncated?: boolean;\n      length?: number;\n    } & SerializedValueBase)\n  | ({ kind: 'bigint'; value: string } & SerializedValueBase)\n  | ({ kind: 'symbol'; description: string } & SerializedValueBase)\n  | ({ kind: 'function'; name?: string } & SerializedValueBase)\n  | ({ kind: 'react_element'; display: string } & SerializedValueBase)\n  | ({\n      kind: 'dom_element';\n      tagName: string;\n      id?: string;\n      className?: string;\n    } & SerializedValueBase)\n  | ({ kind: 'date'; value: string } & SerializedValueBase)\n  | ({ kind: 'regexp'; source: string; flags: string } & SerializedValueBase)\n  | ({\n      kind: 'error';\n      name: string;\n      message: string;\n      stack?: string;\n    } & SerializedValueBase)\n  | ({ kind: 'circular'; refId: number } & SerializedValueBase)\n  | ({ kind: 'max_depth'; type: string; preview: string } & SerializedValueBase)\n  | ({\n      kind: 'array';\n      length: number;\n      truncated?: boolean;\n      items: SerializedValue[];\n    } & SerializedValueBase)\n  | ({\n      kind: 'object';\n      name?: string;\n      truncated?: boolean;\n      entries: Array<{ key: string; value: SerializedValue }>;\n    } & SerializedValueBase)\n  | ({\n      kind: 'map';\n      size: number;\n      truncated?: boolean;\n      entries: Array<{ key: SerializedValue; value: SerializedValue }>;\n    } & SerializedValueBase)\n  | ({\n      kind: 'set';\n      size: number;\n      truncated?: boolean;\n      items: SerializedValue[];\n    } & SerializedValueBase)\n  | ({ kind: 'unknown'; type: string; preview: string } & SerializedValueBase);\n\n/**\n * Enum value type (primitive values only)\n */\nexport type SerializedEnumValue = string | number | boolean;\n\n/**\n * Vue-only: source of a prop entry (declared props vs fallthrough attrs)\n */\nexport type PropEntrySource = 'props' | 'attrs';\n\n/**\n * Single prop entry with editability info\n */\nexport interface SerializedPropEntry {\n  key: string;\n  editable: boolean;\n  value: SerializedValue;\n  /**\n   * Vue-only: where this entry comes from.\n   * - 'props': declared props (instance.props)\n   * - 'attrs': fallthrough attrs (instance.attrs)\n   */\n  source?: PropEntrySource;\n  /** Available enum values (if this prop is an enum type) */\n  enumValues?: SerializedEnumValue[];\n}\n\n/**\n * Complete serialized props object\n */\nexport interface SerializedProps {\n  kind: 'props';\n  entries: SerializedPropEntry[];\n  truncated?: boolean;\n}\n\n// =============================================================================\n// Types - Protocol Messages\n// =============================================================================\n\nexport type PropsOperation = 'probe' | 'read' | 'write' | 'reset' | 'cleanup';\n\ninterface PropsRequestPayload {\n  propPath?: PropPath;\n  propValue?: EncodedPropValue;\n}\n\ninterface PropsRequestBase {\n  v: 1;\n  requestId: string;\n  op: PropsOperation;\n  locator?: ElementLocator;\n  payload?: PropsRequestPayload;\n}\n\ninterface PropsProbeRequest extends PropsRequestBase {\n  op: 'probe';\n}\n\ninterface PropsReadRequest extends PropsRequestBase {\n  op: 'read';\n  locator: ElementLocator;\n}\n\ninterface PropsWriteRequest extends PropsRequestBase {\n  op: 'write';\n  locator: ElementLocator;\n  payload: {\n    propPath: PropPath;\n    propValue: EncodedPropValue;\n  };\n}\n\ninterface PropsResetRequest extends PropsRequestBase {\n  op: 'reset';\n  locator: ElementLocator;\n}\n\ninterface PropsCleanupRequest extends PropsRequestBase {\n  op: 'cleanup';\n}\n\ntype PropsRequest =\n  | PropsProbeRequest\n  | PropsReadRequest\n  | PropsWriteRequest\n  | PropsResetRequest\n  | PropsCleanupRequest;\n\n/**\n * Response data from agent\n */\nexport interface PropsResponseData {\n  hookStatus?: HookStatus;\n  needsRefresh?: boolean;\n  framework?: FrameworkType;\n  /** Framework version (e.g., \"18.2.0\" for React, \"3.4.21\" for Vue) */\n  frameworkVersion?: string;\n  componentName?: string;\n  /** Source file location for the component (React _debugSource / Vue data-v-inspector) */\n  debugSource?: DebugSource;\n  props?: SerializedProps;\n  capabilities?: PropsCapabilities;\n  meta?: Record<string, unknown>;\n}\n\ninterface PropsRawResponse {\n  v: 1;\n  requestId: string;\n  success: boolean;\n  data?: PropsResponseData;\n  error?: string;\n}\n\n// =============================================================================\n// Types - Bridge API\n// =============================================================================\n\n/**\n * Result type that preserves both success/error state and partial data\n */\nexport interface PropsResult<T = PropsResponseData> {\n  ok: boolean;\n  data?: T;\n  error?: string;\n}\n\n/**\n * Custom error with response data attached\n */\nexport class PropsError extends Error {\n  readonly data?: PropsResponseData;\n\n  constructor(message: string, data?: PropsResponseData) {\n    super(message);\n    this.name = 'PropsError';\n    this.data = data;\n  }\n}\n\n/**\n * Props Bridge public interface\n */\nexport interface PropsBridge {\n  /**\n   * Probe agent capabilities for an element\n   */\n  probe(locator?: ElementLocator, timeoutMs?: number): Promise<PropsResult>;\n\n  /**\n   * Read props from element's component\n   */\n  read(locator: ElementLocator, timeoutMs?: number): Promise<PropsResult>;\n\n  /**\n   * Write a prop value\n   */\n  write(\n    locator: ElementLocator,\n    path: PropPath,\n    value: EditablePropValue,\n    timeoutMs?: number,\n  ): Promise<PropsResult>;\n\n  /**\n   * Reset overridden props to original values\n   */\n  reset(locator: ElementLocator, timeoutMs?: number): Promise<PropsResult>;\n\n  /**\n   * Cleanup agent resources\n   */\n  cleanup(timeoutMs?: number): Promise<void>;\n\n  /**\n   * Dispose bridge (remove listeners)\n   */\n  dispose(): void;\n\n  /**\n   * Check if bridge is disposed\n   */\n  isDisposed(): boolean;\n}\n\n/**\n * Options for creating Props Bridge\n */\nexport interface PropsBridgeOptions {\n  defaultTimeoutMs?: number;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst EVENT_NAME = {\n  REQUEST: 'web-editor-props:request',\n  RESPONSE: 'web-editor-props:response',\n  CLEANUP: 'web-editor-props:cleanup',\n} as const;\n\nconst PROTOCOL_VERSION = 1 as const;\n\nconst DEFAULT_TIMEOUT_MS = 2500;\nconst MIN_TIMEOUT_MS = 200;\n\n// =============================================================================\n// Utilities\n// =============================================================================\n\nfunction createRequestId(): string {\n  try {\n    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n      return crypto.randomUUID();\n    }\n  } catch {\n    // Fallback\n  }\n  return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`;\n}\n\nfunction encodePropValue(value: EditablePropValue): EncodedPropValue {\n  if (value === undefined) return { $we: 'undefined' };\n  return value;\n}\n\nfunction isObject(value: unknown): value is Record<string, unknown> {\n  return value !== null && typeof value === 'object';\n}\n\nfunction normalizeErrorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message || String(err);\n  return String(err);\n}\n\nfunction isEditablePrimitive(value: unknown): value is EditablePropValue {\n  if (value === null || value === undefined) return true;\n  const t = typeof value;\n  if (t === 'string' || t === 'boolean') return true;\n  if (t === 'number') return Number.isFinite(value as number);\n  return false;\n}\n\n// Dangerous keys that could cause prototype pollution\nconst DANGEROUS_KEYS = new Set([\n  '__proto__',\n  'constructor',\n  'prototype',\n  '__defineGetter__',\n  '__defineSetter__',\n  '__lookupGetter__',\n  '__lookupSetter__',\n]);\n\nfunction hasDangerousKey(path: PropPath): boolean {\n  return path.some((seg) => typeof seg === 'string' && DANGEROUS_KEYS.has(seg));\n}\n\n// =============================================================================\n// Props Bridge Implementation\n// =============================================================================\n\n/**\n * Create a Props Bridge instance for communicating with the MAIN world agent\n */\nexport function createPropsBridge(options: PropsBridgeOptions = {}): PropsBridge {\n  const defaultTimeoutMs = Math.max(MIN_TIMEOUT_MS, options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);\n\n  interface PendingEntry {\n    resolve: (result: PropsResult) => void;\n    timeoutId: number;\n  }\n\n  const pending = new Map<string, PendingEntry>();\n  let disposed = false;\n\n  function assertActive(): void {\n    if (disposed) {\n      throw new PropsError('PropsBridge is disposed');\n    }\n  }\n\n  function clearPending(error: string): void {\n    for (const [requestId, entry] of pending) {\n      clearTimeout(entry.timeoutId);\n      entry.resolve({ ok: false, error });\n      pending.delete(requestId);\n    }\n  }\n\n  function onResponse(event: Event): void {\n    if (disposed) return;\n\n    const detail = (event as CustomEvent).detail as unknown;\n    if (!isObject(detail)) return;\n    if (detail.v !== PROTOCOL_VERSION) return;\n\n    const requestId = typeof detail.requestId === 'string' ? detail.requestId : '';\n    if (!requestId) return;\n\n    const entry = pending.get(requestId);\n    if (!entry) return;\n\n    pending.delete(requestId);\n    clearTimeout(entry.timeoutId);\n\n    const success = Boolean(detail.success);\n    const data = (detail.data as PropsResponseData | undefined) ?? undefined;\n    const error = typeof detail.error === 'string' ? detail.error : undefined;\n\n    // Always return result with data, even on failure\n    entry.resolve({\n      ok: success,\n      data,\n      error: success ? undefined : error || 'Props agent error',\n    });\n  }\n\n  // Register response listener\n  window.addEventListener(EVENT_NAME.RESPONSE, onResponse as EventListener);\n\n  function sendRequest<T extends PropsRequest>(\n    request: T,\n    timeoutMs: number,\n  ): Promise<PropsResult> {\n    assertActive();\n\n    const { requestId } = request;\n    if (!requestId) {\n      return Promise.resolve({ ok: false, error: 'requestId is required' });\n    }\n\n    if (pending.has(requestId)) {\n      return Promise.resolve({ ok: false, error: `Duplicate requestId: ${requestId}` });\n    }\n\n    return new Promise<PropsResult>((resolve) => {\n      const timeoutId = window.setTimeout(() => {\n        pending.delete(requestId);\n        resolve({\n          ok: false,\n          error: `Props agent timeout after ${timeoutMs}ms (op=${request.op})`,\n        });\n      }, timeoutMs);\n\n      pending.set(requestId, { resolve, timeoutId });\n\n      try {\n        window.dispatchEvent(new CustomEvent(EVENT_NAME.REQUEST, { detail: request }));\n      } catch (err) {\n        clearTimeout(timeoutId);\n        pending.delete(requestId);\n        resolve({\n          ok: false,\n          error: `Failed to dispatch props request: ${normalizeErrorMessage(err)}`,\n        });\n      }\n    });\n  }\n\n  async function probe(locator?: ElementLocator, timeoutMs?: number): Promise<PropsResult> {\n    const request: PropsProbeRequest = {\n      v: PROTOCOL_VERSION,\n      requestId: createRequestId(),\n      op: 'probe',\n      locator,\n    };\n    return sendRequest(request, Math.max(MIN_TIMEOUT_MS, timeoutMs ?? defaultTimeoutMs));\n  }\n\n  async function read(locator: ElementLocator, timeoutMs?: number): Promise<PropsResult> {\n    const request: PropsReadRequest = {\n      v: PROTOCOL_VERSION,\n      requestId: createRequestId(),\n      op: 'read',\n      locator,\n    };\n    return sendRequest(request, Math.max(MIN_TIMEOUT_MS, timeoutMs ?? defaultTimeoutMs));\n  }\n\n  async function write(\n    locator: ElementLocator,\n    path: PropPath,\n    value: EditablePropValue,\n    timeoutMs?: number,\n  ): Promise<PropsResult> {\n    if (!Array.isArray(path) || path.length === 0) {\n      return { ok: false, error: 'prop path is required' };\n    }\n\n    // Security: reject dangerous keys to prevent prototype pollution\n    if (hasDangerousKey(path)) {\n      return { ok: false, error: 'Invalid prop path: contains dangerous key' };\n    }\n\n    if (!isEditablePrimitive(value)) {\n      return { ok: false, error: 'Only primitive prop values are supported' };\n    }\n\n    const request: PropsWriteRequest = {\n      v: PROTOCOL_VERSION,\n      requestId: createRequestId(),\n      op: 'write',\n      locator,\n      payload: {\n        propPath: path,\n        propValue: encodePropValue(value),\n      },\n    };\n    return sendRequest(request, Math.max(MIN_TIMEOUT_MS, timeoutMs ?? defaultTimeoutMs));\n  }\n\n  async function reset(locator: ElementLocator, timeoutMs?: number): Promise<PropsResult> {\n    const request: PropsResetRequest = {\n      v: PROTOCOL_VERSION,\n      requestId: createRequestId(),\n      op: 'reset',\n      locator,\n    };\n    return sendRequest(request, Math.max(MIN_TIMEOUT_MS, timeoutMs ?? defaultTimeoutMs));\n  }\n\n  async function cleanup(timeoutMs?: number): Promise<void> {\n    if (disposed) return;\n\n    const ms = Math.max(MIN_TIMEOUT_MS, timeoutMs ?? 800);\n\n    // Best-effort: ask agent to cleanup first\n    try {\n      const request: PropsCleanupRequest = {\n        v: PROTOCOL_VERSION,\n        requestId: createRequestId(),\n        op: 'cleanup',\n      };\n      await sendRequest(request, ms);\n    } catch {\n      // Ignore agent errors during cleanup\n    } finally {\n      // Dispatch cleanup event for any listeners\n      try {\n        window.dispatchEvent(new CustomEvent(EVENT_NAME.CLEANUP));\n      } catch {\n        // ignore\n      }\n      dispose();\n    }\n  }\n\n  function dispose(): void {\n    if (disposed) return;\n    disposed = true;\n\n    try {\n      window.removeEventListener(EVENT_NAME.RESPONSE, onResponse as EventListener);\n    } catch {\n      // ignore\n    }\n\n    clearPending('PropsBridge disposed');\n  }\n\n  function isDisposedFn(): boolean {\n    return disposed;\n  }\n\n  return {\n    probe,\n    read,\n    write,\n    reset,\n    cleanup,\n    dispose,\n    isDisposed: isDisposedFn,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/snap-engine.ts",
    "content": "/**\n * Snap Engine (Phase 4.2)\n *\n * Computes snapping and alignment guide lines for interactive resize operations.\n *\n * Architecture:\n * - Anchor collection (DOM reads) - called once per gesture to avoid layout thrash\n * - Pure geometry computation (called every frame) - no DOM access\n *\n * Performance considerations:\n * - Siblings are collected once when gesture threshold is exceeded\n * - Distance-based filtering limits anchor count to nearest N elements\n * - Lock/hysteresis mechanism prevents flicker at threshold boundaries\n */\n\nimport {\n  WEB_EDITOR_V2_SNAP_MAX_ANCHOR_ELEMENTS,\n  WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN,\n} from '../constants';\nimport type { DistanceLabel, ViewportLine, ViewportRect } from '../overlay/canvas-overlay';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Source of a snap anchor */\nexport type SnapAnchorSource = 'sibling' | 'viewport';\n\n/** X-axis anchor types (edges and center) */\nexport type SnapAnchorXType = 'left' | 'center' | 'right';\n\n/** Y-axis anchor types (edges and center) */\nexport type SnapAnchorYType = 'top' | 'middle' | 'bottom';\n\n/** Union of all anchor types */\nexport type SnapAnchorType = SnapAnchorXType | SnapAnchorYType;\n\n/** Base interface for snap anchors */\ninterface SnapAnchorBase<TType extends SnapAnchorType> {\n  /** Anchor coordinate in viewport space */\n  readonly value: number;\n  /** Type of anchor (which edge or center) */\n  readonly type: TType;\n  /** Where this anchor came from */\n  readonly source: SnapAnchorSource;\n  /** Source element rect (for guide line extent calculation) */\n  readonly sourceRect?: ViewportRect;\n}\n\n/** X-axis snap anchor */\nexport type SnapAnchorX = SnapAnchorBase<SnapAnchorXType>;\n\n/** Y-axis snap anchor */\nexport type SnapAnchorY = SnapAnchorBase<SnapAnchorYType>;\n\n/** Collection of anchors for both axes */\nexport interface SnapAnchors {\n  readonly x: readonly SnapAnchorX[];\n  readonly y: readonly SnapAnchorY[];\n}\n\n/** Active snap lock state for X axis */\nexport interface SnapLockX {\n  readonly type: SnapAnchorXType;\n  readonly value: number;\n  readonly source: SnapAnchorSource;\n  readonly sourceRect: ViewportRect | null;\n}\n\n/** Active snap lock state for Y axis */\nexport interface SnapLockY {\n  readonly type: SnapAnchorYType;\n  readonly value: number;\n  readonly source: SnapAnchorSource;\n  readonly sourceRect: ViewportRect | null;\n}\n\n/** Result of snap computation */\nexport interface SnapResult {\n  /** Rectangle after snapping applied */\n  readonly snappedRect: ViewportRect;\n  /** Guide lines to render */\n  readonly guideLines: readonly ViewportLine[];\n  /** Active X-axis lock (for hysteresis) */\n  readonly lockX: SnapLockX | null;\n  /** Active Y-axis lock (for hysteresis) */\n  readonly lockY: SnapLockY | null;\n}\n\n/** Resize direction info for snap computation */\nexport interface ResizeDirection {\n  readonly hasWest: boolean;\n  readonly hasEast: boolean;\n  readonly hasNorth: boolean;\n  readonly hasSouth: boolean;\n}\n\n/** Viewport dimensions for guide line calculation */\nexport interface ViewportSize {\n  readonly width: number;\n  readonly height: number;\n}\n\n/** Parameters for computeResizeSnap */\nexport interface ComputeResizeSnapParams {\n  /** Current proposed rectangle (before snapping) */\n  readonly rect: ViewportRect;\n  /** Which directions are being resized */\n  readonly resize: ResizeDirection;\n  /** Available snap anchors */\n  readonly anchors: SnapAnchors;\n  /** Distance threshold for snap activation (px) */\n  readonly thresholdPx: number;\n  /** Additional distance to maintain lock once snapped (px) */\n  readonly hysteresisPx: number;\n  /** Minimum allowed element size (px) */\n  readonly minSizePx: number;\n  /** Current X-axis lock from previous frame */\n  readonly lockX: SnapLockX | null;\n  /** Current Y-axis lock from previous frame */\n  readonly lockY: SnapLockY | null;\n  /** Viewport dimensions for guide line extent calculation */\n  readonly viewport: ViewportSize;\n}\n\n/** Parameters for computeDistanceLabels (Phase 4.3) */\nexport interface ComputeDistanceLabelsParams {\n  /** Current rectangle (typically snappedRect from computeResizeSnap) */\n  readonly rect: ViewportRect;\n  /** Active X-axis lock (from snap result) */\n  readonly lockX: SnapLockX | null;\n  /** Active Y-axis lock (from snap result) */\n  readonly lockY: SnapLockY | null;\n  /** Viewport dimensions */\n  readonly viewport: ViewportSize;\n  /** Minimum gap (px) to display label - hides 0 and sub-pixel gaps */\n  readonly minGapPx: number;\n}\n\n// =============================================================================\n// Internal Types\n// =============================================================================\n\n/** Fixed edge during resize (opposite to drag direction) */\ntype FixedEdgeX = 'left' | 'right' | null;\ntype FixedEdgeY = 'top' | 'bottom' | null;\n\n/** Candidate for best snap match */\ninterface SnapCandidate<TAnchor> {\n  readonly distance: number;\n  readonly anchor: TAnchor;\n  readonly snappedRect: ViewportRect;\n}\n\n// =============================================================================\n// Utility Functions\n// =============================================================================\n\nfunction isFiniteNumber(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value);\n}\n\nfunction isValidRect(rect: ViewportRect | null): rect is ViewportRect {\n  if (!rect) return false;\n  return (\n    isFiniteNumber(rect.left) &&\n    isFiniteNumber(rect.top) &&\n    isFiniteNumber(rect.width) &&\n    isFiniteNumber(rect.height) &&\n    rect.width > 0.5 &&\n    rect.height > 0.5\n  );\n}\n\nfunction readElementRect(element: Element): ViewportRect | null {\n  try {\n    const r = element.getBoundingClientRect();\n    const rect: ViewportRect = {\n      left: r.left,\n      top: r.top,\n      width: r.width,\n      height: r.height,\n    };\n    return isValidRect(rect) ? rect : null;\n  } catch {\n    return null;\n  }\n}\n\n// Rectangle edge/center accessors\nfunction rectRight(r: ViewportRect): number {\n  return r.left + r.width;\n}\n\nfunction rectBottom(r: ViewportRect): number {\n  return r.top + r.height;\n}\n\nfunction rectCenterX(r: ViewportRect): number {\n  return r.left + r.width / 2;\n}\n\nfunction rectCenterY(r: ViewportRect): number {\n  return r.top + r.height / 2;\n}\n\n/** Get X coordinate for a specific anchor type from a rect */\nfunction getRectXValue(rect: ViewportRect, type: SnapAnchorXType): number {\n  switch (type) {\n    case 'left':\n      return rect.left;\n    case 'center':\n      return rectCenterX(rect);\n    case 'right':\n      return rectRight(rect);\n  }\n}\n\n/** Get Y coordinate for a specific anchor type from a rect */\nfunction getRectYValue(rect: ViewportRect, type: SnapAnchorYType): number {\n  switch (type) {\n    case 'top':\n      return rect.top;\n    case 'middle':\n      return rectCenterY(rect);\n    case 'bottom':\n      return rectBottom(rect);\n  }\n}\n\n// =============================================================================\n// Anchor Collection\n// =============================================================================\n\nfunction createEmptyAnchors(): SnapAnchors {\n  return { x: [], y: [] };\n}\n\n/**\n * Collect snap anchors from sibling elements.\n *\n * Strategy:\n * 1. Find target's index in parent.children\n * 2. Scan outward from target (windowed scan) to avoid missing nearby siblings\n *    when target is in the middle/end of a large children list\n * 3. Read bounding rects (single layout pass per element)\n * 4. Sort by distance to target center\n * 5. Take nearest N elements\n * 6. Extract left/center/right and top/middle/bottom anchors\n *\n * 中文说明：使用双向扫描策略，从 target 位置向两侧扩展，\n * 避免当 target 在 children 后半部分时完全扫描不到附近元素。\n */\nexport function collectSiblingAnchors(target: Element): SnapAnchors {\n  const parent = target.parentElement;\n  if (!parent) return createEmptyAnchors();\n\n  const targetRect = readElementRect(target);\n  const refX = targetRect ? rectCenterX(targetRect) : 0;\n  const refY = targetRect ? rectCenterY(targetRect) : 0;\n\n  const children = parent.children;\n  const childCount = children.length;\n\n  // Find target index for windowed scan\n  let targetIndex = -1;\n  for (let i = 0; i < childCount; i++) {\n    if (children[i] === target) {\n      targetIndex = i;\n      break;\n    }\n  }\n  if (targetIndex === -1) return createEmptyAnchors();\n\n  // Windowed scan: expand outward from target index\n  // This ensures we scan nearby siblings first regardless of target position\n  const candidates: Array<{ rect: ViewportRect; distanceSquared: number }> = [];\n  let scanned = 0;\n  let leftOffset = 1;\n  let rightOffset = 1;\n\n  while (scanned < WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN) {\n    const leftIndex = targetIndex - leftOffset;\n    const rightIndex = targetIndex + rightOffset;\n    const canGoLeft = leftIndex >= 0;\n    const canGoRight = rightIndex < childCount;\n\n    if (!canGoLeft && !canGoRight) break;\n\n    // Scan left\n    if (canGoLeft) {\n      const child = children[leftIndex];\n      const rect = readElementRect(child);\n      if (rect) {\n        const dx = rectCenterX(rect) - refX;\n        const dy = rectCenterY(rect) - refY;\n        candidates.push({ rect, distanceSquared: dx * dx + dy * dy });\n      }\n      scanned++;\n      leftOffset++;\n    }\n\n    // Scan right\n    if (canGoRight && scanned < WEB_EDITOR_V2_SNAP_MAX_SIBLINGS_SCAN) {\n      const child = children[rightIndex];\n      const rect = readElementRect(child);\n      if (rect) {\n        const dx = rectCenterX(rect) - refX;\n        const dy = rectCenterY(rect) - refY;\n        candidates.push({ rect, distanceSquared: dx * dx + dy * dy });\n      }\n      scanned++;\n      rightOffset++;\n    }\n  }\n\n  // Sort by distance and take nearest\n  candidates.sort((a, b) => a.distanceSquared - b.distanceSquared);\n  const selected = candidates.slice(0, WEB_EDITOR_V2_SNAP_MAX_ANCHOR_ELEMENTS);\n\n  // Build anchor arrays\n  const xAnchors: SnapAnchorX[] = [];\n  const yAnchors: SnapAnchorY[] = [];\n\n  for (const { rect } of selected) {\n    // X-axis anchors\n    xAnchors.push({ type: 'left', value: rect.left, source: 'sibling', sourceRect: rect });\n    xAnchors.push({\n      type: 'center',\n      value: rectCenterX(rect),\n      source: 'sibling',\n      sourceRect: rect,\n    });\n    xAnchors.push({ type: 'right', value: rectRight(rect), source: 'sibling', sourceRect: rect });\n\n    // Y-axis anchors\n    yAnchors.push({ type: 'top', value: rect.top, source: 'sibling', sourceRect: rect });\n    yAnchors.push({\n      type: 'middle',\n      value: rectCenterY(rect),\n      source: 'sibling',\n      sourceRect: rect,\n    });\n    yAnchors.push({ type: 'bottom', value: rectBottom(rect), source: 'sibling', sourceRect: rect });\n  }\n\n  return { x: xAnchors, y: yAnchors };\n}\n\n/**\n * Collect snap anchors from viewport boundaries.\n *\n * Provides left/center/right edges at x=0, x=viewport/2, x=viewport\n * and top/middle/bottom edges at corresponding y positions.\n */\nexport function collectViewportAnchors(): SnapAnchors {\n  const viewportWidth = Math.max(1, window.innerWidth || 1);\n  const viewportHeight = Math.max(1, window.innerHeight || 1);\n\n  return {\n    x: [\n      { type: 'left', value: 0, source: 'viewport' },\n      { type: 'center', value: viewportWidth / 2, source: 'viewport' },\n      { type: 'right', value: viewportWidth, source: 'viewport' },\n    ],\n    y: [\n      { type: 'top', value: 0, source: 'viewport' },\n      { type: 'middle', value: viewportHeight / 2, source: 'viewport' },\n      { type: 'bottom', value: viewportHeight, source: 'viewport' },\n    ],\n  };\n}\n\n/**\n * Merge multiple anchor collections into one.\n */\nexport function mergeAnchors(...collections: SnapAnchors[]): SnapAnchors {\n  const x: SnapAnchorX[] = [];\n  const y: SnapAnchorY[] = [];\n\n  for (const collection of collections) {\n    x.push(...collection.x);\n    y.push(...collection.y);\n  }\n\n  return { x, y };\n}\n\n// =============================================================================\n// Snap Application\n// =============================================================================\n\n/**\n * Apply X-axis snap by adjusting rect to align specified edge/center with anchor value.\n *\n * @param rect Current rectangle\n * @param fixedEdge Which edge is fixed (opposite to drag direction)\n * @param type Which part of rect to align\n * @param value Target coordinate to snap to\n * @param minSize Minimum allowed width\n * @returns Adjusted rect or null if constraint violated\n */\nfunction applyXSnap(\n  rect: ViewportRect,\n  fixedEdge: FixedEdgeX,\n  type: SnapAnchorXType,\n  value: number,\n  minSize: number,\n): ViewportRect | null {\n  const left = rect.left;\n  const right = rectRight(rect);\n\n  // When left edge is fixed, we can snap right edge or center\n  if (fixedEdge === 'left') {\n    if (type === 'right') {\n      const width = value - left;\n      if (!isFiniteNumber(width) || width < minSize) return null;\n      return { left, top: rect.top, width, height: rect.height };\n    }\n    if (type === 'center') {\n      // Center at value means right = 2*value - left\n      const width = (value - left) * 2;\n      if (!isFiniteNumber(width) || width < minSize) return null;\n      return { left, top: rect.top, width, height: rect.height };\n    }\n    // Snapping to 'left' when left is fixed doesn't make sense\n    return rect;\n  }\n\n  // When right edge is fixed, we can snap left edge or center\n  if (fixedEdge === 'right') {\n    if (type === 'left') {\n      const width = right - value;\n      if (!isFiniteNumber(width) || width < minSize) return null;\n      return { left: value, top: rect.top, width, height: rect.height };\n    }\n    if (type === 'center') {\n      // Center at value means left = 2*value - right\n      const nextLeft = 2 * value - right;\n      const width = right - nextLeft;\n      if (!isFiniteNumber(width) || width < minSize) return null;\n      return { left: nextLeft, top: rect.top, width, height: rect.height };\n    }\n    // Snapping to 'right' when right is fixed doesn't make sense\n    return rect;\n  }\n\n  // No fixed edge - no X resize happening\n  return rect;\n}\n\n/**\n * Apply Y-axis snap by adjusting rect to align specified edge/center with anchor value.\n */\nfunction applyYSnap(\n  rect: ViewportRect,\n  fixedEdge: FixedEdgeY,\n  type: SnapAnchorYType,\n  value: number,\n  minSize: number,\n): ViewportRect | null {\n  const top = rect.top;\n  const bottom = rectBottom(rect);\n\n  if (fixedEdge === 'top') {\n    if (type === 'bottom') {\n      const height = value - top;\n      if (!isFiniteNumber(height) || height < minSize) return null;\n      return { left: rect.left, top, width: rect.width, height };\n    }\n    if (type === 'middle') {\n      const height = (value - top) * 2;\n      if (!isFiniteNumber(height) || height < minSize) return null;\n      return { left: rect.left, top, width: rect.width, height };\n    }\n    return rect;\n  }\n\n  if (fixedEdge === 'bottom') {\n    if (type === 'top') {\n      const height = bottom - value;\n      if (!isFiniteNumber(height) || height < minSize) return null;\n      return { left: rect.left, top: value, width: rect.width, height };\n    }\n    if (type === 'middle') {\n      const nextTop = 2 * value - bottom;\n      const height = bottom - nextTop;\n      if (!isFiniteNumber(height) || height < minSize) return null;\n      return { left: rect.left, top: nextTop, width: rect.width, height };\n    }\n    return rect;\n  }\n\n  return rect;\n}\n\n// =============================================================================\n// Best Snap Selection\n// =============================================================================\n\n/**\n * Find the best X-axis snap among all anchors.\n *\n * Selection criteria:\n * 1. Within threshold distance\n * 2. Produces valid rect (respects minSize)\n * 3. Closest distance wins\n * 4. Sibling anchors preferred over viewport at equal distance\n */\nfunction findBestXSnap(\n  rect: ViewportRect,\n  fixedEdge: FixedEdgeX,\n  anchors: readonly SnapAnchorX[],\n  allowedTypes: readonly SnapAnchorXType[],\n  threshold: number,\n  minSize: number,\n): SnapCandidate<SnapAnchorX> | null {\n  let best: SnapCandidate<SnapAnchorX> | null = null;\n\n  for (const anchor of anchors) {\n    if (!allowedTypes.includes(anchor.type)) continue;\n\n    const currentValue = getRectXValue(rect, anchor.type);\n    const distance = Math.abs(anchor.value - currentValue);\n    if (distance > threshold) continue;\n\n    const snappedRect = applyXSnap(rect, fixedEdge, anchor.type, anchor.value, minSize);\n    if (!snappedRect) continue;\n\n    // Compare with current best\n    const isBetter =\n      !best ||\n      distance < best.distance ||\n      (distance === best.distance &&\n        anchor.source === 'sibling' &&\n        best.anchor.source !== 'sibling');\n\n    if (isBetter) {\n      best = { distance, anchor, snappedRect };\n    }\n  }\n\n  return best;\n}\n\n/**\n * Find the best Y-axis snap among all anchors.\n */\nfunction findBestYSnap(\n  rect: ViewportRect,\n  fixedEdge: FixedEdgeY,\n  anchors: readonly SnapAnchorY[],\n  allowedTypes: readonly SnapAnchorYType[],\n  threshold: number,\n  minSize: number,\n): SnapCandidate<SnapAnchorY> | null {\n  let best: SnapCandidate<SnapAnchorY> | null = null;\n\n  for (const anchor of anchors) {\n    if (!allowedTypes.includes(anchor.type)) continue;\n\n    const currentValue = getRectYValue(rect, anchor.type);\n    const distance = Math.abs(anchor.value - currentValue);\n    if (distance > threshold) continue;\n\n    const snappedRect = applyYSnap(rect, fixedEdge, anchor.type, anchor.value, minSize);\n    if (!snappedRect) continue;\n\n    const isBetter =\n      !best ||\n      distance < best.distance ||\n      (distance === best.distance &&\n        anchor.source === 'sibling' &&\n        best.anchor.source !== 'sibling');\n\n    if (isBetter) {\n      best = { distance, anchor, snappedRect };\n    }\n  }\n\n  return best;\n}\n\n// =============================================================================\n// Guide Line Generation\n// =============================================================================\n\n/**\n * Build guide lines from active snap locks.\n *\n * Guide line extent:\n * - For viewport anchors: full viewport span\n * - For sibling anchors: from source element edge to snapped element edge\n *\n * Note: viewport dimensions are passed as parameters to keep this function pure\n * (no global window access), enabling better testability and potential worker usage.\n */\nfunction buildGuideLines(\n  snappedRect: ViewportRect,\n  lockX: SnapLockX | null,\n  lockY: SnapLockY | null,\n  viewport: ViewportSize,\n): ViewportLine[] {\n  const guides: ViewportLine[] = [];\n  const viewportWidth = Math.max(1, viewport.width);\n  const viewportHeight = Math.max(1, viewport.height);\n\n  if (lockX) {\n    const x = lockX.value;\n    if (lockX.source === 'viewport' || !lockX.sourceRect) {\n      // Full viewport vertical line\n      guides.push({ x1: x, y1: 0, x2: x, y2: viewportHeight });\n    } else {\n      // Line spanning from source to target\n      const sourceRect = lockX.sourceRect;\n      const y1 = Math.min(sourceRect.top, snappedRect.top);\n      const y2 = Math.max(rectBottom(sourceRect), rectBottom(snappedRect));\n      guides.push({ x1: x, y1, x2: x, y2 });\n    }\n  }\n\n  if (lockY) {\n    const y = lockY.value;\n    if (lockY.source === 'viewport' || !lockY.sourceRect) {\n      // Full viewport horizontal line\n      guides.push({ x1: 0, y1: y, x2: viewportWidth, y2: y });\n    } else {\n      // Line spanning from source to target\n      const sourceRect = lockY.sourceRect;\n      const x1 = Math.min(sourceRect.left, snappedRect.left);\n      const x2 = Math.max(rectRight(sourceRect), rectRight(snappedRect));\n      guides.push({ x1, y1: y, x2, y2: y });\n    }\n  }\n\n  return guides;\n}\n\n// =============================================================================\n// Main Computation\n// =============================================================================\n\n/**\n * Compute snapping for a resize operation.\n *\n * This function is pure (no DOM access) and should be called every frame\n * during resize drag operations.\n *\n * Snap semantics for resize:\n * - One edge is fixed (opposite to drag direction)\n * - The moving edge or center can snap to anchors\n * - Hysteresis keeps snap stable once activated\n */\nexport function computeResizeSnap(params: ComputeResizeSnapParams): SnapResult {\n  const { rect, resize, anchors, thresholdPx, hysteresisPx, minSizePx, viewport } = params;\n\n  // Early return for invalid input\n  if (!isValidRect(rect)) {\n    return { snappedRect: rect, guideLines: [], lockX: null, lockY: null };\n  }\n\n  // Determine fixed edges based on resize direction\n  // When dragging from west, right edge is fixed; when from east, left is fixed\n  const fixedEdgeX: FixedEdgeX = resize.hasWest ? 'right' : resize.hasEast ? 'left' : null;\n  const fixedEdgeY: FixedEdgeY = resize.hasNorth ? 'bottom' : resize.hasSouth ? 'top' : null;\n\n  // Determine allowed snap targets based on fixed edge\n  // When left is fixed, we can snap right edge or center\n  // When right is fixed, we can snap left edge or center\n  const allowedXTypes: readonly SnapAnchorXType[] =\n    fixedEdgeX === 'left' ? ['right', 'center'] : fixedEdgeX === 'right' ? ['left', 'center'] : [];\n\n  const allowedYTypes: readonly SnapAnchorYType[] =\n    fixedEdgeY === 'top' ? ['bottom', 'middle'] : fixedEdgeY === 'bottom' ? ['top', 'middle'] : [];\n\n  // Start with input rect\n  let snappedRect: ViewportRect = { ...rect };\n  let lockX: SnapLockX | null = params.lockX;\n  let lockY: SnapLockY | null = params.lockY;\n\n  // ==========================================================================\n  // X-axis snapping\n  // ==========================================================================\n  if (fixedEdgeX) {\n    // Check if existing lock should be maintained (hysteresis)\n    if (lockX) {\n      if (!allowedXTypes.includes(lockX.type)) {\n        // Lock type no longer valid for current resize direction\n        lockX = null;\n      } else {\n        const currentValue = getRectXValue(snappedRect, lockX.type);\n        const distance = Math.abs(lockX.value - currentValue);\n        const canApply = applyXSnap(snappedRect, fixedEdgeX, lockX.type, lockX.value, minSizePx);\n\n        // Keep lock if within threshold + hysteresis and can apply\n        if (distance > thresholdPx + hysteresisPx || !canApply) {\n          lockX = null;\n        }\n      }\n    }\n\n    // Apply existing lock or find new snap\n    if (lockX) {\n      const applied = applyXSnap(snappedRect, fixedEdgeX, lockX.type, lockX.value, minSizePx);\n      if (applied) snappedRect = applied;\n    } else {\n      const best = findBestXSnap(\n        snappedRect,\n        fixedEdgeX,\n        anchors.x,\n        allowedXTypes,\n        thresholdPx,\n        minSizePx,\n      );\n      if (best) {\n        lockX = {\n          type: best.anchor.type,\n          value: best.anchor.value,\n          source: best.anchor.source,\n          sourceRect: best.anchor.sourceRect ?? null,\n        };\n        snappedRect = best.snappedRect;\n      }\n    }\n  } else {\n    // Not resizing horizontally - clear lock\n    lockX = null;\n  }\n\n  // ==========================================================================\n  // Y-axis snapping\n  // ==========================================================================\n  if (fixedEdgeY) {\n    // Check if existing lock should be maintained (hysteresis)\n    if (lockY) {\n      if (!allowedYTypes.includes(lockY.type)) {\n        lockY = null;\n      } else {\n        const currentValue = getRectYValue(snappedRect, lockY.type);\n        const distance = Math.abs(lockY.value - currentValue);\n        const canApply = applyYSnap(snappedRect, fixedEdgeY, lockY.type, lockY.value, minSizePx);\n\n        if (distance > thresholdPx + hysteresisPx || !canApply) {\n          lockY = null;\n        }\n      }\n    }\n\n    // Apply existing lock or find new snap\n    if (lockY) {\n      const applied = applyYSnap(snappedRect, fixedEdgeY, lockY.type, lockY.value, minSizePx);\n      if (applied) snappedRect = applied;\n    } else {\n      const best = findBestYSnap(\n        snappedRect,\n        fixedEdgeY,\n        anchors.y,\n        allowedYTypes,\n        thresholdPx,\n        minSizePx,\n      );\n      if (best) {\n        lockY = {\n          type: best.anchor.type,\n          value: best.anchor.value,\n          source: best.anchor.source,\n          sourceRect: best.anchor.sourceRect ?? null,\n        };\n        snappedRect = best.snappedRect;\n      }\n    }\n  } else {\n    lockY = null;\n  }\n\n  // Build guide lines (viewport passed for pure function)\n  const guideLines = buildGuideLines(snappedRect, lockX, lockY, viewport);\n\n  return { snappedRect, guideLines, lockX, lockY };\n}\n\n// =============================================================================\n// Distance Labels (Phase 4.3)\n// =============================================================================\n\n/**\n * Check if a gap should be shown as a distance label.\n * Requires gap > 0 (no overlap/touching) AND gap >= minGap threshold.\n */\nfunction shouldShowGap(gap: number, minGap: number): boolean {\n  return isFiniteNumber(gap) && gap > 0 && gap >= minGap;\n}\n\n/**\n * Format a pixel value for display.\n */\nfunction formatDistanceText(px: number): string {\n  const rounded = Math.round(px);\n  const normalized = Object.is(rounded, -0) ? 0 : rounded;\n  return `${normalized}px`;\n}\n\n/**\n * Clamp a value within a range.\n */\nfunction clamp(value: number, min: number, max: number): number {\n  if (!isFiniteNumber(value)) return min;\n  return Math.min(max, Math.max(min, value));\n}\n\n/**\n * Compute distance labels from active snap locks.\n *\n * Rules (as per Phase 4.3 decisions):\n * - Hide when gap <= 0 (overlap or touching)\n * - Hide when gap < minGapPx (default 1px)\n * - For sibling locks:\n *   - lockX (vertical guide) → show vertical gap (Y) between rect and sourceRect\n *   - lockY (horizontal guide) → show horizontal gap (X) between rect and sourceRect\n * - For viewport locks:\n *   - Edge align shows the corresponding margin; if filtered, fallback to opposite side\n *   - Center align shows both margins (may yield 2 labels)\n *\n * 中文说明：\n * - 当发生对齐时，显示\"另一个方向\"的间距\n * - lockX 是垂直对齐线，所以显示 Y 方向的间距\n * - lockY 是水平对齐线，所以显示 X 方向的间距\n */\nexport function computeDistanceLabels(params: ComputeDistanceLabelsParams): DistanceLabel[] {\n  const { rect, lockX, lockY, viewport, minGapPx } = params;\n\n  if (!isValidRect(rect)) return [];\n\n  // Ensure viewport dimensions are valid (NaN-safe)\n  const viewportWidth = isFiniteNumber(viewport.width) ? Math.max(1, viewport.width) : 1;\n  const viewportHeight = isFiniteNumber(viewport.height) ? Math.max(1, viewport.height) : 1;\n  const minGap = Math.max(0, minGapPx);\n\n  const labels: DistanceLabel[] = [];\n\n  // ==========================================================================\n  // Sibling gaps (derived from active locks)\n  // ==========================================================================\n\n  // X lock (vertical guide) → show vertical gap (Y-axis distance)\n  if (lockX && lockX.source === 'sibling' && lockX.sourceRect) {\n    const other = lockX.sourceRect;\n    const gapAbove = rect.top - rectBottom(other); // target is below source\n    const gapBelow = other.top - rectBottom(rect); // target is above source\n\n    if (shouldShowGap(gapAbove, minGap)) {\n      labels.push({\n        kind: 'sibling',\n        axis: 'y',\n        value: Math.round(gapAbove),\n        text: formatDistanceText(gapAbove),\n        line: { x1: lockX.value, y1: rectBottom(other), x2: lockX.value, y2: rect.top },\n      });\n    } else if (shouldShowGap(gapBelow, minGap)) {\n      labels.push({\n        kind: 'sibling',\n        axis: 'y',\n        value: Math.round(gapBelow),\n        text: formatDistanceText(gapBelow),\n        line: { x1: lockX.value, y1: rectBottom(rect), x2: lockX.value, y2: other.top },\n      });\n    }\n  }\n\n  // Y lock (horizontal guide) → show horizontal gap (X-axis distance)\n  if (lockY && lockY.source === 'sibling' && lockY.sourceRect) {\n    const other = lockY.sourceRect;\n    const gapLeft = rect.left - rectRight(other); // target is right of source\n    const gapRight = other.left - rectRight(rect); // target is left of source\n\n    if (shouldShowGap(gapLeft, minGap)) {\n      labels.push({\n        kind: 'sibling',\n        axis: 'x',\n        value: Math.round(gapLeft),\n        text: formatDistanceText(gapLeft),\n        line: { x1: rectRight(other), y1: lockY.value, x2: rect.left, y2: lockY.value },\n      });\n    } else if (shouldShowGap(gapRight, minGap)) {\n      labels.push({\n        kind: 'sibling',\n        axis: 'x',\n        value: Math.round(gapRight),\n        text: formatDistanceText(gapRight),\n        line: { x1: rectRight(rect), y1: lockY.value, x2: other.left, y2: lockY.value },\n      });\n    }\n  }\n\n  // ==========================================================================\n  // Viewport margins (derived from viewport locks)\n  // ==========================================================================\n\n  if (lockX && lockX.source === 'viewport') {\n    // Y position for horizontal measurement lines (center of element)\n    const y = clamp(rectCenterY(rect), 0, viewportHeight);\n    const leftGap = rect.left;\n    const rightGap = viewportWidth - rectRight(rect);\n\n    const addLeft = (): boolean => {\n      if (!shouldShowGap(leftGap, minGap)) return false;\n      labels.push({\n        kind: 'viewport',\n        axis: 'x',\n        value: Math.round(leftGap),\n        text: formatDistanceText(leftGap),\n        line: { x1: 0, y1: y, x2: rect.left, y2: y },\n      });\n      return true;\n    };\n\n    const addRight = (): boolean => {\n      if (!shouldShowGap(rightGap, minGap)) return false;\n      labels.push({\n        kind: 'viewport',\n        axis: 'x',\n        value: Math.round(rightGap),\n        text: formatDistanceText(rightGap),\n        line: { x1: rectRight(rect), y1: y, x2: viewportWidth, y2: y },\n      });\n      return true;\n    };\n\n    // Center align: show both margins\n    // Edge align: show corresponding margin, fallback to opposite\n    if (lockX.type === 'center') {\n      addLeft();\n      addRight();\n    } else if (lockX.type === 'left') {\n      if (!addLeft()) addRight();\n    } else {\n      if (!addRight()) addLeft();\n    }\n  }\n\n  if (lockY && lockY.source === 'viewport') {\n    // X position for vertical measurement lines (center of element)\n    const x = clamp(rectCenterX(rect), 0, viewportWidth);\n    const topGap = rect.top;\n    const bottomGap = viewportHeight - rectBottom(rect);\n\n    const addTop = (): boolean => {\n      if (!shouldShowGap(topGap, minGap)) return false;\n      labels.push({\n        kind: 'viewport',\n        axis: 'y',\n        value: Math.round(topGap),\n        text: formatDistanceText(topGap),\n        line: { x1: x, y1: 0, x2: x, y2: rect.top },\n      });\n      return true;\n    };\n\n    const addBottom = (): boolean => {\n      if (!shouldShowGap(bottomGap, minGap)) return false;\n      labels.push({\n        kind: 'viewport',\n        axis: 'y',\n        value: Math.round(bottomGap),\n        text: formatDistanceText(bottomGap),\n        line: { x1: x, y1: rectBottom(rect), x2: x, y2: viewportHeight },\n      });\n      return true;\n    };\n\n    // Middle align: show both margins\n    // Edge align: show corresponding margin, fallback to opposite\n    if (lockY.type === 'middle') {\n      addTop();\n      addBottom();\n    } else if (lockY.type === 'top') {\n      if (!addTop()) addBottom();\n    } else {\n      if (!addBottom()) addTop();\n    }\n  }\n\n  return labels;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/transaction-aggregator.ts",
    "content": "/**\n * Transaction Aggregator (Phase 1.3)\n *\n * Aggregates undo-stack transactions by element for AgentChat integration.\n *\n * Responsibilities:\n * - Group transactions by stable elementKey (fallback: locatorKey for legacy txs)\n * - Compute net effect (first before -> last after) for style/text/class operations\n * - Produce ElementChangeSummary for UI chips + batch Apply prompt building\n */\n\nimport type {\n  ElementChangeSummary,\n  ElementChangeType,\n  ElementLocator,\n  NetEffectPayload,\n  Transaction,\n  WebEditorElementKey,\n} from '../../../common/web-editor-types';\n\nimport { generateElementLabel, generateFullElementLabel } from './element-key';\nimport { locateElement, locatorKey } from './locator';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Maximum length for text preview in UI */\nconst TEXT_PREVIEW_MAX_LENGTH = 96;\n\n/** Maximum length for fallback labels */\nconst FALLBACK_LABEL_MAX_LENGTH = 64;\n\n/** Transaction types that can be applied to Agent */\nconst APPLICABLE_TX_TYPES = new Set(['style', 'text', 'class']);\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\n/**\n * Normalize a key string value\n */\nfunction normalizeKey(value: unknown): string {\n  return String(value ?? '').trim();\n}\n\n/**\n * Normalize a style property value\n */\nfunction normalizeStyleValue(value: unknown): string {\n  return String(value ?? '').trim();\n}\n\n/**\n * Normalize text for preview display\n */\nfunction normalizePreviewText(value: unknown): string {\n  return String(value ?? '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\n/**\n * Truncate string with ellipsis\n */\nfunction truncate(value: unknown, maxLength: number): string {\n  const str = String(value ?? '');\n  if (str.length <= maxLength) return str;\n  return str.slice(0, Math.max(0, maxLength - 1)).trimEnd() + '…';\n}\n\n/**\n * Normalize and dedupe a class list\n */\nfunction normalizeClassList(input: readonly string[] | null | undefined): string[] {\n  const out: string[] = [];\n  const seen = new Set<string>();\n\n  for (const raw of input ?? []) {\n    const token = String(raw ?? '').trim();\n    if (!token) continue;\n    if (seen.has(token)) continue;\n    seen.add(token);\n    out.push(token);\n  }\n\n  return out;\n}\n\n/**\n * Safely locate an element from a locator\n */\nfunction safeLocateElement(locator: ElementLocator): Element | null {\n  if (typeof document === 'undefined') return null;\n  try {\n    return locateElement(locator);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Build fallback labels when element cannot be located\n */\nfunction buildFallbackLabels(\n  locator: ElementLocator,\n  elementKey: WebEditorElementKey,\n): { label: string; fullLabel: string } {\n  let label = '';\n\n  // Try to extract label from fingerprint\n  const fingerprint = normalizeKey(locator.fingerprint);\n  if (fingerprint) {\n    const parts = fingerprint\n      .split('|')\n      .map((p) => p.trim())\n      .filter(Boolean);\n    const tag = parts[0] ?? 'element';\n    const idPart = parts.find((p) => p.startsWith('id='));\n    const id = idPart ? idPart.slice('id='.length).trim() : '';\n    label = id ? `${tag}#${id}` : tag;\n  } else if (Array.isArray(locator.selectors) && locator.selectors.length > 0) {\n    // Fallback to first selector\n    label = truncate(normalizeKey(locator.selectors[0]), FALLBACK_LABEL_MAX_LENGTH) || 'element';\n  } else {\n    // Last resort: use element key\n    label = truncate(elementKey, FALLBACK_LABEL_MAX_LENGTH) || 'element';\n  }\n\n  // Build full label with context\n  const prefixParts: string[] = [];\n  const frame = (locator.frameChain ?? []).join('>').trim();\n  const shadow = (locator.shadowHostChain ?? []).join('>').trim();\n  if (frame) prefixParts.push(frame);\n  if (shadow) prefixParts.push(shadow);\n\n  const fullLabel = prefixParts.length ? `${prefixParts.join('>')} >> ${label}` : label;\n\n  return { label, fullLabel };\n}\n\n/**\n * Resolve labels for an element (live DOM lookup with fallback)\n */\nfunction resolveLabels(\n  locator: ElementLocator,\n  elementKey: WebEditorElementKey,\n): { label: string; fullLabel: string } {\n  const element = safeLocateElement(locator);\n  if (!element) return buildFallbackLabels(locator, elementKey);\n\n  return {\n    label: generateElementLabel(element),\n    fullLabel: generateFullElementLabel(element, locator.shadowHostChain),\n  };\n}\n\n/**\n * Infer the change type from what operations are present\n */\nfunction inferChangeType(\n  hasStyle: boolean,\n  hasText: boolean,\n  hasClass: boolean,\n): ElementChangeType {\n  const count = Number(hasStyle) + Number(hasText) + Number(hasClass);\n  if (count > 1) return 'mixed';\n  if (hasStyle) return 'style';\n  if (hasText) return 'text';\n  if (hasClass) return 'class';\n  return 'mixed';\n}\n\n// =============================================================================\n// Net Effect Computation\n// =============================================================================\n\ninterface StyleNetEffect {\n  before: Record<string, string>;\n  after: Record<string, string>;\n  added: number;\n  removed: number;\n  modified: number;\n  details: string[];\n}\n\n/**\n * Compute net style effect from multiple style transactions\n */\nfunction computeStyleNetEffect(txs: readonly Transaction[]): StyleNetEffect | null {\n  // Track first \"before\" and last \"after\" for each property\n  const firstBeforeByProp = new Map<string, string>();\n  const lastAfterByProp = new Map<string, string>();\n\n  for (const tx of txs) {\n    if (tx.type !== 'style') continue;\n\n    const beforeRaw = tx.before.styles ?? {};\n    const afterRaw = tx.after.styles ?? {};\n\n    const keys = new Set([...Object.keys(beforeRaw), ...Object.keys(afterRaw)]);\n    for (const rawProp of keys) {\n      const prop = String(rawProp ?? '').trim();\n      if (!prop) continue;\n\n      const b = normalizeStyleValue(beforeRaw[prop]);\n      const a = normalizeStyleValue(afterRaw[prop]);\n\n      // Record first seen \"before\" value\n      if (!firstBeforeByProp.has(prop)) {\n        firstBeforeByProp.set(prop, b);\n      }\n      // Always update to latest \"after\" value\n      lastAfterByProp.set(prop, a);\n    }\n  }\n\n  if (firstBeforeByProp.size === 0 && lastAfterByProp.size === 0) {\n    return null;\n  }\n\n  // Build net effect (only include properties that actually changed)\n  const before: Record<string, string> = {};\n  const after: Record<string, string> = {};\n\n  const allProps = new Set([...firstBeforeByProp.keys(), ...lastAfterByProp.keys()]);\n\n  for (const prop of allProps) {\n    const b = firstBeforeByProp.get(prop) ?? '';\n    const a = lastAfterByProp.get(prop) ?? '';\n    if (b === a) continue; // No net change\n    before[prop] = b;\n    after[prop] = a;\n  }\n\n  const changedProps = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort();\n\n  if (changedProps.length === 0) return null;\n\n  // Compute statistics\n  let added = 0;\n  let removed = 0;\n  let modified = 0;\n\n  for (const prop of changedProps) {\n    const b = normalizeStyleValue(before[prop]);\n    const a = normalizeStyleValue(after[prop]);\n\n    if (!b && a) added += 1;\n    else if (b && !a) removed += 1;\n    else modified += 1;\n  }\n\n  return { before, after, added, removed, modified, details: changedProps };\n}\n\ninterface TextNetEffect {\n  before: string;\n  after: string;\n  beforePreview: string;\n  afterPreview: string;\n}\n\n/**\n * Compute net text effect from multiple text transactions\n */\nfunction computeTextNetEffect(txs: readonly Transaction[]): TextNetEffect | null {\n  let before: string | undefined;\n  let after: string | undefined;\n\n  for (const tx of txs) {\n    if (tx.type !== 'text') continue;\n    if (before === undefined) {\n      before = String(tx.before.text ?? '');\n    }\n    after = String(tx.after.text ?? '');\n  }\n\n  if (before === undefined || after === undefined) return null;\n  if (before === after) return null;\n\n  const beforePreview = truncate(normalizePreviewText(before), TEXT_PREVIEW_MAX_LENGTH);\n  const afterPreview = truncate(normalizePreviewText(after), TEXT_PREVIEW_MAX_LENGTH);\n\n  return { before, after, beforePreview, afterPreview };\n}\n\ninterface ClassNetEffect {\n  before: string[];\n  after: string[];\n  added: string[];\n  removed: string[];\n}\n\n/**\n * Compute net class effect from multiple class transactions\n */\nfunction computeClassNetEffect(txs: readonly Transaction[]): ClassNetEffect | null {\n  let beforeRaw: string[] | undefined;\n  let afterRaw: string[] | undefined;\n\n  for (const tx of txs) {\n    if (tx.type !== 'class') continue;\n    if (!beforeRaw) {\n      beforeRaw = normalizeClassList(tx.before.classes);\n    }\n    afterRaw = normalizeClassList(tx.after.classes);\n  }\n\n  if (!beforeRaw || !afterRaw) return null;\n\n  const beforeSet = new Set(beforeRaw);\n  const afterSet = new Set(afterRaw);\n\n  const added = Array.from(afterSet)\n    .filter((c) => !beforeSet.has(c))\n    .sort();\n  const removed = Array.from(beforeSet)\n    .filter((c) => !afterSet.has(c))\n    .sort();\n\n  if (added.length === 0 && removed.length === 0) return null;\n\n  const before = Array.from(beforeSet).sort();\n  const after = Array.from(afterSet).sort();\n\n  return { before, after, added, removed };\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Aggregate transactions by element key and compute net effect summaries.\n *\n * @param transactions - Array of transactions (typically the undo stack)\n * @returns Array of element change summaries, sorted by most recent first\n *\n * Notes:\n * - Input is expected to be an undo stack (chronological), but this function\n *   sorts by timestamp to ensure deterministic results.\n * - For legacy transactions without elementKey, locatorKey(targetLocator) is\n *   used as a fallback. This may cause grouping issues when selectors change.\n * - Only applicable transaction types (style/text/class) are included in output.\n * - Elements with no net effect (changes that cancel out) are filtered.\n */\nexport function aggregateTransactionsByElement(\n  transactions: readonly Transaction[],\n): ElementChangeSummary[] {\n  // Sort by timestamp for deterministic results\n  const indexed = transactions.map((tx, index) => ({ tx, index }));\n  indexed.sort((a, b) => {\n    const at = Number(a.tx.timestamp ?? 0);\n    const bt = Number(b.tx.timestamp ?? 0);\n    if (at !== bt) return at - bt;\n    return a.index - b.index; // Preserve original order for same timestamp\n  });\n\n  // Group transactions by element key\n  const groups = new Map<WebEditorElementKey, Transaction[]>();\n\n  for (const { tx } of indexed) {\n    // Skip non-applicable transaction types\n    if (!APPLICABLE_TX_TYPES.has(tx.type)) continue;\n\n    const rawElementKey = normalizeKey(tx.elementKey);\n\n    let key: WebEditorElementKey;\n    if (rawElementKey) {\n      key = rawElementKey;\n    } else {\n      // Fallback to locatorKey for legacy transactions\n      try {\n        key = locatorKey(tx.targetLocator);\n      } catch {\n        // Use transaction ID to avoid cross-element grouping\n        key = `unknown:${tx.id}`;\n      }\n    }\n\n    const list = groups.get(key);\n    if (list) {\n      list.push(tx);\n    } else {\n      groups.set(key, [tx]);\n    }\n  }\n\n  // Build summaries for each element\n  const summaries: ElementChangeSummary[] = [];\n\n  for (const [elementKey, txs] of groups.entries()) {\n    if (txs.length === 0) continue;\n\n    // Use the latest transaction's locator for element lookup\n    const lastTx = txs[txs.length - 1];\n    const locator = lastTx.after?.locator ?? lastTx.targetLocator;\n\n    // Compute net effects\n    const style = computeStyleNetEffect(txs);\n    const text = computeTextNetEffect(txs);\n    const cls = computeClassNetEffect(txs);\n\n    const hasStyle = style !== null;\n    const hasText = text !== null;\n    const hasClass = cls !== null;\n\n    // Filter elements with no net effect\n    if (!hasStyle && !hasText && !hasClass) continue;\n\n    // Resolve labels (try live DOM, fallback to locator data)\n    const { label, fullLabel } = resolveLabels(locator, elementKey);\n\n    // Build net effect payload for batch Apply\n    const netEffect: NetEffectPayload = {\n      elementKey,\n      locator,\n    };\n    if (style) {\n      netEffect.styleChanges = { before: style.before, after: style.after };\n    }\n    if (text) {\n      netEffect.textChange = { before: text.before, after: text.after };\n    }\n    if (cls) {\n      netEffect.classChanges = { before: cls.before, after: cls.after };\n    }\n\n    // Build changes statistics for UI\n    const changes: ElementChangeSummary['changes'] = {};\n    if (style) {\n      changes.style = {\n        added: style.added,\n        removed: style.removed,\n        modified: style.modified,\n        details: style.details,\n      };\n    }\n    if (text) {\n      changes.text = {\n        beforePreview: text.beforePreview,\n        afterPreview: text.afterPreview,\n      };\n    }\n    if (cls) {\n      changes.class = {\n        added: cls.added,\n        removed: cls.removed,\n      };\n    }\n\n    // Compute metadata (use safe number conversion)\n    const updatedAt = txs.reduce((max, tx) => {\n      const ts = Number(tx.timestamp ?? 0);\n      return Number.isFinite(ts) ? Math.max(max, ts) : max;\n    }, 0);\n    const type = inferChangeType(hasStyle, hasText, hasClass);\n\n    summaries.push({\n      elementKey,\n      label,\n      fullLabel,\n      locator,\n      type,\n      changes,\n      transactionIds: txs.map((tx) => tx.id),\n      netEffect,\n      updatedAt,\n      debugSource: locator.debugSource,\n    });\n  }\n\n  // Sort by most recently changed first, then by label for consistency\n  summaries.sort((a, b) => b.updatedAt - a.updatedAt || a.label.localeCompare(b.label));\n\n  return summaries;\n}\n\n/**\n * Check if there are any applicable changes in the transaction list.\n * Useful for determining if the \"Apply\" button should be enabled.\n *\n * @param transactions - Array of transactions to check\n * @returns True if there are applicable style/text/class changes\n */\nexport function hasApplicableChanges(transactions: readonly Transaction[]): boolean {\n  const summaries = aggregateTransactionsByElement(transactions);\n  return summaries.length > 0;\n}\n\n/**\n * Get element keys that have applicable changes.\n *\n * @param transactions - Array of transactions to analyze\n * @returns Set of element keys with applicable changes\n */\nexport function getChangedElementKeys(\n  transactions: readonly Transaction[],\n): Set<WebEditorElementKey> {\n  const summaries = aggregateTransactionsByElement(transactions);\n  return new Set(summaries.map((s) => s.elementKey));\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/core/transaction-manager.ts",
    "content": "/**\n * Transaction Manager\n *\n * Locator-based undo/redo system for inline style edits.\n *\n * Design principles:\n * - Uses CSS selectors (not DOM references) for element identification\n * - Supports transaction merging for continuous edits (e.g., slider drag)\n * - Provides handle-based API for batched operations\n * - Emits change events for UI synchronization\n */\n\nimport type {\n  ElementLocator,\n  MoveOperationData,\n  MoveTransactionData,\n  StructureOperationData,\n  Transaction,\n  TransactionSnapshot,\n  WebEditorElementKey,\n} from '@/common/web-editor-types';\nimport { Disposer } from '../utils/disposables';\nimport { generateStableElementKey } from './element-key';\nimport { createElementLocator, locateElement, locatorKey } from './locator';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Change event action types */\nexport type TransactionChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback';\n\n/** Change event emitted when transaction state changes */\nexport interface TransactionChangeEvent {\n  action: TransactionChangeAction;\n  transaction: Transaction | null;\n  undoCount: number;\n  redoCount: number;\n}\n\n/** Options for creating the Transaction Manager */\nexport interface TransactionManagerOptions {\n  /** Maximum transactions to keep in history (oldest dropped) */\n  maxHistory?: number;\n  /** Time window (ms) for merging consecutive edits to same property */\n  mergeWindowMs?: number;\n  /** Enable Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z keyboard shortcuts */\n  enableKeyBindings?: boolean;\n  /** Check if event is from editor UI (to ignore keybindings) */\n  isEventFromEditorUi?: (event: Event) => boolean;\n  /** Custom time source (for testing) */\n  now?: () => number;\n  /** Called when transaction state changes */\n  onChange?: (event: TransactionChangeEvent) => void;\n  /** Called when applying a transaction fails */\n  onApplyError?: (error: unknown) => void;\n}\n\n/** Handle for an in-progress style transaction (for batching) */\nexport interface StyleTransactionHandle {\n  /** Unique handle ID */\n  readonly id: string;\n  /** CSS property being edited */\n  readonly property: string;\n  /** Target element locator */\n  readonly targetLocator: ElementLocator;\n  /** Update the style value (live preview) */\n  set(value: string): void;\n  /** Commit the transaction and record to history */\n  commit(options?: { merge?: boolean }): Transaction | null;\n  /** Rollback to original value without recording */\n  rollback(): void;\n}\n\n/**\n * Handle for an in-progress multi-style transaction (Phase 4.9)\n *\n * Used for operations that modify multiple CSS properties atomically,\n * such as resize handles (width + height) or position handles (top + left).\n */\nexport interface MultiStyleTransactionHandle {\n  /** Unique handle ID */\n  readonly id: string;\n  /** CSS properties being edited (normalized, unique) */\n  readonly properties: readonly string[];\n  /** Target element locator */\n  readonly targetLocator: ElementLocator;\n  /**\n   * Update one or more style values (live preview).\n   * Keys outside the declared `properties` are ignored.\n   */\n  set(values: Record<string, string>): void;\n  /** Commit the transaction and record to history */\n  commit(options?: { merge?: boolean }): Transaction | null;\n  /** Rollback all tracked properties to original values without recording */\n  rollback(): void;\n}\n\n/** Handle for an in-progress move transaction (Phase 2.4-2.6) */\nexport interface MoveTransactionHandle {\n  /** Unique handle ID */\n  readonly id: string;\n  /** Locator for the dragged element at drag start */\n  readonly beforeLocator: ElementLocator;\n  /** Original location */\n  readonly from: MoveOperationData;\n  /** Commit the move and record to history (call after DOM move) */\n  commit(targetAfterMove: Element): Transaction | null;\n  /** Cancel the move session without recording */\n  cancel(): void;\n}\n\n/** Transaction Manager public interface */\nexport interface TransactionManager {\n  /** Begin an interactive style edit (returns handle for batching) */\n  beginStyle(target: Element, property: string): StyleTransactionHandle | null;\n  /**\n   * Begin an interactive multi-style edit (Phase 4.9)\n   *\n   * For operations that modify multiple CSS properties atomically.\n   * Returns null if element doesn't support inline styles or properties list is empty.\n   */\n  beginMultiStyle(target: Element, properties: string[]): MultiStyleTransactionHandle | null;\n  /** Begin a drag move transaction (records before state at drag start) */\n  beginMove(target: Element): MoveTransactionHandle | null;\n  /** Apply a style change immediately and record transaction */\n  applyStyle(\n    target: Element,\n    property: string,\n    value: string,\n    options?: { merge?: boolean },\n  ): Transaction | null;\n  /** Record a style transaction without applying (for external changes) */\n  recordStyle(\n    locator: ElementLocator,\n    property: string,\n    beforeValue: string,\n    afterValue: string,\n    options?: { merge?: boolean },\n  ): Transaction | null;\n  /** Record a text transaction for contentEditable edit (Phase 2.7) */\n  recordText(target: Element, beforeText: string, afterText: string): Transaction | null;\n  /** Record a class list change and create transaction (Phase 4.7) */\n  recordClass(target: Element, beforeClasses: string[], afterClasses: string[]): Transaction | null;\n  /** Apply a structure operation and record transaction (Phase 5.5) */\n  applyStructure(target: Element, data: StructureOperationData): Transaction | null;\n  /** Undo the last transaction */\n  undo(): Transaction | null;\n  /** Redo the last undone transaction */\n  redo(): Transaction | null;\n  /** Check if undo is available */\n  canUndo(): boolean;\n  /** Check if redo is available */\n  canRedo(): boolean;\n  /** Get current undo stack (readonly) */\n  getUndoStack(): readonly Transaction[];\n  /** Get current redo stack (readonly) */\n  getRedoStack(): readonly Transaction[];\n  /** Clear all transaction history */\n  clear(): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_MAX_HISTORY = 100;\nconst DEFAULT_MERGE_WINDOW_MS = 800;\n\nconst KEYBIND_OPTIONS: AddEventListenerOptions = {\n  capture: true,\n  passive: false,\n};\n\n// =============================================================================\n// Style Helpers\n// =============================================================================\n\n/**\n * Normalize CSS property name to kebab-case.\n * Preserves custom properties (--var-name).\n */\nfunction normalizePropertyName(property: string): string {\n  const p = property.trim();\n  if (!p) return '';\n\n  // Preserve custom properties\n  if (p.startsWith('--')) return p;\n\n  // Already kebab-case\n  if (p.includes('-')) return p.toLowerCase();\n\n  // Convert camelCase to kebab-case\n  return p.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`).toLowerCase();\n}\n\n/**\n * Safely get CSSStyleDeclaration from element\n */\nfunction getInlineStyle(element: Element): CSSStyleDeclaration | null {\n  const htmlElement = element as HTMLElement;\n  const style = htmlElement.style;\n\n  if (!style) return null;\n  if (typeof style.getPropertyValue !== 'function') return null;\n  if (typeof style.setProperty !== 'function') return null;\n  if (typeof style.removeProperty !== 'function') return null;\n\n  return style;\n}\n\n/**\n * Read inline style property value\n */\nfunction readStyleValue(style: CSSStyleDeclaration, property: string): string {\n  const prop = normalizePropertyName(property);\n  if (!prop) return '';\n  return style.getPropertyValue(prop).trim();\n}\n\n/**\n * Write inline style property value\n */\nfunction writeStyleValue(style: CSSStyleDeclaration, property: string, value: string): void {\n  const prop = normalizePropertyName(property);\n  if (!prop) return;\n\n  const v = value.trim();\n  if (!v) {\n    style.removeProperty(prop);\n  } else {\n    style.setProperty(prop, v);\n  }\n}\n\n/**\n * Apply a styles snapshot to an element\n */\nfunction applyStylesSnapshot(element: Element, styles: Record<string, string> | undefined): void {\n  if (!styles) return;\n\n  const inlineStyle = getInlineStyle(element);\n  if (!inlineStyle) return;\n\n  for (const [property, value] of Object.entries(styles)) {\n    writeStyleValue(inlineStyle, property, value);\n  }\n}\n\n// =============================================================================\n// Class Helpers (Phase 4.7)\n// =============================================================================\n\n/**\n * Normalize class list: deduplicate, trim, remove empty tokens\n */\nfunction normalizeClassList(input: readonly string[] | null | undefined): string[] {\n  const out: string[] = [];\n  const seen = new Set<string>();\n\n  for (const raw of input ?? []) {\n    const token = String(raw ?? '').trim();\n    if (!token) continue;\n    if (seen.has(token)) continue;\n    seen.add(token);\n    out.push(token);\n  }\n\n  return out;\n}\n\n/**\n * Check if two string arrays are equal (order-sensitive)\n */\nfunction isSameStringList(a: readonly string[], b: readonly string[]): boolean {\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) return false;\n  }\n  return true;\n}\n\n/**\n * Read class list from element (compatible with SVG elements)\n */\nfunction readClassList(element: Element): string[] {\n  try {\n    // HTMLElement has classList, but SVG's className is SVGAnimatedString\n    const list = (element as HTMLElement).classList;\n    if (list && typeof list[Symbol.iterator] === 'function') {\n      return Array.from(list).filter(Boolean);\n    }\n  } catch {\n    // Fall back to attribute parsing\n  }\n\n  try {\n    const raw = element.getAttribute('class') ?? '';\n    return raw\n      .split(/\\s+/)\n      .map((t) => t.trim())\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Apply class list to element (compatible with SVG elements)\n * Uses setAttribute for cross-browser SVG compatibility\n */\nfunction applyClassListToElement(element: Element, classes: readonly string[]): void {\n  const normalized = normalizeClassList(classes);\n  const value = normalized.join(' ').trim();\n\n  try {\n    if (value) {\n      element.setAttribute('class', value);\n    } else {\n      element.removeAttribute('class');\n    }\n  } catch {\n    // Best-effort: element may be in an invalid state or disconnected\n  }\n}\n\n// =============================================================================\n// Structure Helpers (Phase 5.5)\n// =============================================================================\n\n/**\n * Read element's inline styles as a plain object.\n * Only includes explicitly set inline properties (not computed styles).\n */\nfunction readInlineStyleMap(element: Element): Record<string, string> | undefined {\n  const style = getInlineStyle(element);\n  if (!style) return undefined;\n\n  const result: Record<string, string> = {};\n  for (let i = 0; i < style.length; i++) {\n    const prop = style.item(i);\n    if (!prop) continue;\n    const value = style.getPropertyValue(prop).trim();\n    if (value) {\n      result[prop] = value;\n    }\n  }\n\n  return Object.keys(result).length > 0 ? result : undefined;\n}\n\n/**\n * Parse HTML string into a single root element.\n * Returns null if parsing fails or yields multiple root elements.\n */\nfunction parseSingleRootElement(html: string): Element | null {\n  const trimmed = String(html ?? '').trim();\n  if (!trimmed) return null;\n\n  try {\n    const template = document.createElement('template');\n    template.innerHTML = trimmed;\n\n    const firstChild = template.content.firstElementChild;\n    if (!firstChild || template.content.childElementCount !== 1) {\n      return null;\n    }\n    return firstChild;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Remove id attributes from an element and all its descendants.\n * Used by duplicate to avoid creating duplicate IDs on the page.\n */\nfunction stripIdsFromSubtree(root: Element): void {\n  try {\n    root.removeAttribute('id');\n    const descendantsWithId = root.querySelectorAll('[id]');\n    for (const el of Array.from(descendantsWithId)) {\n      el.removeAttribute('id');\n    }\n  } catch {\n    // Best-effort: ignore errors\n  }\n}\n\n/**\n * Insert an element into a parent at a specific position.\n * Used for deterministic undo/redo of delete/duplicate operations.\n */\nfunction insertElementAtPosition(\n  parent: Element,\n  element: Element,\n  position: MoveOperationData,\n): boolean {\n  if (!parent.isConnected) return false;\n\n  let reference: ChildNode | null = null;\n\n  // Anchor-first resolution for stability\n  if (position.anchorLocator) {\n    const anchor = locateElement(position.anchorLocator);\n    if (anchor && anchor.parentElement === parent) {\n      reference = position.anchorPosition === 'before' ? anchor : anchor.nextSibling;\n    }\n  }\n\n  // Fallback to index-based insertion\n  if (!reference) {\n    const children = Array.from(parent.children);\n    const index = Math.max(0, Math.min(position.insertIndex, children.length));\n    reference = children[index] ?? null;\n  }\n\n  try {\n    parent.insertBefore(element, reference);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Wrap an element with a new container at the same DOM position.\n * Returns the wrapper element on success, null on failure.\n */\nfunction wrapElementWithContainer(\n  target: Element,\n  wrapperTag: string,\n  wrapperStyles?: Record<string, string>,\n): Element | null {\n  const parent = target.parentElement;\n  if (!parent) return null;\n\n  const tag = String(wrapperTag || 'div').toLowerCase();\n  const wrapper = document.createElement(tag);\n\n  // Apply wrapper styles\n  if (wrapperStyles) {\n    applyStylesSnapshot(wrapper, wrapperStyles);\n  }\n\n  try {\n    parent.insertBefore(wrapper, target);\n    wrapper.appendChild(target);\n    return wrapper;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Unwrap a container that has exactly one element child.\n * Moves the child to the container's position and removes the container.\n * Returns the unwrapped child on success, null on failure.\n */\nfunction unwrapSingleChildContainer(wrapper: Element): Element | null {\n  const parent = wrapper.parentElement;\n  if (!parent) return null;\n  if (wrapper.childElementCount !== 1) return null;\n\n  const child = wrapper.firstElementChild;\n  if (!child) return null;\n\n  try {\n    parent.insertBefore(child, wrapper);\n    wrapper.remove();\n    return child;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Build insertion position data for inserting after a target element.\n * Used by duplicate to record where the clone was inserted.\n */\nfunction buildInsertAfterPosition(target: Element): MoveOperationData | null {\n  const parent = target.parentElement;\n  if (!parent) return null;\n\n  const siblings = Array.from(parent.children);\n  const index = siblings.indexOf(target);\n  if (index < 0) return null;\n\n  return {\n    parentLocator: createElementLocator(parent),\n    insertIndex: index + 1,\n    anchorLocator: createElementLocator(target),\n    anchorPosition: 'after',\n  };\n}\n\n// =============================================================================\n// Transaction Helpers\n// =============================================================================\n\nlet transactionSeq = 0;\n\n/**\n * Generate unique transaction ID\n */\nfunction generateTransactionId(timestamp: number): string {\n  transactionSeq += 1;\n  return `tx_${timestamp.toString(36)}_${transactionSeq.toString(36)}`;\n}\n\n/**\n * Create a style transaction record from style maps.\n * This is the core factory used by both single-style and multi-style APIs.\n *\n * @param id - Unique transaction identifier\n * @param locator - Target element locator\n * @param beforeStyles - Style values before the change\n * @param afterStyles - Style values after the change\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createStyleTransactionFromStyles(\n  id: string,\n  locator: ElementLocator,\n  beforeStyles: Record<string, string>,\n  afterStyles: Record<string, string>,\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const beforeSnapshot: TransactionSnapshot = {\n    locator,\n    styles: beforeStyles,\n  };\n\n  const afterSnapshot: TransactionSnapshot = {\n    locator,\n    styles: afterStyles,\n  };\n\n  return {\n    id,\n    type: 'style',\n    targetLocator: locator,\n    elementKey,\n    before: beforeSnapshot,\n    after: afterSnapshot,\n    timestamp,\n    merged: false,\n  };\n}\n\n/**\n * Create a style transaction record for a single property.\n * Convenience wrapper around createStyleTransactionFromStyles.\n *\n * @param id - Unique transaction identifier\n * @param locator - Target element locator\n * @param property - CSS property name\n * @param beforeValue - Property value before the change\n * @param afterValue - Property value after the change\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createStyleTransaction(\n  id: string,\n  locator: ElementLocator,\n  property: string,\n  beforeValue: string,\n  afterValue: string,\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const prop = normalizePropertyName(property);\n  return createStyleTransactionFromStyles(\n    id,\n    locator,\n    { [prop]: beforeValue },\n    { [prop]: afterValue },\n    timestamp,\n    elementKey,\n  );\n}\n\n/**\n * Create a text transaction record (Phase 2.7)\n *\n * @param id - Unique transaction identifier\n * @param locator - Target element locator\n * @param beforeText - Text content before the change\n * @param afterText - Text content after the change\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createTextTransaction(\n  id: string,\n  locator: ElementLocator,\n  beforeText: string,\n  afterText: string,\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const beforeSnapshot: TransactionSnapshot = {\n    locator,\n    text: beforeText,\n  };\n\n  const afterSnapshot: TransactionSnapshot = {\n    locator,\n    text: afterText,\n  };\n\n  return {\n    id,\n    type: 'text',\n    targetLocator: locator,\n    elementKey,\n    before: beforeSnapshot,\n    after: afterSnapshot,\n    timestamp,\n    merged: false,\n  };\n}\n\n/**\n * Create a class transaction record (Phase 4.7)\n *\n * Uses separate before/after locators to improve undo/redo recovery\n * when CSS selectors include class-based matching.\n *\n * @param id - Unique transaction identifier\n * @param beforeLocator - Element locator before class change\n * @param afterLocator - Element locator after class change\n * @param beforeClasses - Class list before the change\n * @param afterClasses - Class list after the change\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createClassTransaction(\n  id: string,\n  beforeLocator: ElementLocator,\n  afterLocator: ElementLocator,\n  beforeClasses: string[],\n  afterClasses: string[],\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const beforeSnapshot: TransactionSnapshot = {\n    locator: beforeLocator,\n    classes: beforeClasses,\n  };\n\n  const afterSnapshot: TransactionSnapshot = {\n    locator: afterLocator,\n    classes: afterClasses,\n  };\n\n  return {\n    id,\n    type: 'class',\n    targetLocator: afterLocator,\n    elementKey,\n    before: beforeSnapshot,\n    after: afterSnapshot,\n    timestamp,\n    merged: false,\n  };\n}\n\n/**\n * Create a move transaction record (Phase 2.4-2.6)\n *\n * @param id - Unique transaction identifier\n * @param beforeLocator - Element locator before move\n * @param afterLocator - Element locator after move\n * @param moveData - Move operation data (from/to positions)\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createMoveTransaction(\n  id: string,\n  beforeLocator: ElementLocator,\n  afterLocator: ElementLocator,\n  moveData: MoveTransactionData,\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const beforeSnapshot: TransactionSnapshot = {\n    locator: beforeLocator,\n  };\n\n  const afterSnapshot: TransactionSnapshot = {\n    locator: afterLocator,\n  };\n\n  return {\n    id,\n    type: 'move',\n    targetLocator: afterLocator,\n    elementKey,\n    before: beforeSnapshot,\n    after: afterSnapshot,\n    moveData,\n    timestamp,\n    merged: false,\n  };\n}\n\n/**\n * Create a structure transaction record (Phase 5.5)\n *\n * Used for wrap/unwrap/delete/duplicate operations.\n * delete/duplicate store position + html for deterministic undo/redo.\n *\n * @param id - Unique transaction identifier\n * @param targetLocator - Primary target element locator\n * @param beforeLocator - Element locator before structure change\n * @param afterLocator - Element locator after structure change\n * @param structureData - Structure operation data\n * @param timestamp - Transaction timestamp\n * @param elementKey - Optional stable element key for transaction grouping\n */\nfunction createStructureTransaction(\n  id: string,\n  targetLocator: ElementLocator,\n  beforeLocator: ElementLocator,\n  afterLocator: ElementLocator,\n  structureData: StructureOperationData,\n  timestamp: number,\n  elementKey?: WebEditorElementKey,\n): Transaction {\n  const beforeSnapshot: TransactionSnapshot = { locator: beforeLocator };\n  const afterSnapshot: TransactionSnapshot = { locator: afterLocator };\n\n  return {\n    id,\n    type: 'structure',\n    targetLocator,\n    elementKey,\n    before: beforeSnapshot,\n    after: afterSnapshot,\n    structureData,\n    timestamp,\n    merged: false,\n  };\n}\n\n/**\n * Check if element is a disallowed target for structure operations (HTML/BODY/HEAD)\n * These elements should not be wrapped, deleted, duplicated, or unwrapped.\n */\nfunction isDisallowedStructureTarget(element: Element): boolean {\n  const tag = element.tagName?.toUpperCase();\n  return tag === 'HTML' || tag === 'BODY' || tag === 'HEAD';\n}\n\n/**\n * Check if element is a disallowed parent container for structure operations (HTML/HEAD only)\n * BODY is allowed as a parent container (unlike as a target).\n */\nfunction isDisallowedStructureContainer(element: Element): boolean {\n  const tag = element.tagName?.toUpperCase();\n  return tag === 'HTML' || tag === 'HEAD';\n}\n\n/**\n * Check if element is a disallowed move target (HTML/BODY/HEAD)\n */\nfunction isDisallowedMoveElement(element: Element): boolean {\n  const tag = element.tagName?.toUpperCase();\n  return tag === 'HTML' || tag === 'BODY' || tag === 'HEAD';\n}\n\n/**\n * Build MoveOperationData from element's current DOM position\n */\nfunction buildMoveOperationData(element: Element): MoveOperationData | null {\n  const parent = element.parentElement;\n  if (!parent) return null;\n\n  const siblings = Array.from(parent.children);\n  const insertIndex = siblings.indexOf(element);\n  if (insertIndex < 0) return null;\n\n  const parentLocator = createElementLocator(parent);\n\n  // Prefer anchoring to next sibling (insertBefore semantics)\n  const next = element.nextElementSibling;\n  if (next) {\n    return {\n      parentLocator,\n      insertIndex,\n      anchorLocator: createElementLocator(next),\n      anchorPosition: 'before',\n    };\n  }\n\n  // Fallback to previous sibling\n  const prev = element.previousElementSibling;\n  if (prev) {\n    return {\n      parentLocator,\n      insertIndex,\n      anchorLocator: createElementLocator(prev),\n      anchorPosition: 'after',\n    };\n  }\n\n  // No siblings - index only\n  return {\n    parentLocator,\n    insertIndex,\n    anchorPosition: 'before',\n  };\n}\n\n/**\n * Apply a move operation (for undo/redo)\n */\nfunction applyMoveOperation(target: Element, op: MoveOperationData): boolean {\n  if (!target.isConnected) return false;\n  if (isDisallowedMoveElement(target)) return false;\n\n  const parent = locateElement(op.parentLocator);\n  if (!parent) return false;\n  if (!parent.isConnected) return false;\n\n  // Disallow cross-root moves\n  const targetRoot = target.getRootNode?.();\n  const parentRoot = parent.getRootNode?.();\n  if (targetRoot && parentRoot && targetRoot !== parentRoot) return false;\n\n  // Prevent cycles (moving into own descendant)\n  if (target === parent || target.contains(parent)) return false;\n\n  let reference: ChildNode | null = null;\n\n  // Anchor-first resolution\n  if (op.anchorLocator) {\n    const anchor = locateElement(op.anchorLocator);\n    if (anchor && anchor !== target && anchor.parentElement === parent) {\n      reference = op.anchorPosition === 'before' ? anchor : anchor.nextSibling;\n      // Skip if reference is the target itself\n      if (reference === target) {\n        reference = target.nextSibling;\n      }\n    }\n  }\n\n  // Fallback: index-based\n  if (!reference) {\n    const children = Array.from(parent.children);\n    // Remove target from consideration if it's already in parent\n    const existingIndex = children.indexOf(target);\n    if (existingIndex !== -1) {\n      children.splice(existingIndex, 1);\n    }\n    const index = Math.max(0, Math.min(op.insertIndex, children.length));\n    reference = children[index] ?? null;\n  }\n\n  try {\n    parent.insertBefore(target, reference);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get the single style property from a transaction (if applicable)\n */\nfunction getSingleStyleProperty(tx: Transaction): string | null {\n  const keys = new Set<string>();\n\n  if (tx.before.styles) {\n    for (const k of Object.keys(tx.before.styles)) keys.add(k);\n  }\n  if (tx.after.styles) {\n    for (const k of Object.keys(tx.after.styles)) keys.add(k);\n  }\n\n  return keys.size === 1 ? Array.from(keys)[0]! : null;\n}\n\n/**\n * Check if two transactions can be merged\n */\nfunction canMerge(prev: Transaction, next: Transaction, mergeWindowMs: number): boolean {\n  // Only merge style transactions\n  if (prev.type !== 'style' || next.type !== 'style') return false;\n\n  // Check time window\n  if (Math.abs(next.timestamp - prev.timestamp) > mergeWindowMs) return false;\n\n  // Check same target element\n  if (locatorKey(prev.targetLocator) !== locatorKey(next.targetLocator)) return false;\n\n  // Check same property\n  const prevProp = getSingleStyleProperty(prev);\n  const nextProp = getSingleStyleProperty(next);\n  if (!prevProp || !nextProp || prevProp !== nextProp) return false;\n\n  return true;\n}\n\n/**\n * Merge next transaction into prev (mutates prev)\n */\nfunction mergeInto(prev: Transaction, next: Transaction): boolean {\n  const prop = getSingleStyleProperty(prev);\n  if (!prop) return false;\n\n  const nextValue = next.after.styles?.[prop];\n  if (nextValue === undefined) return false;\n\n  // Update prev's after state\n  if (!prev.after.styles) prev.after.styles = {};\n  prev.after.styles[prop] = nextValue;\n  prev.timestamp = next.timestamp;\n  prev.merged = true;\n\n  return true;\n}\n\n/**\n * Apply a structure transaction (undo or redo) - Phase 5.5\n *\n * Structure operations may create/remove nodes, so delete/duplicate\n * store position + html to make redo/undo deterministic.\n */\nfunction applyStructureTransaction(tx: Transaction, direction: 'undo' | 'redo'): boolean {\n  const data = tx.structureData;\n  if (!data) return false;\n\n  const isRedo = direction === 'redo';\n\n  switch (data.action) {\n    case 'wrap': {\n      if (isRedo) {\n        // Redo wrap: find the target and wrap it\n        const target =\n          locateElement(tx.before.locator) ??\n          locateElement(tx.targetLocator) ??\n          locateElement(tx.after.locator);\n        if (!target || !target.isConnected) return false;\n        if (isDisallowedStructureTarget(target)) return false;\n\n        const parent = target.parentElement;\n        if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return false;\n\n        const wrapper = wrapElementWithContainer(\n          target,\n          data.wrapperTag ?? 'div',\n          data.wrapperStyles,\n        );\n        if (!wrapper || !wrapper.isConnected) return false;\n\n        // Update locators for subsequent undo\n        const wrapperLocator = createElementLocator(wrapper);\n        tx.after.locator = wrapperLocator;\n        tx.targetLocator = wrapperLocator;\n        return true;\n      }\n\n      // Undo wrap: unwrap the wrapper\n      const wrapper = locateElement(tx.after.locator) ?? locateElement(tx.targetLocator);\n      if (!wrapper || !wrapper.isConnected) return false;\n      if (isDisallowedStructureTarget(wrapper)) return false;\n\n      const child = unwrapSingleChildContainer(wrapper);\n      if (!child || !child.isConnected) return false;\n\n      // Update before locator for subsequent redo\n      tx.before.locator = createElementLocator(child);\n      return true;\n    }\n\n    case 'unwrap': {\n      if (isRedo) {\n        // Redo unwrap: find the wrapper and unwrap it\n        const wrapper =\n          locateElement(tx.before.locator) ??\n          locateElement(tx.after.locator)?.parentElement ??\n          locateElement(tx.targetLocator)?.parentElement;\n        if (!wrapper || !wrapper.isConnected) return false;\n        if (isDisallowedStructureTarget(wrapper)) return false;\n\n        const child = unwrapSingleChildContainer(wrapper);\n        if (!child || !child.isConnected) return false;\n\n        // Update locators for subsequent undo\n        const childLocator = createElementLocator(child);\n        tx.after.locator = childLocator;\n        tx.targetLocator = childLocator;\n        return true;\n      }\n\n      // Undo unwrap: rewrap the child\n      const child = locateElement(tx.after.locator) ?? locateElement(tx.targetLocator);\n      if (!child || !child.isConnected) return false;\n      if (isDisallowedStructureTarget(child)) return false;\n\n      const parent = child.parentElement;\n      if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return false;\n\n      const wrapper = wrapElementWithContainer(child, data.wrapperTag ?? 'div', data.wrapperStyles);\n      if (!wrapper || !wrapper.isConnected) return false;\n\n      // Update before locator for subsequent redo\n      tx.before.locator = createElementLocator(wrapper);\n      return true;\n    }\n\n    case 'delete': {\n      if (isRedo) {\n        // Redo delete: remove the element\n        const target = locateElement(tx.before.locator) ?? locateElement(tx.targetLocator);\n        if (!target || !target.isConnected) return false;\n        if (isDisallowedStructureTarget(target)) return false;\n\n        target.remove();\n        return true;\n      }\n\n      // Undo delete: restore the element from html + position\n      if (!data.position || !data.html) return false;\n\n      const parent = locateElement(data.position.parentLocator);\n      if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return false;\n\n      const element = parseSingleRootElement(data.html);\n      if (!element) return false;\n\n      if (!insertElementAtPosition(parent, element, data.position)) return false;\n\n      // Update locators for subsequent redo\n      const locator = createElementLocator(element);\n      tx.before.locator = locator;\n      tx.targetLocator = locator;\n      return true;\n    }\n\n    case 'duplicate': {\n      if (isRedo) {\n        // Redo duplicate: recreate the clone from html + position\n        if (!data.position || !data.html) return false;\n\n        const parent = locateElement(data.position.parentLocator);\n        if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return false;\n\n        const element = parseSingleRootElement(data.html);\n        if (!element) return false;\n\n        if (!insertElementAtPosition(parent, element, data.position)) return false;\n\n        // Update locators for subsequent undo\n        const locator = createElementLocator(element);\n        tx.after.locator = locator;\n        tx.targetLocator = locator;\n        return true;\n      }\n\n      // Undo duplicate: remove the clone\n      const clone = locateElement(tx.after.locator) ?? locateElement(tx.targetLocator);\n      if (!clone || !clone.isConnected) return false;\n      if (isDisallowedStructureTarget(clone)) return false;\n\n      clone.remove();\n      return true;\n    }\n\n    default:\n      return false;\n  }\n}\n\n/**\n * Apply a transaction (undo or redo)\n * Returns true on success, false on failure\n */\nfunction applyTransaction(tx: Transaction, direction: 'undo' | 'redo'): boolean {\n  // Phase 2.4-2.6: Apply move transactions\n  if (tx.type === 'move') {\n    const moveData = tx.moveData;\n    if (!moveData) return false;\n\n    // For undo: element is currently at after position, use after.locator to find it\n    // For redo: element is currently at before position, use before.locator to find it\n    const primaryLocator = direction === 'undo' ? tx.after.locator : tx.before.locator;\n    const fallbackLocator = direction === 'undo' ? tx.before.locator : tx.after.locator;\n\n    const target =\n      locateElement(primaryLocator) ??\n      locateElement(fallbackLocator) ??\n      locateElement(tx.targetLocator);\n\n    if (!target) return false;\n\n    const op = direction === 'undo' ? moveData.from : moveData.to;\n    return applyMoveOperation(target, op);\n  }\n\n  // Phase 4.7: Apply class transactions\n  if (tx.type === 'class') {\n    // For undo: element is currently at after state, use after.locator to find it\n    // For redo: element is currently at before state, use before.locator to find it\n    const primaryLocator = direction === 'undo' ? tx.after.locator : tx.before.locator;\n    const fallbackLocator = direction === 'undo' ? tx.before.locator : tx.after.locator;\n\n    const target =\n      locateElement(primaryLocator) ??\n      locateElement(fallbackLocator) ??\n      locateElement(tx.targetLocator);\n\n    if (!target) return false;\n\n    const snapshot = direction === 'undo' ? tx.before : tx.after;\n    const classes = Array.isArray(snapshot.classes) ? snapshot.classes : [];\n    applyClassListToElement(target, classes);\n    return true;\n  }\n\n  // Phase 5.5: Apply structure transactions\n  if (tx.type === 'structure') {\n    return applyStructureTransaction(tx, direction);\n  }\n\n  // Only handle style and text transactions (other types are no-op here)\n  if (tx.type !== 'style' && tx.type !== 'text') return true;\n\n  const target = locateElement(tx.targetLocator);\n  if (!target) {\n    return false;\n  }\n\n  const snapshot = direction === 'undo' ? tx.before : tx.after;\n\n  if (tx.type === 'style') {\n    applyStylesSnapshot(target, snapshot.styles);\n    return true;\n  }\n\n  // Phase 2.7: Apply text content change\n  if (tx.type === 'text') {\n    target.textContent = snapshot.text ?? '';\n    return true;\n  }\n\n  return true;\n}\n\n// =============================================================================\n// Transaction Manager Implementation\n// =============================================================================\n\n/**\n * Create a Transaction Manager instance\n */\nexport function createTransactionManager(\n  options: TransactionManagerOptions = {},\n): TransactionManager {\n  const disposer = new Disposer();\n\n  // Configuration\n  const maxHistory = Math.max(1, options.maxHistory ?? DEFAULT_MAX_HISTORY);\n  const mergeWindowMs = Math.max(0, options.mergeWindowMs ?? DEFAULT_MERGE_WINDOW_MS);\n  const now = options.now ?? (() => Date.now());\n\n  // State\n  const undoStack: Transaction[] = [];\n  const redoStack: Transaction[] = [];\n\n  // ==========================================================================\n  // Event Emission\n  // ==========================================================================\n\n  function emit(action: TransactionChangeAction, transaction: Transaction | null): void {\n    options.onChange?.({\n      action,\n      transaction,\n      undoCount: undoStack.length,\n      redoCount: redoStack.length,\n    });\n  }\n\n  // ==========================================================================\n  // Stack Management\n  // ==========================================================================\n\n  function enforceMaxHistory(): void {\n    if (undoStack.length > maxHistory) {\n      undoStack.splice(0, undoStack.length - maxHistory);\n    }\n  }\n\n  function pushTransaction(tx: Transaction, allowMerge: boolean): void {\n    const hadRedo = redoStack.length > 0;\n\n    // Clear redo stack on new action\n    if (hadRedo) {\n      redoStack.length = 0;\n    }\n\n    // Try to merge with previous transaction\n    if (!hadRedo && allowMerge && undoStack.length > 0) {\n      const last = undoStack[undoStack.length - 1]!;\n      if (canMerge(last, tx, mergeWindowMs) && mergeInto(last, tx)) {\n        emit('merge', last);\n        return;\n      }\n    }\n\n    undoStack.push(tx);\n    enforceMaxHistory();\n    emit('push', tx);\n  }\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function recordStyle(\n    locator: ElementLocator,\n    property: string,\n    beforeValue: string,\n    afterValue: string,\n    recordOptions?: { merge?: boolean },\n  ): Transaction | null {\n    if (disposer.isDisposed) return null;\n\n    const prop = normalizePropertyName(property);\n    if (!prop) return null;\n\n    const before = beforeValue.trim();\n    const after = afterValue.trim();\n    if (before === after) return null;\n\n    const id = generateTransactionId(now());\n    const tx = createStyleTransaction(id, locator, prop, before, after, now());\n    pushTransaction(tx, recordOptions?.merge !== false);\n\n    return tx;\n  }\n\n  /**\n   * Record a text transaction (Phase 2.7)\n   */\n  function recordText(target: Element, beforeText: string, afterText: string): Transaction | null {\n    if (disposer.isDisposed) return null;\n\n    const before = String(beforeText ?? '');\n    const after = String(afterText ?? '');\n    if (before === after) return null;\n\n    const locator = createElementLocator(target);\n    const timestamp = now();\n    const id = generateTransactionId(timestamp);\n    const elementKey = generateStableElementKey(target, locator.shadowHostChain);\n    const tx = createTextTransaction(id, locator, before, after, timestamp, elementKey);\n\n    // No merge for text transactions in Phase 2\n    pushTransaction(tx, false);\n    return tx;\n  }\n\n  /**\n   * Record a class list change and create transaction (Phase 4.7)\n   *\n   * Notes:\n   * - Uses setAttribute/removeAttribute for SVG compatibility\n   * - Captures before/after locators to improve redo/undo recovery\n   *   when CSS selectors include class-based matching\n   * - No merge support (class edits should be discrete undo steps)\n   */\n  function recordClass(\n    target: Element,\n    beforeClasses: string[],\n    afterClasses: string[],\n  ): Transaction | null {\n    if (disposer.isDisposed) return null;\n    if (!target.isConnected) return null;\n\n    // Read current DOM state as ground truth\n    const domClasses = normalizeClassList(readClassList(target));\n    const beforeInput = normalizeClassList(beforeClasses);\n    const after = normalizeClassList(afterClasses);\n\n    // Prefer DOM as source of truth if caller-provided classes are stale\n    const before = isSameStringList(beforeInput, domClasses) ? beforeInput : domClasses;\n    if (isSameStringList(before, after)) return null;\n\n    const timestamp = now();\n    const id = generateTransactionId(timestamp);\n\n    // Capture locator before applying change (class may affect selector matching)\n    const beforeLocator = createElementLocator(target);\n\n    // Generate stable element key BEFORE class mutation to ensure consistency\n    const elementKey = generateStableElementKey(target, beforeLocator.shadowHostChain);\n\n    // Apply the change\n    applyClassListToElement(target, after);\n\n    // Capture locator after applying change\n    const afterLocator = createElementLocator(target);\n\n    const tx = createClassTransaction(\n      id,\n      beforeLocator,\n      afterLocator,\n      before,\n      after,\n      timestamp,\n      elementKey,\n    );\n\n    // No merge for class transactions (each add/remove is a discrete undo step)\n    pushTransaction(tx, false);\n    return tx;\n  }\n\n  /**\n   * Apply a structure operation and record a transaction (Phase 5.5)\n   *\n   * Performs the DOM mutation immediately and records the transaction.\n   * delete/duplicate store position + html for deterministic undo/redo.\n   * unwrap is limited to single-child containers to keep the schema minimal.\n   */\n  function applyStructure(target: Element, input: StructureOperationData): Transaction | null {\n    if (disposer.isDisposed) return null;\n    if (!target.isConnected) return null;\n    if (isDisallowedStructureTarget(target)) return null;\n\n    const action = input?.action;\n    const timestamp = now();\n    const id = generateTransactionId(timestamp);\n\n    // =========================================================================\n    // Wrap: create a container around the target element\n    // =========================================================================\n    if (action === 'wrap') {\n      const parent = target.parentElement;\n      if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return null;\n\n      const beforeLocator = createElementLocator(target);\n      const wrapper = wrapElementWithContainer(\n        target,\n        input.wrapperTag ?? 'div',\n        input.wrapperStyles,\n      );\n      if (!wrapper || !wrapper.isConnected) return null;\n\n      const wrapperLocator = createElementLocator(wrapper);\n      const elementKey = generateStableElementKey(wrapper, wrapperLocator.shadowHostChain);\n      const structureData: StructureOperationData = {\n        action: 'wrap',\n        wrapperTag: input.wrapperTag ?? 'div',\n        wrapperStyles: input.wrapperStyles,\n      };\n\n      const tx = createStructureTransaction(\n        id,\n        wrapperLocator,\n        beforeLocator,\n        wrapperLocator,\n        structureData,\n        timestamp,\n        elementKey,\n      );\n\n      pushTransaction(tx, false);\n      return tx;\n    }\n\n    // =========================================================================\n    // Unwrap: remove the container and keep its single child\n    // =========================================================================\n    if (action === 'unwrap') {\n      const wrapper = target;\n      const parent = wrapper.parentElement;\n      if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return null;\n\n      // Only support unwrapping containers with exactly one element child\n      if (wrapper.childElementCount !== 1) return null;\n\n      const beforeLocator = createElementLocator(wrapper);\n      const wrapperTag = wrapper.tagName.toLowerCase();\n      const wrapperStyles = readInlineStyleMap(wrapper);\n\n      const child = unwrapSingleChildContainer(wrapper);\n      if (!child || !child.isConnected) return null;\n\n      const childLocator = createElementLocator(child);\n      const elementKey = generateStableElementKey(child, childLocator.shadowHostChain);\n      const structureData: StructureOperationData = {\n        action: 'unwrap',\n        wrapperTag,\n        wrapperStyles,\n      };\n\n      const tx = createStructureTransaction(\n        id,\n        childLocator,\n        beforeLocator,\n        childLocator,\n        structureData,\n        timestamp,\n        elementKey,\n      );\n\n      pushTransaction(tx, false);\n      return tx;\n    }\n\n    // =========================================================================\n    // Delete: remove the element and store info for restoration\n    // =========================================================================\n    if (action === 'delete') {\n      const position = buildMoveOperationData(target);\n      if (!position) return null;\n\n      // Store outerHTML for undo restoration\n      const html = String((target as unknown as { outerHTML?: unknown }).outerHTML ?? '').trim();\n      if (!html) return null;\n\n      const beforeLocator = createElementLocator(target);\n      // Generate stable key BEFORE removing element from DOM\n      const elementKey = generateStableElementKey(target, beforeLocator.shadowHostChain);\n      const afterLocator = position.parentLocator;\n\n      try {\n        target.remove();\n      } catch {\n        return null;\n      }\n\n      const structureData: StructureOperationData = {\n        action: 'delete',\n        position,\n        html,\n      };\n\n      const tx = createStructureTransaction(\n        id,\n        beforeLocator,\n        beforeLocator,\n        afterLocator,\n        structureData,\n        timestamp,\n        elementKey,\n      );\n\n      pushTransaction(tx, false);\n      return tx;\n    }\n\n    // =========================================================================\n    // Duplicate: clone the element and insert after it\n    // =========================================================================\n    if (action === 'duplicate') {\n      const parent = target.parentElement;\n      if (!parent || !parent.isConnected || isDisallowedStructureContainer(parent)) return null;\n\n      const position = buildInsertAfterPosition(target);\n      if (!position) return null;\n\n      const beforeLocator = createElementLocator(target);\n\n      // Clone the element and strip IDs to avoid duplicates\n      const clone = target.cloneNode(true) as Element;\n      stripIdsFromSubtree(clone);\n\n      try {\n        // Insert immediately after target\n        parent.insertBefore(clone, target.nextSibling);\n      } catch {\n        return null;\n      }\n\n      // Store clone's outerHTML for redo restoration\n      const html = String((clone as unknown as { outerHTML?: unknown }).outerHTML ?? '').trim();\n      if (!html) return null;\n\n      const cloneLocator = createElementLocator(clone);\n      // Generate key for the NEW clone element (not the original target)\n      const elementKey = generateStableElementKey(clone, cloneLocator.shadowHostChain);\n      const structureData: StructureOperationData = {\n        action: 'duplicate',\n        position,\n        html,\n      };\n\n      const tx = createStructureTransaction(\n        id,\n        cloneLocator,\n        beforeLocator,\n        cloneLocator,\n        structureData,\n        timestamp,\n        elementKey,\n      );\n\n      pushTransaction(tx, false);\n      return tx;\n    }\n\n    return null;\n  }\n\n  /**\n   * Begin a move transaction for drag-reorder (Phase 2.4-2.6)\n   *\n   * Records the element's location at drag start. Call commit() after DOM move\n   * to record the final location and create the transaction.\n   */\n  function beginMove(target: Element): MoveTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    if (!target.isConnected) return null;\n    if (isDisallowedMoveElement(target)) return null;\n\n    const from = buildMoveOperationData(target);\n    if (!from) return null;\n\n    const startedAt = now();\n    const id = generateTransactionId(startedAt);\n    const beforeLocator = createElementLocator(target);\n    let completed = false;\n\n    function commit(targetAfterMove: Element): Transaction | null {\n      if (completed || disposer.isDisposed) return null;\n      completed = true;\n\n      if (!targetAfterMove.isConnected) return null;\n      if (isDisallowedMoveElement(targetAfterMove)) return null;\n\n      const to = buildMoveOperationData(targetAfterMove);\n      if (!to) return null;\n\n      // Skip no-op moves (same parent and same effective position)\n      const sameParent = locatorKey(from!.parentLocator) === locatorKey(to.parentLocator);\n      const sameIndex = from!.insertIndex === to.insertIndex;\n      const sameAnchorPos = from!.anchorPosition === to.anchorPosition;\n      const sameAnchor =\n        (!from!.anchorLocator && !to.anchorLocator) ||\n        (from!.anchorLocator &&\n          to.anchorLocator &&\n          locatorKey(from!.anchorLocator) === locatorKey(to.anchorLocator));\n\n      if (sameParent && sameIndex && sameAnchor && sameAnchorPos) {\n        return null;\n      }\n\n      const afterLocator = createElementLocator(targetAfterMove);\n      const elementKey = generateStableElementKey(targetAfterMove, afterLocator.shadowHostChain);\n      const moveData: MoveTransactionData = { from: from!, to };\n      const tx = createMoveTransaction(\n        id,\n        beforeLocator,\n        afterLocator,\n        moveData,\n        now(),\n        elementKey,\n      );\n\n      // No merge for move transactions\n      pushTransaction(tx, false);\n      return tx;\n    }\n\n    function cancel(): void {\n      if (completed || disposer.isDisposed) return;\n      completed = true;\n    }\n\n    return {\n      id,\n      beforeLocator,\n      from,\n      commit,\n      cancel,\n    };\n  }\n\n  function beginStyle(target: Element, property: string): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n\n    const inlineStyleOrNull = getInlineStyle(target);\n    if (!inlineStyleOrNull) return null;\n\n    // Capture as non-null after guard (TypeScript can't narrow across closures)\n    const inlineStyle: CSSStyleDeclaration = inlineStyleOrNull;\n\n    const prop = normalizePropertyName(property);\n    if (!prop) return null;\n\n    const locator = createElementLocator(target);\n    const beforeValue = readStyleValue(inlineStyle, prop);\n    const id = generateTransactionId(now());\n\n    // Generate stable element key at the start (before any mutations)\n    const elementKey = generateStableElementKey(target, locator.shadowHostChain);\n\n    let completed = false;\n\n    function set(value: string): void {\n      if (completed || disposer.isDisposed) return;\n      writeStyleValue(inlineStyle, prop, value);\n    }\n\n    function commit(commitOptions?: { merge?: boolean }): Transaction | null {\n      if (completed || disposer.isDisposed) return null;\n      completed = true;\n\n      const afterValue = readStyleValue(inlineStyle, prop);\n      if (afterValue === beforeValue) return null;\n\n      const tx = createStyleTransaction(\n        id,\n        locator,\n        prop,\n        beforeValue,\n        afterValue,\n        now(),\n        elementKey,\n      );\n      pushTransaction(tx, commitOptions?.merge !== false);\n      return tx;\n    }\n\n    function rollback(): void {\n      if (completed || disposer.isDisposed) return;\n      completed = true;\n\n      writeStyleValue(inlineStyle, prop, beforeValue);\n      emit('rollback', null);\n    }\n\n    return {\n      id,\n      property: prop,\n      targetLocator: locator,\n      set,\n      commit,\n      rollback,\n    };\n  }\n\n  /**\n   * Begin an interactive multi-style edit (Phase 4.9)\n   *\n   * For operations that modify multiple CSS properties atomically,\n   * such as resize handles (width + height) or position handles (top + left).\n   *\n   * Key differences from beginStyle:\n   * - Tracks multiple properties at once\n   * - Only records properties that actually changed\n   * - Default merge is disabled to preserve gesture undo granularity\n   */\n  function beginMultiStyle(\n    target: Element,\n    properties: string[],\n  ): MultiStyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n\n    const inlineStyleOrNull = getInlineStyle(target);\n    if (!inlineStyleOrNull) return null;\n    const inlineStyle: CSSStyleDeclaration = inlineStyleOrNull;\n\n    // Normalize and deduplicate properties\n    const normalizedProps = Array.from(\n      new Set(\n        properties.map((p) => normalizePropertyName(String(p))).filter((p): p is string => !!p),\n      ),\n    );\n    if (normalizedProps.length === 0) return null;\n\n    const trackedProps = new Set(normalizedProps);\n    const locator = createElementLocator(target);\n    const startedAt = now();\n    const id = generateTransactionId(startedAt);\n\n    // Generate stable element key at the start (before any mutations)\n    const elementKey = generateStableElementKey(target, locator.shadowHostChain);\n\n    // Capture original values for all tracked properties\n    const beforeValues: Record<string, string> = {};\n    for (const prop of normalizedProps) {\n      beforeValues[prop] = readStyleValue(inlineStyle, prop);\n    }\n\n    let completed = false;\n\n    /**\n     * Update one or more style values (live preview).\n     * Only properties declared in the initial list are applied.\n     */\n    function set(values: Record<string, string>): void {\n      if (completed || disposer.isDisposed) return;\n\n      for (const [rawKey, rawVal] of Object.entries(values)) {\n        const prop = normalizePropertyName(rawKey);\n        if (!prop || !trackedProps.has(prop)) continue;\n        writeStyleValue(inlineStyle, prop, String(rawVal ?? ''));\n      }\n    }\n\n    /**\n     * Commit the transaction and record to history.\n     * Only properties that actually changed are included in the transaction.\n     */\n    function commit(commitOptions?: { merge?: boolean }): Transaction | null {\n      if (completed || disposer.isDisposed) return null;\n      completed = true;\n\n      const beforeStyles: Record<string, string> = {};\n      const afterStyles: Record<string, string> = {};\n\n      // Only include properties that actually changed\n      for (const prop of normalizedProps) {\n        const beforeVal = beforeValues[prop] ?? '';\n        const afterVal = readStyleValue(inlineStyle, prop);\n        if (afterVal === beforeVal) continue;\n        beforeStyles[prop] = beforeVal;\n        afterStyles[prop] = afterVal;\n      }\n\n      // No changes - don't create a transaction\n      if (Object.keys(beforeStyles).length === 0) return null;\n\n      const tx = createStyleTransactionFromStyles(\n        id,\n        locator,\n        beforeStyles,\n        afterStyles,\n        now(),\n        elementKey,\n      );\n\n      // Default to no-merge to preserve gesture undo granularity.\n      // Multi-style edits (e.g., drag resize) should be single undo steps.\n      pushTransaction(tx, commitOptions?.merge === true);\n      return tx;\n    }\n\n    /**\n     * Rollback all tracked properties to original values without recording.\n     */\n    function rollback(): void {\n      if (completed || disposer.isDisposed) return;\n      completed = true;\n\n      for (const prop of normalizedProps) {\n        writeStyleValue(inlineStyle, prop, beforeValues[prop] ?? '');\n      }\n      emit('rollback', null);\n    }\n\n    return {\n      id,\n      properties: normalizedProps,\n      targetLocator: locator,\n      set,\n      commit,\n      rollback,\n    };\n  }\n\n  function applyStyle(\n    target: Element,\n    property: string,\n    value: string,\n    applyOptions?: { merge?: boolean },\n  ): Transaction | null {\n    const handle = beginStyle(target, property);\n    if (!handle) return null;\n\n    handle.set(value);\n    return handle.commit(applyOptions);\n  }\n\n  function undo(): Transaction | null {\n    if (disposer.isDisposed) return null;\n\n    const tx = undoStack.pop();\n    if (!tx) return null;\n\n    // Try to apply the undo\n    const success = applyTransaction(tx, 'undo');\n    if (!success) {\n      // Restore stack state on failure\n      undoStack.push(tx);\n      options.onApplyError?.(new Error(`Failed to locate element for undo: ${tx.id}`));\n      return null;\n    }\n\n    redoStack.push(tx);\n    emit('undo', tx);\n    return tx;\n  }\n\n  function redo(): Transaction | null {\n    if (disposer.isDisposed) return null;\n\n    const tx = redoStack.pop();\n    if (!tx) return null;\n\n    // Try to apply the redo\n    const success = applyTransaction(tx, 'redo');\n    if (!success) {\n      // Restore stack state on failure\n      redoStack.push(tx);\n      options.onApplyError?.(new Error(`Failed to locate element for redo: ${tx.id}`));\n      return null;\n    }\n\n    undoStack.push(tx);\n    enforceMaxHistory();\n    emit('redo', tx);\n    return tx;\n  }\n\n  function canUndo(): boolean {\n    return undoStack.length > 0;\n  }\n\n  function canRedo(): boolean {\n    return redoStack.length > 0;\n  }\n\n  function getUndoStack(): readonly Transaction[] {\n    return undoStack.slice();\n  }\n\n  function getRedoStack(): readonly Transaction[] {\n    return redoStack.slice();\n  }\n\n  function clear(): void {\n    undoStack.length = 0;\n    redoStack.length = 0;\n    emit('clear', null);\n  }\n\n  // ==========================================================================\n  // Keyboard Bindings\n  // ==========================================================================\n\n  if (options.enableKeyBindings) {\n    disposer.listen(\n      window,\n      'keydown',\n      (event: KeyboardEvent) => {\n        // Skip if event is from editor UI\n        if (options.isEventFromEditorUi?.(event)) return;\n\n        // Check for Ctrl/Cmd modifier\n        const isMod = event.metaKey || event.ctrlKey;\n        if (!isMod || event.altKey) return;\n\n        const key = event.key.toLowerCase();\n\n        // Ctrl/Cmd+Z: Undo, Ctrl/Cmd+Shift+Z: Redo, Ctrl/Cmd+Y: Redo\n        if (key === 'z') {\n          if (event.shiftKey) {\n            redo();\n          } else {\n            undo();\n          }\n          event.preventDefault();\n          event.stopPropagation();\n          event.stopImmediatePropagation();\n        } else if (key === 'y') {\n          redo();\n          event.preventDefault();\n          event.stopPropagation();\n          event.stopImmediatePropagation();\n        }\n      },\n      KEYBIND_OPTIONS,\n    );\n  }\n\n  // ==========================================================================\n  // Cleanup\n  // ==========================================================================\n\n  function dispose(): void {\n    undoStack.length = 0;\n    redoStack.length = 0;\n    disposer.dispose();\n  }\n\n  return {\n    beginStyle,\n    beginMultiStyle,\n    beginMove,\n    applyStyle,\n    recordStyle,\n    recordText,\n    recordClass,\n    applyStructure,\n    undo,\n    redo,\n    canUndo,\n    canRedo,\n    getUndoStack,\n    getRedoStack,\n    clear,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/drag/drag-reorder-controller.ts",
    "content": "/**\n * Drag Reorder Controller (Phase 2.4-2.6)\n *\n * Coordinates drag-to-reorder interactions:\n * - Renders drag ghost + insertion indicator via CanvasOverlay\n * - Computes insertion position from pointer hit-testing\n * - Applies DOM move on drop (sibling insert only in Phase 1)\n * - Records the drag as a single move transaction (undo/redo)\n *\n * Phase 1 constraints:\n * - Sibling insertion only (before/after hit target), no \"insert as child\"\n * - No cross-root moves (Document/ShadowRoot boundary)\n * - Disallow moving HTML/BODY/HEAD\n * - Disallow inserting into own subtree\n * - Layout heuristic: supports 1D layouts (vertical block, flex-column, flex-row)\n * - Grid layouts are not supported (2D positioning is too complex)\n * - RTL direction is not handled (future enhancement)\n */\n\nimport {\n  WEB_EDITOR_V2_DRAG_HYSTERESIS_PX,\n  WEB_EDITOR_V2_DRAG_MAX_HIT_ELEMENTS,\n  WEB_EDITOR_V2_LOG_PREFIX,\n} from '../constants';\nimport type {\n  DragCancelEvent,\n  DragEndEvent,\n  DragMoveEvent,\n  DragStartEvent,\n} from '../core/event-controller';\nimport type { PositionTracker } from '../core/position-tracker';\nimport type { MoveTransactionHandle, TransactionManager } from '../core/transaction-manager';\nimport type { CanvasOverlay, ViewportLine, ViewportRect } from '../overlay/canvas-overlay';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface DragReorderControllerOptions {\n  /** Check if a node belongs to the editor overlay */\n  isOverlayElement: (node: unknown) => boolean;\n  /** UI root element (for disabling pointer events during drag) */\n  uiRoot: HTMLElement;\n  /** Canvas overlay for rendering ghost and insertion line */\n  canvasOverlay: CanvasOverlay;\n  /** Position tracker for syncing after drop */\n  positionTracker: PositionTracker;\n  /** Transaction manager for recording move transactions */\n  transactionManager: TransactionManager;\n}\n\nexport interface DragReorderController {\n  /** Called when drag starts */\n  onDragStart(ev: DragStartEvent): boolean;\n  /** Called on pointer move during drag */\n  onDragMove(ev: DragMoveEvent): void;\n  /** Called when drag ends (drop) */\n  onDragEnd(ev: DragEndEvent): void;\n  /** Called when drag is cancelled */\n  onDragCancel(ev: DragCancelEvent): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\ntype InsertSide = 'before' | 'after';\n\ninterface InsertPosition {\n  /** The hit element */\n  target: Element;\n  /** The parent element of the hit target */\n  parent: Element;\n  /** Insert before or after the target */\n  side: InsertSide;\n  /** The reference node for insertBefore (null means append) */\n  referenceNode: ChildNode | null;\n  /** Whether this is a no-op (same position as current) */\n  isNoop: boolean;\n  /** The indicator line to draw */\n  indicatorLine: ViewportLine;\n}\n\ninterface DragState {\n  /** Pointer ID being tracked */\n  pointerId: number;\n  /** The element being dragged */\n  draggedElement: Element;\n  /** The root node of the dragged element (Document or ShadowRoot) */\n  draggedRoot: Document | ShadowRoot;\n  /** Initial bounding rect of the dragged element */\n  startRect: ViewportRect;\n  /** Offset from pointer to element top-left */\n  pointerOffsetX: number;\n  pointerOffsetY: number;\n  /** Last known pointer position */\n  lastClientX: number;\n  lastClientY: number;\n  /** Current insertion preview */\n  preview: InsertPosition | null;\n  /** Saved pointer-events style for uiRoot */\n  uiPointerEventsBefore: string;\n  /** Move transaction handle */\n  moveHandle: MoveTransactionHandle;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isDocumentOrShadowRoot(value: unknown): value is Document | ShadowRoot {\n  return value instanceof Document || value instanceof ShadowRoot;\n}\n\nfunction isDisallowedDragElement(element: Element): boolean {\n  const tag = element.tagName?.toUpperCase();\n  return tag === 'HTML' || tag === 'BODY' || tag === 'HEAD';\n}\n\nfunction toViewportRect(rect: DOMRectReadOnly): ViewportRect | null {\n  const { left, top, width, height } = rect;\n  if (\n    !Number.isFinite(left) ||\n    !Number.isFinite(top) ||\n    !Number.isFinite(width) ||\n    !Number.isFinite(height)\n  ) {\n    return null;\n  }\n  return {\n    left,\n    top,\n    width: Math.max(0, width),\n    height: Math.max(0, height),\n  };\n}\n\n/**\n * Get elements at a viewport point from a specific root (Document or ShadowRoot).\n * This is Shadow DOM aware - uses the root's elementsFromPoint to correctly\n * hit elements inside that shadow tree.\n */\nfunction getHitElementsFromRoot(\n  root: Document | ShadowRoot,\n  clientX: number,\n  clientY: number,\n): Element[] {\n  if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return [];\n\n  try {\n    if (typeof root.elementsFromPoint === 'function') {\n      return root.elementsFromPoint(clientX, clientY);\n    }\n  } catch {\n    // Fall back to elementFromPoint\n  }\n\n  try {\n    const el = root.elementFromPoint(clientX, clientY);\n    return el ? [el] : [];\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Container axis information for drag reordering.\n * - axis: The primary axis for insertion ('x' for horizontal, 'y' for vertical)\n * - reverse: Whether the visual order is reversed from DOM order\n */\ntype ContainerAxis = { axis: 'x' | 'y'; reverse: boolean } | null;\n\n/**\n * Determine the insertion axis for the parent container.\n * - Grid layouts are not supported (returns null)\n * - Flex row/column are supported with appropriate axis\n * - Non-flex layouts default to vertical (block flow)\n */\nfunction getContainerAxis(parent: Element): ContainerAxis {\n  try {\n    const style = window.getComputedStyle(parent);\n    const display = style.display;\n\n    // Reject grid layouts - 2D positioning is too complex for Phase 1\n    if (display === 'grid' || display === 'inline-grid') return null;\n\n    // Handle flex layouts with appropriate axis\n    if (display === 'flex' || display === 'inline-flex') {\n      // Reject wrapped flex layouts - they become 2D\n      const wrap = style.flexWrap;\n      if (wrap === 'wrap' || wrap === 'wrap-reverse') return null;\n\n      const dir = style.flexDirection;\n      switch (dir) {\n        case 'row':\n          return { axis: 'x', reverse: false };\n        case 'row-reverse':\n          return { axis: 'x', reverse: true };\n        case 'column':\n          return { axis: 'y', reverse: false };\n        case 'column-reverse':\n          return { axis: 'y', reverse: true };\n        default:\n          // Unknown flex direction - fall back to vertical\n          return { axis: 'y', reverse: false };\n      }\n    }\n\n    // Non-flex layouts (block, inline-block, etc.) use vertical flow\n    return { axis: 'y', reverse: false };\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Choose insert side with hysteresis to avoid flip-flopping.\n * Supports both X and Y axes, with proper handling for reverse layouts.\n *\n * @param clientPos - The client coordinate (clientX for X axis, clientY for Y axis)\n * @param rect - The bounding rect of the target element\n * @param prevSide - The previous side (for hysteresis)\n * @param axis - The axis to use for comparison ('x' or 'y')\n * @param reverse - Whether the layout is reversed (row-reverse, column-reverse)\n * @returns The DOM insertion side ('before' or 'after')\n */\nfunction chooseSideWithHysteresis(\n  clientPos: number,\n  rect: DOMRectReadOnly,\n  prevSide: InsertSide | null,\n  axis: 'x' | 'y',\n  reverse: boolean,\n): InsertSide {\n  // Calculate midpoint based on axis\n  const mid = axis === 'x' ? rect.left + rect.width / 2 : rect.top + rect.height / 2;\n\n  // For reverse layouts, we need to flip the comparison logic\n  // In reverse mode, \"before in visual\" means \"after in DOM\"\n  const effectivePos = reverse ? -clientPos : clientPos;\n  const effectiveMid = reverse ? -mid : mid;\n\n  if (!prevSide) {\n    return effectivePos < effectiveMid ? 'before' : 'after';\n  }\n\n  // Apply hysteresis band around midline\n  if (prevSide === 'before') {\n    return effectivePos > effectiveMid + WEB_EDITOR_V2_DRAG_HYSTERESIS_PX ? 'after' : 'before';\n  }\n\n  return effectivePos < effectiveMid - WEB_EDITOR_V2_DRAG_HYSTERESIS_PX ? 'before' : 'after';\n}\n\n/**\n * Check if the proposed move is a no-op (same position as current)\n */\nfunction isNoopMove(\n  draggedElement: Element,\n  parent: Element,\n  referenceNode: ChildNode | null,\n): boolean {\n  if (draggedElement.parentNode !== parent) return false;\n\n  // Reference is the dragged element itself\n  if (referenceNode === draggedElement) return true;\n\n  // Reference is the element right after dragged (no change)\n  if (referenceNode === draggedElement.nextSibling) return true;\n\n  // Reference is null (append) and dragged is already last\n  if (referenceNode === null && draggedElement.nextSibling === null) return true;\n\n  return false;\n}\n\n/**\n * Check if an element is a valid drop target\n */\nfunction isValidDropTarget(\n  el: Element,\n  draggedElement: Element,\n  draggedRoot: Document | ShadowRoot,\n  isOverlayElement: (node: unknown) => boolean,\n): boolean {\n  if (!el.isConnected) return false;\n  if (isOverlayElement(el)) return false;\n  if (el === draggedElement) return false;\n  if (isDisallowedDragElement(el)) return false;\n\n  // Prevent inserting into itself / its subtree\n  if (draggedElement.contains(el)) return false;\n\n  if (!el.parentElement) return false;\n\n  const root = el.getRootNode?.();\n  if (!isDocumentOrShadowRoot(root)) return false;\n  if (root !== draggedRoot) return false;\n\n  return true;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\nexport function createDragReorderController(\n  options: DragReorderControllerOptions,\n): DragReorderController {\n  const disposer = new Disposer();\n  const { canvasOverlay, isOverlayElement, positionTracker, transactionManager, uiRoot } = options;\n\n  let state: DragState | null = null;\n  let rafId: number | null = null;\n\n  function cancelRaf(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n  disposer.add(cancelRaf);\n\n  function setUiPointerEventsEnabled(enabled: boolean, s: DragState): void {\n    uiRoot.style.pointerEvents = enabled ? s.uiPointerEventsBefore : 'none';\n  }\n\n  function clearVisuals(): void {\n    canvasOverlay.setDragGhostRect(null);\n    canvasOverlay.setInsertionLine(null);\n    canvasOverlay.render();\n  }\n\n  function cleanup(): void {\n    const s = state;\n    if (!s) return;\n\n    cancelRaf();\n    setUiPointerEventsEnabled(true, s);\n    clearVisuals();\n    state = null;\n  }\n\n  /**\n   * Compute the insertion position from current pointer location.\n   * Uses the dragged element's root for hit-testing to correctly handle Shadow DOM.\n   */\n  function computeInsertPosition(s: DragState): InsertPosition | null {\n    // Use the dragged element's root for Shadow DOM aware hit-testing\n    const hits = getHitElementsFromRoot(s.draggedRoot, s.lastClientX, s.lastClientY).slice(\n      0,\n      WEB_EDITOR_V2_DRAG_MAX_HIT_ELEMENTS,\n    );\n    const target = hits.find((el) =>\n      isValidDropTarget(el, s.draggedElement, s.draggedRoot, isOverlayElement),\n    );\n    if (!target) return null;\n\n    const parent = target.parentElement;\n    if (!parent) return null;\n\n    // Get container axis info (null means unsupported layout like grid)\n    const container = getContainerAxis(parent);\n    if (!container) return null;\n\n    let rect: DOMRectReadOnly;\n    try {\n      rect = target.getBoundingClientRect();\n    } catch {\n      return null;\n    }\n\n    if (\n      !Number.isFinite(rect.left) ||\n      !Number.isFinite(rect.top) ||\n      rect.width <= 0.5 ||\n      rect.height <= 0.5\n    ) {\n      return null;\n    }\n\n    // Choose side based on the container's axis\n    const prevSide = s.preview && s.preview.target === target ? s.preview.side : null;\n    const clientPos = container.axis === 'x' ? s.lastClientX : s.lastClientY;\n    const side = chooseSideWithHysteresis(\n      clientPos,\n      rect,\n      prevSide,\n      container.axis,\n      container.reverse,\n    );\n    const referenceNode = side === 'before' ? target : target.nextSibling;\n\n    const noop = isNoopMove(s.draggedElement, parent, referenceNode);\n\n    // Draw indicator line based on axis and reverse mode\n    let indicatorLine: ViewportLine;\n    if (container.axis === 'x') {\n      // Horizontal layout: draw vertical insertion line\n      // For reverse layouts, swap the visual positions of before/after\n      const beforeX = container.reverse ? rect.left + rect.width : rect.left;\n      const afterX = container.reverse ? rect.left : rect.left + rect.width;\n      const x = side === 'before' ? beforeX : afterX;\n      indicatorLine = {\n        x1: x,\n        y1: rect.top,\n        x2: x,\n        y2: rect.top + rect.height,\n      };\n    } else {\n      // Vertical layout: draw horizontal insertion line\n      // For reverse layouts, swap the visual positions of before/after\n      const beforeY = container.reverse ? rect.top + rect.height : rect.top;\n      const afterY = container.reverse ? rect.top : rect.top + rect.height;\n      const y = side === 'before' ? beforeY : afterY;\n      indicatorLine = {\n        x1: rect.left,\n        y1: y,\n        x2: rect.left + rect.width,\n        y2: y,\n      };\n    }\n\n    return { target, parent, side, referenceNode, isNoop: noop, indicatorLine };\n  }\n\n  /**\n   * Update frame: compute ghost rect and insertion preview, then render\n   */\n  function updateFrame(): void {\n    rafId = null;\n    const s = state;\n    if (!s) return;\n\n    if (!s.draggedElement.isConnected) {\n      s.moveHandle.cancel();\n      cleanup();\n      return;\n    }\n\n    // Compute ghost rect based on current pointer position\n    const ghostRect: ViewportRect = {\n      left: s.lastClientX - s.pointerOffsetX,\n      top: s.lastClientY - s.pointerOffsetY,\n      width: s.startRect.width,\n      height: s.startRect.height,\n    };\n\n    s.preview = computeInsertPosition(s);\n\n    canvasOverlay.setDragGhostRect(ghostRect);\n    canvasOverlay.setInsertionLine(s.preview?.indicatorLine ?? null);\n    canvasOverlay.render();\n  }\n\n  function scheduleUpdate(): void {\n    if (disposer.isDisposed) return;\n    if (rafId !== null) return;\n    rafId = requestAnimationFrame(updateFrame);\n  }\n\n  /**\n   * Apply the DOM move operation\n   */\n  function applyDomMove(draggedElement: Element, insert: InsertPosition): boolean {\n    const parent = insert.parent;\n    if (!parent.isConnected) return false;\n\n    // Prevent cycles (moving into own descendant)\n    if (draggedElement === parent) return false;\n    if (draggedElement.contains(parent)) return false;\n\n    // Disallow cross-root moves\n    const rootA = draggedElement.getRootNode?.();\n    const rootB = parent.getRootNode?.();\n    if (!isDocumentOrShadowRoot(rootA) || !isDocumentOrShadowRoot(rootB) || rootA !== rootB) {\n      return false;\n    }\n\n    // Re-validate target and parent relationship\n    if (!insert.target.isConnected) return false;\n    if (insert.target.parentElement !== parent) return false;\n\n    const ref: ChildNode | null =\n      insert.side === 'before' ? insert.target : insert.target.nextSibling;\n    if (isNoopMove(draggedElement, parent, ref)) return true;\n\n    try {\n      parent.insertBefore(draggedElement, ref);\n      return true;\n    } catch (error) {\n      console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} DOM move failed:`, error);\n      return false;\n    }\n  }\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function onDragStart(ev: DragStartEvent): boolean {\n    if (disposer.isDisposed) return false;\n\n    // Cancel any stale session first\n    if (state) {\n      state.moveHandle.cancel();\n      cleanup();\n    }\n\n    const draggedElement = ev.draggedElement;\n    if (!draggedElement || !(draggedElement instanceof Element)) return false;\n    if (!draggedElement.isConnected) return false;\n    if (isDisallowedDragElement(draggedElement)) return false;\n\n    const rawRoot = draggedElement.getRootNode?.();\n    const draggedRoot = isDocumentOrShadowRoot(rawRoot) ? rawRoot : document;\n\n    let rect: DOMRectReadOnly;\n    try {\n      rect = draggedElement.getBoundingClientRect();\n    } catch {\n      return false;\n    }\n\n    const startRect = toViewportRect(rect);\n    if (!startRect || startRect.width <= 0.5 || startRect.height <= 0.5) return false;\n\n    const moveHandle = transactionManager.beginMove(draggedElement);\n    if (!moveHandle) return false;\n\n    const prevPointerEvents = uiRoot.style.pointerEvents;\n\n    state = {\n      pointerId: ev.pointerId,\n      draggedElement,\n      draggedRoot,\n      startRect,\n      pointerOffsetX: ev.startClientX - startRect.left,\n      pointerOffsetY: ev.startClientY - startRect.top,\n      lastClientX: ev.clientX,\n      lastClientY: ev.clientY,\n      preview: null,\n      uiPointerEventsBefore: prevPointerEvents,\n      moveHandle,\n    };\n\n    setUiPointerEventsEnabled(false, state);\n    scheduleUpdate();\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Drag started`);\n    return true;\n  }\n\n  function onDragMove(ev: DragMoveEvent): void {\n    const s = state;\n    if (!s) return;\n    if (ev.pointerId !== s.pointerId) return;\n\n    s.lastClientX = ev.clientX;\n    s.lastClientY = ev.clientY;\n    scheduleUpdate();\n  }\n\n  function onDragEnd(ev: DragEndEvent): void {\n    const s = state;\n    if (!s) return;\n    if (ev.pointerId !== s.pointerId) return;\n\n    s.lastClientX = ev.clientX;\n    s.lastClientY = ev.clientY;\n\n    cancelRaf();\n    const insert = computeInsertPosition(s);\n\n    if (!insert || insert.isNoop) {\n      s.moveHandle.cancel();\n      cleanup();\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Drag cancelled (no-op or no target)`);\n      return;\n    }\n\n    const ok = applyDomMove(s.draggedElement, insert);\n    if (!ok) {\n      s.moveHandle.cancel();\n      cleanup();\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Drag failed (DOM move error)`);\n      return;\n    }\n\n    s.moveHandle.commit(s.draggedElement);\n    positionTracker.forceUpdate();\n    cleanup();\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Drag completed`);\n  }\n\n  function onDragCancel(_ev: DragCancelEvent): void {\n    const s = state;\n    if (!s) return;\n    s.moveHandle.cancel();\n    cleanup();\n\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Drag cancelled`);\n  }\n\n  // Cleanup on dispose\n  disposer.add(() => {\n    const s = state;\n    if (!s) return;\n    s.moveHandle.cancel();\n    cleanup();\n  });\n\n  return {\n    onDragStart,\n    onDragMove,\n    onDragEnd,\n    onDragCancel,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/overlay/canvas-overlay.ts",
    "content": "/**\n * Canvas Overlay\n *\n * High-performance overlay renderer for visual feedback (hover, selection, guides).\n *\n * Features:\n * - DPR-aware rendering for crisp visuals on HiDPI displays\n * - rAF-coalesced rendering via markDirty() pattern\n * - ResizeObserver-backed automatic sizing\n * - Separate layers for hover, selection, and future guides\n *\n * Performance considerations:\n * - Uses `desynchronized: true` for lower latency\n * - Batches all drawing to single rAF\n * - Only redraws when dirty flag is set\n * - Pixel-aligned strokes for crisp lines\n */\n\nimport {\n  WEB_EDITOR_V2_COLORS,\n  WEB_EDITOR_V2_DISTANCE_LABEL_FONT,\n  WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET,\n  WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X,\n  WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y,\n  WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS,\n  WEB_EDITOR_V2_DISTANCE_LINE_WIDTH,\n  WEB_EDITOR_V2_DISTANCE_TICK_SIZE,\n  WEB_EDITOR_V2_GUIDE_LINE_WIDTH,\n  WEB_EDITOR_V2_INSERTION_LINE_WIDTH,\n  WEB_EDITOR_V2_LOG_PREFIX,\n} from '../constants';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Rectangle in viewport coordinates */\nexport type ViewportRect = Pick<DOMRectReadOnly, 'left' | 'top' | 'width' | 'height'>;\n\n/** Line segment in viewport coordinates */\nexport interface ViewportLine {\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n}\n\n/** Distance label kind (Phase 4.3) */\nexport type DistanceLabelKind = 'sibling' | 'viewport';\n\n/** Axis of the measured distance (Phase 4.3) */\nexport type DistanceLabelAxis = 'x' | 'y';\n\n/** Distance label rendered on overlay (Phase 4.3) */\nexport interface DistanceLabel {\n  /** Source of this measurement */\n  readonly kind: DistanceLabelKind;\n  /** 'x' => horizontal distance, 'y' => vertical distance */\n  readonly axis: DistanceLabelAxis;\n  /** Rounded px value */\n  readonly value: number;\n  /** Display text (e.g. \"12px\") */\n  readonly text: string;\n  /** Measurement line segment */\n  readonly line: ViewportLine;\n}\n\n/** Box style configuration */\nexport interface BoxStyle {\n  /** Stroke color */\n  strokeColor: string;\n  /** Fill color (with alpha for transparency) */\n  fillColor: string;\n  /** Line width in CSS pixels */\n  lineWidth: number;\n  /** Dash pattern (empty array for solid line) */\n  dashPattern: number[];\n}\n\n/** Canvas overlay interface */\nexport interface CanvasOverlay {\n  /** The underlying canvas element */\n  canvas: HTMLCanvasElement;\n  /** Mark state as dirty and schedule a render on next animation frame */\n  markDirty(): void;\n  /** Render immediately if dirty (called by RAF engine) */\n  render(): void;\n  /** Clear all visual elements */\n  clear(): void;\n  /** Update hover highlight (with optional transition animation) */\n  setHoverRect(rect: ViewportRect | null, options?: { animate?: boolean }): void;\n  /** Update selection highlight */\n  setSelectionRect(rect: ViewportRect | null): void;\n  /** Update drag ghost highlight (Phase 2.4) */\n  setDragGhostRect(rect: ViewportRect | null): void;\n  /** Update insertion indicator line (Phase 2.4) */\n  setInsertionLine(line: ViewportLine | null): void;\n  /** Update alignment guide lines (Phase 4.2) */\n  setGuideLines(lines: readonly ViewportLine[] | null): void;\n  /** Update distance labels (Phase 4.3) */\n  setDistanceLabels(labels: readonly DistanceLabel[] | null): void;\n  /** Dispose and cleanup */\n  dispose(): void;\n}\n\n/** Options for creating canvas overlay */\nexport interface CanvasOverlayOptions {\n  /** Container element (should be overlayRoot from ShadowHost) */\n  container: HTMLElement;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst CANVAS_ATTR = 'data-mcp-canvas';\nconst CANVAS_ATTR_VALUE = 'overlay';\n\n/** Duration of hover rect transition animation in milliseconds */\nconst HOVER_ANIMATION_DURATION_MS = 100;\n\n/** Default styles for different box types */\nconst BOX_STYLES = {\n  hover: {\n    strokeColor: WEB_EDITOR_V2_COLORS.hover,\n    fillColor: `${WEB_EDITOR_V2_COLORS.hover}15`, // 15 = ~8% opacity\n    lineWidth: 2,\n    dashPattern: [6, 4],\n  },\n  selection: {\n    strokeColor: WEB_EDITOR_V2_COLORS.selected,\n    fillColor: `${WEB_EDITOR_V2_COLORS.selected}20`, // 20 = ~12% opacity\n    lineWidth: 2,\n    dashPattern: [],\n  },\n  dragGhost: {\n    strokeColor: WEB_EDITOR_V2_COLORS.selectionBorder,\n    fillColor: WEB_EDITOR_V2_COLORS.dragGhost,\n    lineWidth: 2,\n    dashPattern: [8, 6],\n  },\n} satisfies Record<string, BoxStyle>;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFinitePositive(value: number): boolean {\n  return Number.isFinite(value) && value > 0;\n}\n\nfunction isValidRect(rect: ViewportRect | null): rect is ViewportRect {\n  if (!rect) return false;\n  return (\n    Number.isFinite(rect.left) &&\n    Number.isFinite(rect.top) &&\n    isFinitePositive(rect.width) &&\n    isFinitePositive(rect.height)\n  );\n}\n\nfunction isValidLine(line: ViewportLine | null): line is ViewportLine {\n  if (!line) return false;\n  return (\n    Number.isFinite(line.x1) &&\n    Number.isFinite(line.y1) &&\n    Number.isFinite(line.x2) &&\n    Number.isFinite(line.y2)\n  );\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min;\n  return Math.min(max, Math.max(min, value));\n}\n\n// =============================================================================\n// Animation Helpers\n// =============================================================================\n\n/** Cubic ease-out curve: fast start, slow end (matches CSS ease-out approximately) */\nfunction easeOutCubic(t: number): number {\n  return 1 - Math.pow(1 - t, 3);\n}\n\n/** Linear interpolation between two values */\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\n/** Interpolate between two rectangles */\nfunction lerpRect(from: ViewportRect, to: ViewportRect, t: number): ViewportRect {\n  return {\n    left: lerp(from.left, to.left, t),\n    top: lerp(from.top, to.top, t),\n    width: lerp(from.width, to.width, t),\n    height: lerp(from.height, to.height, t),\n  };\n}\n\n/**\n * Build a rounded rectangle path (without beginning a new path)\n */\nfunction buildRoundedRectPath(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  w: number,\n  h: number,\n  r: number,\n): void {\n  const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));\n  ctx.moveTo(x + radius, y);\n  ctx.arcTo(x + w, y, x + w, y + h, radius);\n  ctx.arcTo(x + w, y + h, x, y + h, radius);\n  ctx.arcTo(x, y + h, x, y, radius);\n  ctx.arcTo(x, y, x + w, y, radius);\n  ctx.closePath();\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a canvas overlay for rendering visual feedback.\n */\nexport function createCanvasOverlay(options: CanvasOverlayOptions): CanvasOverlay {\n  const { container } = options;\n  const disposer = new Disposer();\n\n  // Cleanup any existing canvas from previous instance\n  const existing = container.querySelector<HTMLCanvasElement>(\n    `canvas[${CANVAS_ATTR}=\"${CANVAS_ATTR_VALUE}\"]`,\n  );\n  if (existing) {\n    existing.remove();\n  }\n\n  // Create canvas element\n  const canvas = document.createElement('canvas');\n  canvas.setAttribute(CANVAS_ATTR, CANVAS_ATTR_VALUE);\n  canvas.setAttribute('aria-hidden', 'true');\n\n  // Style for fullscreen coverage\n  Object.assign(canvas.style, {\n    position: 'absolute',\n    inset: '0',\n    width: '100%',\n    height: '100%',\n    pointerEvents: 'none',\n    display: 'block',\n  });\n\n  container.append(canvas);\n  disposer.add(() => canvas.remove());\n\n  // Get 2D context with performance options\n  const ctxOrNull = canvas.getContext('2d', {\n    alpha: true,\n    desynchronized: true, // Lower latency on supported browsers\n  });\n\n  if (!ctxOrNull) {\n    disposer.dispose();\n    throw new Error(`${WEB_EDITOR_V2_LOG_PREFIX} Failed to get canvas 2D context`);\n  }\n\n  // Capture as non-null after guard (TypeScript needs explicit assignment)\n  const ctx: CanvasRenderingContext2D = ctxOrNull;\n\n  // ==========================================================================\n  // State\n  // ==========================================================================\n\n  let hoverRect: ViewportRect | null = null;\n\n  // Hover animation state: tracks in-progress transition between two rect positions\n  interface HoverAnimation {\n    /** Starting rectangle (from position) */\n    start: ViewportRect;\n    /** Ending rectangle (to position) */\n    end: ViewportRect;\n    /** Animation start timestamp (performance.now()) */\n    startTime: number;\n    /** Animation duration in milliseconds */\n    durationMs: number;\n  }\n  let hoverAnimation: HoverAnimation | null = null;\n\n  let selectionRect: ViewportRect | null = null;\n  let dragGhostRect: ViewportRect | null = null;\n  let insertionLine: ViewportLine | null = null;\n  let guideLines: readonly ViewportLine[] | null = null;\n  let distanceLabels: readonly DistanceLabel[] | null = null;\n\n  let viewportWidth = 1;\n  let viewportHeight = 1;\n  let devicePixelRatio = 1;\n\n  let dirty = true;\n  let rafId: number | null = null;\n\n  // ==========================================================================\n  // RAF Management\n  // ==========================================================================\n\n  function cancelRaf(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n  disposer.add(cancelRaf);\n\n  function scheduleRaf(): void {\n    if (rafId !== null || disposer.isDisposed) return;\n    rafId = requestAnimationFrame(() => {\n      rafId = null;\n      render();\n    });\n  }\n\n  // ==========================================================================\n  // Canvas Sizing (DPR-aware)\n  // ==========================================================================\n\n  function updateCanvasSize(): boolean {\n    const nextDpr = Math.max(1, window.devicePixelRatio || 1);\n    const cssWidth = Math.max(1, viewportWidth);\n    const cssHeight = Math.max(1, viewportHeight);\n\n    const pixelWidth = Math.round(cssWidth * nextDpr);\n    const pixelHeight = Math.round(cssHeight * nextDpr);\n\n    const needsResize =\n      canvas.width !== pixelWidth ||\n      canvas.height !== pixelHeight ||\n      Math.abs(devicePixelRatio - nextDpr) > 0.001;\n\n    if (!needsResize) return false;\n\n    devicePixelRatio = nextDpr;\n    canvas.width = pixelWidth;\n    canvas.height = pixelHeight;\n\n    // Reset transform after resize (canvas state is cleared)\n    ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);\n    ctx.lineJoin = 'round';\n    ctx.lineCap = 'round';\n\n    return true;\n  }\n\n  // ==========================================================================\n  // Drawing Functions\n  // ==========================================================================\n\n  function clearCanvas(): void {\n    updateCanvasSize();\n    ctx.clearRect(0, 0, viewportWidth, viewportHeight);\n  }\n\n  function drawBox(rect: ViewportRect | null, style: BoxStyle): void {\n    if (!isValidRect(rect)) return;\n\n    const w = Math.round(rect.width);\n    const h = Math.round(rect.height);\n    if (w <= 0 || h <= 0) return;\n\n    // Pixel-align for crisp strokes (add 0.5 for even line widths)\n    const x = Math.round(rect.left) + 0.5;\n    const y = Math.round(rect.top) + 0.5;\n\n    ctx.save();\n\n    // Configure stroke\n    ctx.lineWidth = style.lineWidth;\n    ctx.strokeStyle = style.strokeColor;\n    ctx.fillStyle = style.fillColor;\n    ctx.setLineDash(style.dashPattern);\n\n    // Draw rectangle\n    ctx.beginPath();\n    ctx.rect(x, y, w, h);\n    ctx.fill();\n    ctx.stroke();\n\n    ctx.restore();\n  }\n\n  /**\n   * Draw an insertion indicator line (horizontal)\n   */\n  function drawInsertionLine(line: ViewportLine | null): void {\n    if (!isValidLine(line)) return;\n\n    ctx.save();\n\n    ctx.lineWidth = WEB_EDITOR_V2_INSERTION_LINE_WIDTH;\n    ctx.strokeStyle = WEB_EDITOR_V2_COLORS.insertionLine;\n    ctx.setLineDash([]);\n    ctx.lineCap = 'round';\n\n    // Pixel-align for crisp strokes\n    const x1 = Math.round(line.x1) + 0.5;\n    const y1 = Math.round(line.y1) + 0.5;\n    const x2 = Math.round(line.x2) + 0.5;\n    const y2 = Math.round(line.y2) + 0.5;\n\n    ctx.beginPath();\n    ctx.moveTo(x1, y1);\n    ctx.lineTo(x2, y2);\n    ctx.stroke();\n\n    ctx.restore();\n  }\n\n  /**\n   * Draw alignment guide lines (Phase 4.2)\n   *\n   * Guide lines indicate snap alignments during resize operations.\n   * Multiple lines may be drawn simultaneously (one per axis).\n   */\n  function drawGuideLines(lines: readonly ViewportLine[] | null): void {\n    if (!lines || lines.length === 0) return;\n\n    ctx.save();\n\n    ctx.lineWidth = WEB_EDITOR_V2_GUIDE_LINE_WIDTH;\n    ctx.strokeStyle = WEB_EDITOR_V2_COLORS.guideLine;\n    ctx.setLineDash([]);\n    ctx.lineCap = 'round';\n\n    // Batch all lines into single path for performance\n    ctx.beginPath();\n    for (const line of lines) {\n      if (!isValidLine(line)) continue;\n\n      // Pixel-align for crisp strokes\n      const x1 = Math.round(line.x1) + 0.5;\n      const y1 = Math.round(line.y1) + 0.5;\n      const x2 = Math.round(line.x2) + 0.5;\n      const y2 = Math.round(line.y2) + 0.5;\n\n      ctx.moveTo(x1, y1);\n      ctx.lineTo(x2, y2);\n    }\n    ctx.stroke();\n\n    ctx.restore();\n  }\n\n  /**\n   * Draw distance labels (Phase 4.3)\n   *\n   * Renders:\n   * - Measurement line (pink, matches guide line color)\n   * - End ticks (perpendicular marks at each end)\n   * - Text pill (dark translucent background with white text)\n   */\n  function drawDistanceLabels(labels: readonly DistanceLabel[] | null): void {\n    if (!labels || labels.length === 0) return;\n\n    ctx.save();\n\n    // Draw measurement lines and ticks first (batched for performance)\n    ctx.lineWidth = WEB_EDITOR_V2_DISTANCE_LINE_WIDTH;\n    ctx.strokeStyle = WEB_EDITOR_V2_COLORS.guideLine;\n    ctx.setLineDash([]);\n    ctx.lineCap = 'round';\n\n    const tick = WEB_EDITOR_V2_DISTANCE_TICK_SIZE;\n\n    ctx.beginPath();\n    for (const label of labels) {\n      const line = label.line;\n      if (!isValidLine(line)) continue;\n\n      // Pixel-align for crisp 1px strokes\n      const x1 = Math.round(line.x1) + 0.5;\n      const y1 = Math.round(line.y1) + 0.5;\n      const x2 = Math.round(line.x2) + 0.5;\n      const y2 = Math.round(line.y2) + 0.5;\n\n      // Draw measurement line\n      ctx.moveTo(x1, y1);\n      ctx.lineTo(x2, y2);\n\n      // Draw end ticks (perpendicular to measurement direction)\n      if (label.axis === 'x') {\n        // Horizontal distance: vertical ticks at each end\n        ctx.moveTo(x1, y1 - tick);\n        ctx.lineTo(x1, y1 + tick);\n        ctx.moveTo(x2, y2 - tick);\n        ctx.lineTo(x2, y2 + tick);\n      } else {\n        // Vertical distance: horizontal ticks at each end\n        ctx.moveTo(x1 - tick, y1);\n        ctx.lineTo(x1 + tick, y1);\n        ctx.moveTo(x2 - tick, y2);\n        ctx.lineTo(x2 + tick, y2);\n      }\n    }\n    ctx.stroke();\n\n    // Draw text pills (each label gets its own pill)\n    ctx.font = WEB_EDITOR_V2_DISTANCE_LABEL_FONT;\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'middle';\n\n    for (const label of labels) {\n      const line = label.line;\n      if (!isValidLine(line)) continue;\n\n      // Measure text dimensions\n      const metrics = ctx.measureText(label.text);\n      const textWidth = metrics.width;\n      // Use actualBoundingBox if available, fallback to estimated values\n      const ascent = Number.isFinite(metrics.actualBoundingBoxAscent)\n        ? metrics.actualBoundingBoxAscent\n        : 8;\n      const descent = Number.isFinite(metrics.actualBoundingBoxDescent)\n        ? metrics.actualBoundingBoxDescent\n        : 3;\n      const textHeight = ascent + descent;\n\n      // Calculate pill dimensions\n      const pillWidth = Math.ceil(textWidth + WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_X * 2);\n      const pillHeight = Math.ceil(textHeight + WEB_EDITOR_V2_DISTANCE_LABEL_PADDING_Y * 2);\n\n      // Position pill at midpoint of measurement line with offset\n      const midX = (line.x1 + line.x2) / 2;\n      const midY = (line.y1 + line.y2) / 2;\n      const offset = WEB_EDITOR_V2_DISTANCE_LABEL_OFFSET;\n\n      let pillX = midX - pillWidth / 2;\n      let pillY = midY - pillHeight / 2;\n\n      // Position based on axis with auto-flip if out of viewport\n      if (label.axis === 'x') {\n        // Horizontal distance: prefer above the line\n        pillY = midY - pillHeight / 2 - offset;\n        if (pillY < 0) {\n          pillY = midY + offset - pillHeight / 2;\n        }\n      } else {\n        // Vertical distance: prefer right of the line\n        pillX = midX + offset - pillWidth / 2;\n        if (pillX + pillWidth > viewportWidth) {\n          pillX = midX - offset - pillWidth / 2;\n        }\n      }\n\n      // Clamp within viewport bounds (handle edge case where pill > viewport)\n      const maxPillX = Math.max(2, viewportWidth - pillWidth - 2);\n      const maxPillY = Math.max(2, viewportHeight - pillHeight - 2);\n      pillX = clamp(pillX, 2, maxPillX);\n      pillY = clamp(pillY, 2, maxPillY);\n\n      // Draw pill background\n      ctx.save();\n      ctx.fillStyle = WEB_EDITOR_V2_COLORS.distanceLabelBg;\n      ctx.strokeStyle = WEB_EDITOR_V2_COLORS.distanceLabelBorder;\n      ctx.lineWidth = 1;\n\n      ctx.beginPath();\n      buildRoundedRectPath(\n        ctx,\n        pillX,\n        pillY,\n        pillWidth,\n        pillHeight,\n        WEB_EDITOR_V2_DISTANCE_LABEL_RADIUS,\n      );\n      ctx.fill();\n      ctx.stroke();\n\n      // Draw text\n      ctx.fillStyle = WEB_EDITOR_V2_COLORS.distanceLabelText;\n      ctx.fillText(label.text, pillX + pillWidth / 2, pillY + pillHeight / 2);\n      ctx.restore();\n    }\n\n    ctx.restore();\n  }\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function markDirty(): void {\n    if (disposer.isDisposed) return;\n    dirty = true;\n    scheduleRaf();\n  }\n\n  function render(): void {\n    if (disposer.isDisposed || !dirty) return;\n\n    // Cancel any pending RAF (in case render() is called manually)\n    cancelRaf();\n\n    // Reset dirty flag before drawing\n    dirty = false;\n\n    // Calculate hover rect to render (may be animated)\n    const now = performance.now();\n    let hoverRectToRender = hoverRect;\n\n    if (hoverAnimation) {\n      const elapsed = now - hoverAnimation.startTime;\n      const progress = clamp(elapsed / hoverAnimation.durationMs, 0, 1);\n      const easedProgress = easeOutCubic(progress);\n      hoverRectToRender = lerpRect(hoverAnimation.start, hoverAnimation.end, easedProgress);\n\n      if (progress >= 1) {\n        // Animation complete, clear state\n        hoverAnimation = null;\n      } else {\n        // Animation in progress, schedule next frame\n        dirty = true;\n      }\n    }\n\n    // Clear and redraw\n    clearCanvas();\n    drawBox(hoverRectToRender, BOX_STYLES.hover);\n    drawBox(selectionRect, BOX_STYLES.selection);\n    drawBox(dragGhostRect, BOX_STYLES.dragGhost);\n    drawInsertionLine(insertionLine);\n    drawGuideLines(guideLines);\n    drawDistanceLabels(distanceLabels);\n\n    // If something marked dirty during render, schedule another frame\n    if (dirty) {\n      scheduleRaf();\n    }\n  }\n\n  function setHoverRect(rect: ViewportRect | null, options?: { animate?: boolean }): void {\n    const shouldAnimate = options?.animate === true;\n\n    // Fast path: no animation requested (snap immediately)\n    if (!shouldAnimate) {\n      hoverAnimation = null;\n      hoverRect = rect;\n      markDirty();\n      return;\n    }\n\n    // Animation requested: calculate starting position\n    const now = performance.now();\n    let fromRect: ViewportRect | null = hoverRect;\n\n    // If animation is in progress, start from current interpolated position\n    // This ensures smooth transition when target changes mid-animation\n    if (hoverAnimation) {\n      const elapsed = now - hoverAnimation.startTime;\n      const progress = clamp(elapsed / hoverAnimation.durationMs, 0, 1);\n      const easedProgress = easeOutCubic(progress);\n      fromRect = lerpRect(hoverAnimation.start, hoverAnimation.end, easedProgress);\n    }\n\n    // Cannot animate if source or target rect is invalid\n    if (!isValidRect(fromRect) || !isValidRect(rect)) {\n      hoverAnimation = null;\n      hoverRect = rect;\n      markDirty();\n      return;\n    }\n\n    // Start animation from current position to target\n    hoverAnimation = {\n      start: { ...fromRect },\n      end: { ...rect },\n      startTime: now,\n      durationMs: HOVER_ANIMATION_DURATION_MS,\n    };\n    hoverRect = rect;\n    markDirty();\n  }\n\n  function setSelectionRect(rect: ViewportRect | null): void {\n    selectionRect = rect;\n    markDirty();\n  }\n\n  function setDragGhostRect(rect: ViewportRect | null): void {\n    dragGhostRect = rect;\n    markDirty();\n  }\n\n  function setInsertionLine(line: ViewportLine | null): void {\n    insertionLine = line;\n    markDirty();\n  }\n\n  function setGuideLines(lines: readonly ViewportLine[] | null): void {\n    guideLines = lines && lines.length > 0 ? lines : null;\n    markDirty();\n  }\n\n  function setDistanceLabels(labels: readonly DistanceLabel[] | null): void {\n    distanceLabels = labels && labels.length > 0 ? labels : null;\n    markDirty();\n  }\n\n  function clear(): void {\n    hoverRect = null;\n    hoverAnimation = null;\n    selectionRect = null;\n    dragGhostRect = null;\n    insertionLine = null;\n    guideLines = null;\n    distanceLabels = null;\n    markDirty();\n  }\n\n  // ==========================================================================\n  // Initialization\n  // ==========================================================================\n\n  // Initial size measurement\n  try {\n    const rect = container.getBoundingClientRect();\n    viewportWidth = Math.max(1, rect.width);\n    viewportHeight = Math.max(1, rect.height);\n  } catch (error) {\n    console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Initial size measurement failed:`, error);\n  }\n\n  // Setup ResizeObserver for automatic sizing\n  disposer.observeResize(container, (entries) => {\n    const entry = entries[0];\n    const rect = entry?.contentRect;\n    if (!rect) return;\n\n    const nextWidth = Math.max(1, rect.width);\n    const nextHeight = Math.max(1, rect.height);\n\n    // Skip if size hasn't changed significantly\n    if (Math.abs(nextWidth - viewportWidth) < 0.5 && Math.abs(nextHeight - viewportHeight) < 0.5) {\n      return;\n    }\n\n    viewportWidth = nextWidth;\n    viewportHeight = nextHeight;\n    markDirty();\n  });\n\n  // Initial render\n  markDirty();\n\n  return {\n    canvas,\n    markDirty,\n    render,\n    clear,\n    setHoverRect,\n    setSelectionRect,\n    setDragGhostRect,\n    setInsertionLine,\n    setGuideLines,\n    setDistanceLabels,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/overlay/handles-controller.ts",
    "content": "/**\n * Handles Controller (Phase 4.9)\n *\n * Renders interactive resize handles on top of the selected element.\n * Integrates drag-to-resize with TransactionManager.beginMultiStyle() so each gesture\n * becomes a single undo/redo step.\n *\n * Design notes:\n * - Uses DOM (not Canvas) for reliable hit targets, cursors, and pointer capture.\n * - Uses rAF-throttled updates to bound work to at most once per frame.\n * - Handles are positioned using transform for GPU-accelerated performance.\n */\n\nimport {\n  WEB_EDITOR_V2_DISTANCE_LABEL_MIN_PX,\n  WEB_EDITOR_V2_LOG_PREFIX,\n  WEB_EDITOR_V2_SNAP_HYSTERESIS_PX,\n  WEB_EDITOR_V2_SNAP_THRESHOLD_PX,\n} from '../constants';\nimport {\n  collectSiblingAnchors,\n  collectViewportAnchors,\n  computeDistanceLabels,\n  computeResizeSnap,\n  mergeAnchors,\n  type SnapAnchors,\n  type SnapLockX,\n  type SnapLockY,\n} from '../core/snap-engine';\nimport type { PositionTracker } from '../core/position-tracker';\nimport type { MultiStyleTransactionHandle, TransactionManager } from '../core/transaction-manager';\nimport type { CanvasOverlay, ViewportRect } from './canvas-overlay';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Resize handle direction (8 cardinal + ordinal directions) */\nexport type ResizeHandleDir = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';\n\n/** Position mode for determining how to apply position changes */\ntype ResizePositionMode = 'fixed' | 'absolute' | 'relative' | 'static';\n\n/** Options for creating the handles controller */\nexport interface HandlesControllerOptions {\n  /** overlayRoot from ShadowHost (Canvas container) */\n  container: HTMLElement;\n  /** Canvas overlay for drawing alignment guide lines (Phase 4.2) */\n  canvasOverlay: CanvasOverlay;\n  /** Transaction manager for atomic multi-style resize commits */\n  transactionManager: TransactionManager;\n  /** Position tracker for final sync after commit/cancel */\n  positionTracker: PositionTracker;\n}\n\n/** Handles controller public interface */\nexport interface HandlesController {\n  /** Update the current selected element (null hides handles and cancels active drag) */\n  setTarget(target: Element | null): void;\n  /** Update the current selection rect in viewport coordinates (null hides handles) */\n  setSelectionRect(rect: ViewportRect | null): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n/** Box-sizing extras for accurate size calculations */\ninterface BoxExtras {\n  boxSizing: 'border-box' | 'content-box';\n  horizontalExtras: number; // padding + border (horizontal)\n  verticalExtras: number; // padding + border (vertical)\n}\n\n/** Origin info for absolute positioning calculations */\ninterface AbsoluteOrigin {\n  originX: number;\n  originY: number;\n  scrollLeft: number;\n  scrollTop: number;\n}\n\n/** Active resize session state */\ninterface ResizeSession {\n  /** Pointer ID for capture tracking */\n  pointerId: number;\n  /** Direction being dragged */\n  dir: ResizeHandleDir;\n  /** Handle element being dragged */\n  handleEl: HTMLElement;\n  /** Target element being resized */\n  target: HTMLElement;\n  /** Position mode of target */\n  mode: ResizePositionMode;\n  /** CSS properties tracked for this gesture (used to start tx after threshold) */\n  properties: readonly string[];\n  /** Transaction handle for atomic commit/rollback (created after threshold) */\n  tx: MultiStyleTransactionHandle | null;\n  /** Whether the drag threshold has been exceeded (resize is active) */\n  hasPassedThreshold: boolean;\n\n  /** Whether this direction affects width */\n  affectsWidth: boolean;\n  /** Whether this direction affects height */\n  affectsHeight: boolean;\n  /** Whether dragging from west edge */\n  hasWest: boolean;\n  /** Whether dragging from north edge */\n  hasNorth: boolean;\n\n  // ===========================================================================\n  // Snap state (Phase 4.2)\n  // ===========================================================================\n\n  /** Pre-collected anchors for this gesture (siblings + viewport) */\n  anchors: SnapAnchors | null;\n  /** Active X-axis snap lock (for hysteresis) */\n  lockX: SnapLockX | null;\n  /** Active Y-axis snap lock (for hysteresis) */\n  lockY: SnapLockY | null;\n  /** Whether guides were drawn last frame (for change detection) */\n  hadGuidesLastFrame: boolean;\n  /** Whether distance labels were drawn last frame (for change detection) */\n  hadDistanceLabelsLastFrame: boolean;\n\n  /** Start pointer position */\n  startClientX: number;\n  startClientY: number;\n  /** Last pointer position (for rAF) */\n  lastClientX: number;\n  lastClientY: number;\n\n  /** Starting element rect */\n  startRect: ViewportRect;\n  /** Starting position value (left/top or margin-left/margin-top) */\n  startPosX: number;\n  startPosY: number;\n  /** Absolute positioning origin info (null for non-absolute) */\n  absOrigin: AbsoluteOrigin | null;\n  /** Box model extras for size calculations */\n  extras: BoxExtras;\n\n  /** Previous body styles for restoration */\n  prevBodyCursor: string;\n  prevBodyUserSelect: string;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Minimum element size in border-box pixels */\nconst MIN_BORDER_BOX_SIZE_PX = 1;\n\n/** Minimum pointer movement (px) to start resizing (prevents click -> transaction) */\nconst RESIZE_DRAG_THRESHOLD_PX = 3;\n\n/** All resize handle directions */\nconst HANDLE_DIRS: readonly ResizeHandleDir[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];\n\n/** Cursor style for each direction */\nconst CURSOR_BY_DIR: Readonly<Record<ResizeHandleDir, string>> = {\n  n: 'ns-resize',\n  s: 'ns-resize',\n  e: 'ew-resize',\n  w: 'ew-resize',\n  ne: 'nesw-resize',\n  sw: 'nesw-resize',\n  nw: 'nwse-resize',\n  se: 'nwse-resize',\n};\n\n// =============================================================================\n// Utility Functions\n// =============================================================================\n\nfunction isFiniteNumber(value: unknown): value is number {\n  return typeof value === 'number' && Number.isFinite(value);\n}\n\nfunction isValidRect(rect: ViewportRect | null): rect is ViewportRect {\n  if (!rect) return false;\n  return (\n    isFiniteNumber(rect.left) &&\n    isFiniteNumber(rect.top) &&\n    isFiniteNumber(rect.width) &&\n    isFiniteNumber(rect.height) &&\n    rect.width > 0.5 &&\n    rect.height > 0.5\n  );\n}\n\nfunction clampMin(value: number, min: number): number {\n  if (!Number.isFinite(value)) return min;\n  return value < min ? min : value;\n}\n\n/**\n * Parse a CSS pixel value (e.g., \"10px\", \"auto\", \"10\") to a number.\n * Returns null for non-numeric values like \"auto\".\n */\nfunction parsePx(value: string): number | null {\n  const trimmed = value.trim();\n  if (!trimmed || trimmed === 'auto' || trimmed === 'none') return null;\n\n  const match = trimmed.match(/^(-?\\d+(?:\\.\\d+)?)px$/);\n  if (match) {\n    const num = Number(match[1]);\n    return Number.isFinite(num) ? num : null;\n  }\n\n  const num = Number(trimmed);\n  return Number.isFinite(num) ? num : null;\n}\n\n/**\n * Format a number as a CSS pixel value.\n * Rounds to 2 decimal places to avoid floating point noise.\n */\nfunction formatPx(value: number): string {\n  if (!Number.isFinite(value)) return '0px';\n  const rounded = Math.round(value * 100) / 100;\n  const normalized = Object.is(rounded, -0) ? 0 : rounded;\n  return `${normalized}px`;\n}\n\n/**\n * Get the resize position mode from a CSS position value.\n */\nfunction getResizeMode(position: string): ResizePositionMode {\n  const p = position.trim().toLowerCase();\n  if (p === 'fixed') return 'fixed';\n  if (p === 'absolute') return 'absolute';\n  if (p === 'relative' || p === 'sticky') return 'relative';\n  return 'static';\n}\n\n// Direction helper functions\nfunction dirHasWest(dir: ResizeHandleDir): boolean {\n  return dir === 'w' || dir === 'nw' || dir === 'sw';\n}\n\nfunction dirHasEast(dir: ResizeHandleDir): boolean {\n  return dir === 'e' || dir === 'ne' || dir === 'se';\n}\n\nfunction dirHasNorth(dir: ResizeHandleDir): boolean {\n  return dir === 'n' || dir === 'nw' || dir === 'ne';\n}\n\nfunction dirHasSouth(dir: ResizeHandleDir): boolean {\n  return dir === 's' || dir === 'sw' || dir === 'se';\n}\n\n/**\n * Read an element's bounding rect in viewport coordinates.\n */\nfunction readViewportRect(element: Element): ViewportRect | null {\n  try {\n    const r = element.getBoundingClientRect();\n    if (\n      !Number.isFinite(r.left) ||\n      !Number.isFinite(r.top) ||\n      !Number.isFinite(r.width) ||\n      !Number.isFinite(r.height)\n    ) {\n      return null;\n    }\n    return {\n      left: r.left,\n      top: r.top,\n      width: Math.max(0, r.width),\n      height: Math.max(0, r.height),\n    };\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Safely get computed style for an element.\n */\nfunction safeGetComputedStyle(element: Element): CSSStyleDeclaration | null {\n  try {\n    return window.getComputedStyle(element);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Sum two CSS property values as pixels.\n */\nfunction sumStylePx(style: CSSStyleDeclaration, propA: string, propB: string): number {\n  const a = parsePx(style.getPropertyValue(propA)) ?? 0;\n  const b = parsePx(style.getPropertyValue(propB)) ?? 0;\n  return a + b;\n}\n\n/**\n * Read box model extras (padding + border) from computed style.\n */\nfunction readBoxExtras(style: CSSStyleDeclaration): BoxExtras {\n  const boxSizingRaw = style.getPropertyValue('box-sizing').trim();\n  const boxSizing: BoxExtras['boxSizing'] =\n    boxSizingRaw === 'border-box' ? 'border-box' : 'content-box';\n\n  const paddingX = sumStylePx(style, 'padding-left', 'padding-right');\n  const paddingY = sumStylePx(style, 'padding-top', 'padding-bottom');\n  const borderX = sumStylePx(style, 'border-left-width', 'border-right-width');\n  const borderY = sumStylePx(style, 'border-top-width', 'border-bottom-width');\n\n  return {\n    boxSizing,\n    horizontalExtras: paddingX + borderX,\n    verticalExtras: paddingY + borderY,\n  };\n}\n\n/**\n * Convert border-box size to CSS width/height value based on box-sizing.\n */\nfunction borderBoxToCssSize(\n  borderBoxPx: number,\n  extrasPx: number,\n  boxSizing: BoxExtras['boxSizing'],\n): number {\n  if (boxSizing === 'border-box') return borderBoxPx;\n  return Math.max(0, borderBoxPx - extrasPx);\n}\n\n/**\n * Compute the origin point for absolute positioning calculations.\n * For absolute elements, this is the padding-box of the offsetParent.\n */\nfunction computeAbsoluteOrigin(target: HTMLElement): AbsoluteOrigin {\n  try {\n    const op = target.offsetParent;\n    if (op instanceof HTMLElement) {\n      const rect = op.getBoundingClientRect();\n      const style = safeGetComputedStyle(op);\n      const borderLeft = style ? (parsePx(style.getPropertyValue('border-left-width')) ?? 0) : 0;\n      const borderTop = style ? (parsePx(style.getPropertyValue('border-top-width')) ?? 0) : 0;\n      return {\n        originX: rect.left + borderLeft,\n        originY: rect.top + borderTop,\n        scrollLeft: op.scrollLeft,\n        scrollTop: op.scrollTop,\n      };\n    }\n  } catch {\n    // Best-effort fallback below\n  }\n  // Fallback to viewport origin (for fixed or when offsetParent is null)\n  return { originX: 0, originY: 0, scrollLeft: 0, scrollTop: 0 };\n}\n\n/**\n * Stop event propagation and prevent default.\n */\nfunction stopEvent(event: Event): void {\n  if (event.cancelable) event.preventDefault();\n  event.stopPropagation();\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a handles controller for resize interactions.\n */\nexport function createHandlesController(options: HandlesControllerOptions): HandlesController {\n  const disposer = new Disposer();\n  const { container, canvasOverlay, transactionManager, positionTracker } = options;\n\n  // ===========================================================================\n  // DOM Structure\n  // ===========================================================================\n\n  // Layer container (covers viewport, pointer-events: none)\n  const layer = document.createElement('div');\n  layer.className = 'we-handles-layer';\n  layer.setAttribute('aria-hidden', 'true');\n  container.append(layer);\n  disposer.add(() => layer.remove());\n\n  // Selection frame (positioned by selection rect)\n  const frame = document.createElement('div');\n  frame.className = 'we-selection-frame';\n  frame.hidden = true;\n  layer.append(frame);\n\n  // Size HUD (displays W×H while dragging)\n  const sizeHud = document.createElement('div');\n  sizeHud.className = 'we-size-hud';\n  sizeHud.hidden = true;\n  frame.append(sizeHud);\n\n  // Create 8 resize handles\n  const handleEls = new Map<ResizeHandleDir, HTMLDivElement>();\n  for (const dir of HANDLE_DIRS) {\n    const el = document.createElement('div');\n    el.className = 'we-resize-handle';\n    el.dataset.dir = dir;\n    el.tabIndex = -1;\n    frame.append(el);\n    handleEls.set(dir, el);\n  }\n\n  // ===========================================================================\n  // State\n  // ===========================================================================\n\n  let currentTarget: HTMLElement | null = null;\n  let currentSelectionRect: ViewportRect | null = null;\n  let session: ResizeSession | null = null;\n\n  // rAF scheduling\n  let rafId: number | null = null;\n\n  function cancelRaf(): void {\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n  }\n  disposer.add(cancelRaf);\n\n  // ===========================================================================\n  // Rendering\n  // ===========================================================================\n\n  /**\n   * Render the selection frame at the given rect.\n   */\n  function renderSelectionRect(rect: ViewportRect | null): void {\n    const shouldShow = !!currentTarget && isValidRect(rect);\n    if (!shouldShow) {\n      frame.hidden = true;\n      return;\n    }\n\n    frame.hidden = false;\n    frame.style.transform = `translate3d(${rect.left}px, ${rect.top}px, 0)`;\n    frame.style.width = `${rect.width}px`;\n    frame.style.height = `${rect.height}px`;\n  }\n\n  /**\n   * Update the size HUD text.\n   */\n  function setHud(text: string | null): void {\n    if (!text) {\n      sizeHud.hidden = true;\n      sizeHud.textContent = '';\n      return;\n    }\n    sizeHud.hidden = false;\n    sizeHud.textContent = text;\n  }\n\n  // ===========================================================================\n  // Session Lifecycle\n  // ===========================================================================\n\n  /**\n   * Restore body styles after resize session ends.\n   */\n  function restoreBodyStyles(s: ResizeSession): void {\n    document.body.style.cursor = s.prevBodyCursor;\n    document.body.style.userSelect = s.prevBodyUserSelect;\n  }\n\n  /**\n   * Cancel the current resize session and rollback changes.\n   */\n  function cancelSession(reason: string): void {\n    const s = session;\n    if (!s) return;\n\n    cancelRaf();\n    session = null;\n\n    // Rollback transaction (only if a resize actually started)\n    if (s.tx) {\n      try {\n        s.tx.rollback();\n      } catch (error) {\n        console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Resize rollback failed:`, error);\n      }\n    }\n\n    // Restore body styles\n    try {\n      restoreBodyStyles(s);\n    } catch {\n      // Best-effort\n    }\n\n    // Clear snap overlays (Phase 4.2 & 4.3)\n    try {\n      canvasOverlay.setGuideLines(null);\n      canvasOverlay.setDistanceLabels(null);\n      canvasOverlay.render();\n    } catch {\n      // Best-effort\n    }\n\n    setHud(null);\n    renderSelectionRect(currentSelectionRect);\n\n    // Force position sync after rollback\n    try {\n      positionTracker.forceUpdate();\n    } catch {\n      // Best-effort\n    }\n\n    if (reason) {\n      console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Resize cancelled (${reason})`);\n    }\n  }\n\n  /**\n   * Commit the current resize session.\n   */\n  function commitSession(): void {\n    const s = session;\n    if (!s) return;\n\n    cancelRaf();\n    session = null;\n\n    // Commit transaction (no-op if drag never crossed threshold)\n    if (s.tx) {\n      try {\n        s.tx.commit({ merge: false });\n      } catch (error) {\n        console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Resize commit failed:`, error);\n        // Attempt rollback on commit failure\n        try {\n          s.tx.rollback();\n        } catch {\n          // Best-effort\n        }\n      }\n    }\n\n    // Restore body styles\n    try {\n      restoreBodyStyles(s);\n    } catch {\n      // Best-effort\n    }\n\n    // Clear snap overlays (Phase 4.2 & 4.3)\n    try {\n      canvasOverlay.setGuideLines(null);\n      canvasOverlay.setDistanceLabels(null);\n      canvasOverlay.render();\n    } catch {\n      // Best-effort\n    }\n\n    setHud(null);\n\n    // Force position sync after commit\n    try {\n      positionTracker.forceUpdate();\n    } catch {\n      // Best-effort\n    }\n  }\n\n  /**\n   * Schedule an update frame if not already scheduled.\n   */\n  function scheduleFrame(): void {\n    if (rafId !== null || disposer.isDisposed) return;\n    rafId = requestAnimationFrame(() => {\n      rafId = null;\n      updateFrame();\n    });\n  }\n\n  /**\n   * Update frame - apply style changes based on current drag position.\n   */\n  function updateFrame(): void {\n    const s = session;\n    if (!s) return;\n\n    // Verify target is still connected\n    if (!s.target.isConnected) {\n      cancelSession('target_disconnected');\n      return;\n    }\n\n    const dx = s.lastClientX - s.startClientX;\n    const dy = s.lastClientY - s.startClientY;\n\n    // Only start a real resize after pointer movement exceeds the threshold.\n    // This prevents \"clicking a handle\" from generating a transaction or writing styles.\n    if (!s.hasPassedThreshold) {\n      if (Math.hypot(dx, dy) < RESIZE_DRAG_THRESHOLD_PX) {\n        return;\n      }\n\n      s.hasPassedThreshold = true;\n\n      const startedTx = transactionManager.beginMultiStyle(s.target, Array.from(s.properties));\n      if (!startedTx) {\n        cancelSession('tx_unavailable');\n        return;\n      }\n      s.tx = startedTx;\n\n      // Collect snap anchors once when gesture becomes active (Phase 4.2)\n      // This avoids per-frame layout thrashing from reading sibling rects\n      try {\n        const siblingAnchors = collectSiblingAnchors(s.target);\n        const viewportAnchors = collectViewportAnchors();\n        s.anchors = mergeAnchors(siblingAnchors, viewportAnchors);\n      } catch {\n        // Snap will be disabled if anchor collection fails\n        s.anchors = null;\n      }\n    }\n\n    const tx = s.tx;\n    if (!tx) {\n      cancelSession('tx_missing');\n      return;\n    }\n\n    // Calculate new border-box dimensions\n    let nextWidthBorderBox = s.startRect.width;\n    let nextHeightBorderBox = s.startRect.height;\n\n    if (s.affectsWidth) {\n      if (dirHasEast(s.dir)) {\n        nextWidthBorderBox = clampMin(s.startRect.width + dx, MIN_BORDER_BOX_SIZE_PX);\n      }\n      if (dirHasWest(s.dir)) {\n        nextWidthBorderBox = clampMin(s.startRect.width - dx, MIN_BORDER_BOX_SIZE_PX);\n      }\n    }\n\n    if (s.affectsHeight) {\n      if (dirHasSouth(s.dir)) {\n        nextHeightBorderBox = clampMin(s.startRect.height + dy, MIN_BORDER_BOX_SIZE_PX);\n      }\n      if (dirHasNorth(s.dir)) {\n        nextHeightBorderBox = clampMin(s.startRect.height - dy, MIN_BORDER_BOX_SIZE_PX);\n      }\n    }\n\n    // Build proposed preview rect (before snapping)\n    const proposedLeftDelta = s.hasWest ? s.startRect.width - nextWidthBorderBox : 0;\n    const proposedTopDelta = s.hasNorth ? s.startRect.height - nextHeightBorderBox : 0;\n    const proposedRect: ViewportRect = {\n      left: s.startRect.left + proposedLeftDelta,\n      top: s.startRect.top + proposedTopDelta,\n      width: nextWidthBorderBox,\n      height: nextHeightBorderBox,\n    };\n\n    // Apply snapping if anchors are available (Phase 4.2)\n    let finalRect = proposedRect;\n    if (s.anchors) {\n      const hasEast = dirHasEast(s.dir);\n      const hasSouth = dirHasSouth(s.dir);\n\n      const snapResult = computeResizeSnap({\n        rect: proposedRect,\n        resize: {\n          hasWest: s.hasWest,\n          hasEast,\n          hasNorth: s.hasNorth,\n          hasSouth,\n        },\n        anchors: s.anchors,\n        thresholdPx: WEB_EDITOR_V2_SNAP_THRESHOLD_PX,\n        hysteresisPx: WEB_EDITOR_V2_SNAP_HYSTERESIS_PX,\n        minSizePx: MIN_BORDER_BOX_SIZE_PX,\n        lockX: s.lockX,\n        lockY: s.lockY,\n        viewport: {\n          width: window.innerWidth || 1,\n          height: window.innerHeight || 1,\n        },\n      });\n\n      // Update lock state for hysteresis\n      s.lockX = snapResult.lockX;\n      s.lockY = snapResult.lockY;\n      finalRect = snapResult.snappedRect;\n\n      // Compute distance labels (Phase 4.3)\n      const distanceLabels = computeDistanceLabels({\n        rect: finalRect,\n        lockX: s.lockX,\n        lockY: s.lockY,\n        minGapPx: WEB_EDITOR_V2_DISTANCE_LABEL_MIN_PX,\n        viewport: {\n          width: window.innerWidth || 1,\n          height: window.innerHeight || 1,\n        },\n      });\n\n      // Draw guide lines and distance labels (only update if state changed)\n      const hasGuides = snapResult.guideLines.length > 0;\n      const hasDistanceLabels = distanceLabels.length > 0;\n\n      if (hasGuides || s.hadGuidesLastFrame || hasDistanceLabels || s.hadDistanceLabelsLastFrame) {\n        try {\n          canvasOverlay.setGuideLines(hasGuides ? snapResult.guideLines : null);\n          canvasOverlay.setDistanceLabels(hasDistanceLabels ? distanceLabels : null);\n          canvasOverlay.render();\n        } catch {\n          // Best-effort; snapping still applies even if overlay fails\n        }\n        s.hadGuidesLastFrame = hasGuides;\n        s.hadDistanceLabelsLastFrame = hasDistanceLabels;\n      }\n\n      // Update dimensions from snapped rect\n      nextWidthBorderBox = finalRect.width;\n      nextHeightBorderBox = finalRect.height;\n    }\n\n    // Calculate edge deltas from snapped rect (for position updates)\n    const leftEdgeDelta = finalRect.left - s.startRect.left;\n    const topEdgeDelta = finalRect.top - s.startRect.top;\n\n    // Render preview immediately\n    renderSelectionRect(finalRect);\n\n    // Update HUD\n    setHud(`${Math.round(finalRect.width)} × ${Math.round(finalRect.height)}`);\n\n    // Build style changes\n    const styles: Record<string, string> = {};\n\n    if (s.affectsWidth) {\n      const widthCssPx = borderBoxToCssSize(\n        nextWidthBorderBox,\n        s.extras.horizontalExtras,\n        s.extras.boxSizing,\n      );\n      styles.width = formatPx(widthCssPx);\n    }\n\n    if (s.affectsHeight) {\n      const heightCssPx = borderBoxToCssSize(\n        nextHeightBorderBox,\n        s.extras.verticalExtras,\n        s.extras.boxSizing,\n      );\n      styles.height = formatPx(heightCssPx);\n    }\n\n    // Position-mode specific handling\n    if (s.mode === 'absolute' || s.mode === 'fixed') {\n      // For absolute/fixed: update left/top and clear right/bottom to avoid over-constraint\n      if (s.affectsWidth) {\n        styles.left = formatPx(s.startPosX + leftEdgeDelta);\n        styles.right = '';\n      }\n      if (s.affectsHeight) {\n        styles.top = formatPx(s.startPosY + topEdgeDelta);\n        styles.bottom = '';\n      }\n    } else if (s.mode === 'relative') {\n      // For relative: only update position if dragging from edge that needs it\n      if (s.affectsWidth && s.hasWest) {\n        styles.left = formatPx(s.startPosX + leftEdgeDelta);\n      }\n      if (s.affectsHeight && s.hasNorth) {\n        styles.top = formatPx(s.startPosY + topEdgeDelta);\n      }\n    } else {\n      // For static: use margin as best-effort fallback\n      if (s.affectsWidth && s.hasWest) {\n        styles['margin-left'] = formatPx(s.startPosX + leftEdgeDelta);\n      }\n      if (s.affectsHeight && s.hasNorth) {\n        styles['margin-top'] = formatPx(s.startPosY + topEdgeDelta);\n      }\n    }\n\n    // Apply styles\n    try {\n      tx.set(styles);\n    } catch (error) {\n      console.warn(`${WEB_EDITOR_V2_LOG_PREFIX} Resize preview apply failed:`, error);\n      cancelSession('apply_failed');\n    }\n  }\n\n  /**\n   * Start a resize session.\n   */\n  function startResize(dir: ResizeHandleDir, handleEl: HTMLElement, event: PointerEvent): void {\n    if (disposer.isDisposed) return;\n\n    // Only handle primary button\n    if (event.button !== 0) return;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    // Cancel any existing session\n    if (session) cancelSession('restart');\n\n    const computed = safeGetComputedStyle(target);\n    if (!computed) return;\n\n    // Block transformed elements (matrix math required for proper handling)\n    const transform = computed.getPropertyValue('transform').trim();\n    if (transform && transform !== 'none') {\n      console.warn(\n        `${WEB_EDITOR_V2_LOG_PREFIX} Resize handles do not support transformed elements yet`,\n      );\n      return;\n    }\n\n    const position = computed.getPropertyValue('position');\n    const mode = getResizeMode(position);\n\n    // Determine which axes are affected\n    const hasWest = dirHasWest(dir);\n    const hasNorth = dirHasNorth(dir);\n    const affectsWidth = hasWest || dirHasEast(dir);\n    const affectsHeight = hasNorth || dirHasSouth(dir);\n\n    // Read margins (needed for fixed/absolute origin and static auto detection)\n    const marginLeftRaw = computed.getPropertyValue('margin-left').trim().toLowerCase();\n    const marginTopRaw = computed.getPropertyValue('margin-top').trim().toLowerCase();\n    const marginLeftPx = parsePx(marginLeftRaw) ?? 0;\n    const marginTopPx = parsePx(marginTopRaw) ?? 0;\n\n    // Static positioning: margin:auto is commonly used for centering in flex/grid.\n    // Resizing from that side would force a numeric margin and break layout.\n    if (mode === 'static') {\n      if (hasWest && marginLeftRaw === 'auto') {\n        console.warn(\n          `${WEB_EDITOR_V2_LOG_PREFIX} Resize from west is disabled when margin-left is auto`,\n        );\n        return;\n      }\n      if (hasNorth && marginTopRaw === 'auto') {\n        console.warn(\n          `${WEB_EDITOR_V2_LOG_PREFIX} Resize from north is disabled when margin-top is auto`,\n        );\n        return;\n      }\n    }\n\n    const rect = isValidRect(currentSelectionRect)\n      ? currentSelectionRect\n      : readViewportRect(target);\n    if (!rect || !isValidRect(rect)) return;\n\n    // Build properties list for transaction\n    const properties: string[] = [];\n\n    if (affectsWidth) {\n      properties.push('width');\n      if (mode === 'absolute' || mode === 'fixed') {\n        properties.push('left', 'right');\n      } else if (mode === 'relative') {\n        if (hasWest) properties.push('left');\n      } else {\n        if (hasWest) properties.push('margin-left');\n      }\n    }\n\n    if (affectsHeight) {\n      properties.push('height');\n      if (mode === 'absolute' || mode === 'fixed') {\n        properties.push('top', 'bottom');\n      } else if (mode === 'relative') {\n        if (hasNorth) properties.push('top');\n      } else {\n        if (hasNorth) properties.push('margin-top');\n      }\n    }\n\n    // Calculate starting position based on mode\n    let absOrigin: AbsoluteOrigin | null = null;\n    let startPosX = 0;\n    let startPosY = 0;\n\n    if (mode === 'absolute') {\n      absOrigin = computeAbsoluteOrigin(target);\n      // Subtract margin to get the actual CSS left/top value\n      startPosX = affectsWidth\n        ? rect.left - marginLeftPx - absOrigin.originX + absOrigin.scrollLeft\n        : 0;\n      startPosY = affectsHeight\n        ? rect.top - marginTopPx - absOrigin.originY + absOrigin.scrollTop\n        : 0;\n    } else if (mode === 'fixed') {\n      absOrigin = { originX: 0, originY: 0, scrollLeft: 0, scrollTop: 0 };\n      // Subtract margin to get the actual CSS left/top value\n      startPosX = affectsWidth ? rect.left - marginLeftPx : 0;\n      startPosY = affectsHeight ? rect.top - marginTopPx : 0;\n    } else if (mode === 'relative') {\n      startPosX = affectsWidth && hasWest ? (parsePx(computed.getPropertyValue('left')) ?? 0) : 0;\n      startPosY = affectsHeight && hasNorth ? (parsePx(computed.getPropertyValue('top')) ?? 0) : 0;\n    } else {\n      startPosX = affectsWidth && hasWest ? marginLeftPx : 0;\n      startPosY = affectsHeight && hasNorth ? marginTopPx : 0;\n    }\n\n    const extras = readBoxExtras(computed);\n\n    // Save current body styles\n    const prevBodyCursor = document.body.style.cursor;\n    const prevBodyUserSelect = document.body.style.userSelect;\n\n    // Create session (transaction is created after threshold is crossed)\n    session = {\n      pointerId: event.pointerId,\n      dir,\n      handleEl,\n      target,\n      mode,\n      properties,\n      tx: null,\n      hasPassedThreshold: false,\n      affectsWidth,\n      affectsHeight,\n      hasWest,\n      hasNorth,\n      // Snap state (Phase 4.2 & 4.3) - initialized to null, populated after threshold\n      anchors: null,\n      lockX: null,\n      lockY: null,\n      hadGuidesLastFrame: false,\n      hadDistanceLabelsLastFrame: false,\n      startClientX: event.clientX,\n      startClientY: event.clientY,\n      lastClientX: event.clientX,\n      lastClientY: event.clientY,\n      startRect: rect,\n      startPosX,\n      startPosY,\n      absOrigin,\n      extras,\n      prevBodyCursor,\n      prevBodyUserSelect,\n    };\n\n    // Capture pointer for robust tracking\n    try {\n      handleEl.setPointerCapture(event.pointerId);\n    } catch {\n      // Pointer capture may fail on some elements/browsers\n    }\n\n    // Apply drag visual affordances\n    document.body.style.cursor = CURSOR_BY_DIR[dir];\n    document.body.style.userSelect = 'none';\n\n    stopEvent(event);\n\n    // Initial render and schedule first frame\n    renderSelectionRect(rect);\n    scheduleFrame();\n  }\n\n  // ===========================================================================\n  // Event Handlers\n  // ===========================================================================\n\n  function handlePointerMove(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    stopEvent(event);\n    s.lastClientX = event.clientX;\n    s.lastClientY = event.clientY;\n    scheduleFrame();\n  }\n\n  function handlePointerUp(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    stopEvent(event);\n    s.lastClientX = event.clientX;\n    s.lastClientY = event.clientY;\n    commitSession();\n  }\n\n  function handlePointerCancel(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    stopEvent(event);\n    cancelSession(event.type);\n  }\n\n  /**\n   * Handle ESC key - cancel resize without triggering EventController deselect.\n   * Uses stopImmediatePropagation to prevent other handlers from seeing the event.\n   */\n  function handleKeyDown(event: KeyboardEvent): void {\n    if (!session) return;\n    if (event.key !== 'Escape') return;\n\n    event.preventDefault();\n    event.stopImmediatePropagation();\n    event.stopPropagation();\n    cancelSession('escape');\n  }\n\n  function handleWindowBlur(): void {\n    if (!session) return;\n    cancelSession('blur');\n  }\n\n  function handleVisibilityChange(): void {\n    if (!session) return;\n    if (document.visibilityState !== 'visible') {\n      cancelSession('visibilitychange');\n    }\n  }\n\n  // ===========================================================================\n  // Event Wiring\n  // ===========================================================================\n\n  for (const [dir, el] of handleEls) {\n    disposer.listen(el, 'pointerdown', (event: PointerEvent) => startResize(dir, el, event));\n    disposer.listen(el, 'pointermove', handlePointerMove);\n    disposer.listen(el, 'pointerup', handlePointerUp);\n    disposer.listen(el, 'pointercancel', handlePointerCancel);\n    disposer.listen(el, 'lostpointercapture', handlePointerCancel);\n  }\n\n  // Global event handlers\n  disposer.listen(document, 'keydown', handleKeyDown, { capture: true });\n  disposer.listen(window, 'blur', handleWindowBlur);\n  disposer.listen(document, 'visibilitychange', handleVisibilityChange);\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  function setTarget(target: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    // Cancel active session when selection changes\n    if (session) cancelSession('target_change');\n\n    if (target instanceof HTMLElement && target.isConnected) {\n      currentTarget = target;\n    } else {\n      currentTarget = null;\n    }\n\n    renderSelectionRect(currentSelectionRect);\n  }\n\n  function setSelectionRect(rect: ViewportRect | null): void {\n    if (disposer.isDisposed) return;\n\n    currentSelectionRect = isValidRect(rect) ? rect : null;\n\n    // Hide if target is gone\n    if (!currentTarget || !currentTarget.isConnected) {\n      frame.hidden = true;\n      return;\n    }\n\n    // Cancel session if rect becomes invalid\n    if (session && !currentSelectionRect) {\n      cancelSession('rect_lost');\n      return;\n    }\n\n    // When idle, follow position tracker updates\n    if (!session) {\n      renderSelectionRect(currentSelectionRect);\n    }\n  }\n\n  function dispose(): void {\n    cancelSession('dispose');\n    currentTarget = null;\n    currentSelectionRect = null;\n    disposer.dispose();\n  }\n\n  // Initial state\n  renderSelectionRect(null);\n\n  return { setTarget, setSelectionRect, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/selection/selection-engine.ts",
    "content": "/**\n * Selection Engine (Phase 1.6 - Basic)\n *\n * Heuristic-based target picking to reduce noisy selections.\n *\n * Goals:\n * - Skip invisible/transparent elements\n * - De-prioritize \"wrapper-only\" elements (single-child, no visual boundary)\n * - Prefer interactive elements (button/link/input/etc.)\n * - Prefer elements with visual boundaries (border/background/shadow)\n * - Support basic parent drilling via Alt modifier\n *\n * Scoring system:\n * - Positive scores: interactive elements, visual boundaries, appropriate size\n * - Negative scores: wrapper-only, too small/large, SVG internals\n * - Candidates sorted by score descending, then by DOM order\n */\n\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating the selection engine */\nexport interface SelectionEngineOptions {\n  /** Check if a DOM node belongs to the editor overlay */\n  isOverlayElement: (node: unknown) => boolean;\n}\n\n/** A scored selection candidate */\nexport interface SelectionCandidate {\n  /** The candidate element */\n  element: Element;\n  /** Heuristic score (higher = better target) */\n  score: number;\n  /** Debug reasons explaining the score */\n  reasons: string[];\n  /** Whether this element is a wrapper-only container (no visual meaning) */\n  wrapperOnly?: boolean;\n}\n\n/** Keyboard modifiers for selection behavior */\nexport interface Modifiers {\n  alt: boolean;\n  shift: boolean;\n  ctrl: boolean;\n  meta: boolean;\n}\n\n/** Selection engine public interface */\nexport interface SelectionEngine {\n  /** Find the best target at a viewport point with modifier support */\n  findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null;\n  /**\n   * Find the best target from an Event (Shadow DOM aware via composedPath).\n   * Intended for click/selection only; hover should stay coordinate-based for performance.\n   *\n   * - Uses composedPath() to access elements inside Shadow DOM\n   * - Ctrl/Cmd + Click: selects innermost visible element (drill-in)\n   * - Alt + Click: selects parent element (drill-up)\n   */\n  findBestTargetFromEvent(event: Event, modifiers: Modifiers): Element | null;\n  /** Get scored candidates at a point (for debugging or drill-up UI) */\n  getCandidatesAtPoint(x: number, y: number): SelectionCandidate[];\n  /** Get a meaningful parent candidate (for Alt drill-up) */\n  getParentCandidate(current: Element): Element | null;\n  /** Cleanup */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Max elements from elementsFromPoint to process */\nconst MAX_HIT_ELEMENTS = 8;\n\n/** Max ancestor depth to traverse */\nconst MAX_ANCESTOR_DEPTH = 6;\n\n/** Max total candidates to consider */\nconst MAX_CANDIDATES = 60;\n\n/** Epsilon for rect comparisons */\nconst RECT_EPSILON = 0.5;\n\n/** Tags that are inherently interactive */\nconst INTERACTIVE_TAGS = new Set([\n  'A',\n  'BUTTON',\n  'INPUT',\n  'SELECT',\n  'TEXTAREA',\n  'LABEL',\n  'SUMMARY',\n  'DETAILS',\n]);\n\n/** ARIA roles that indicate interactivity */\nconst INTERACTIVE_ROLES = new Set([\n  'button',\n  'link',\n  'checkbox',\n  'radio',\n  'switch',\n  'tab',\n  'menuitem',\n  'option',\n  'combobox',\n  'textbox',\n]);\n\n/** Tags commonly used as layout wrappers */\nconst WRAPPER_TAGS = new Set([\n  'DIV',\n  'SPAN',\n  'SECTION',\n  'ARTICLE',\n  'MAIN',\n  'HEADER',\n  'FOOTER',\n  'NAV',\n  'ASIDE',\n]);\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Parse a CSS numeric value\n */\nfunction parseNumber(value: string): number {\n  const n = Number.parseFloat(value);\n  return Number.isFinite(n) ? n : 0;\n}\n\n/**\n * Check if a color is effectively transparent\n */\nfunction isTransparentColor(value: string): boolean {\n  const v = value.trim().toLowerCase();\n  if (v === 'transparent') return true;\n\n  // Check rgba() with alpha <= 0.01\n  const rgba = v.match(/^rgba?\\((.+)\\)$/);\n  if (rgba) {\n    const parts = rgba[1].split(',').map((p) => p.trim());\n    if (parts.length >= 4) {\n      const alpha = Number.parseFloat(parts[3]);\n      return Number.isFinite(alpha) && alpha <= 0.01;\n    }\n    // rgb() without alpha is opaque\n    return false;\n  }\n\n  // Check hsla() with alpha <= 0.01\n  const hsla = v.match(/^hsla?\\((.+)\\)$/);\n  if (hsla) {\n    const parts = hsla[1].split(',').map((p) => p.trim());\n    if (parts.length >= 4) {\n      const alpha = Number.parseFloat(parts[3]);\n      return Number.isFinite(alpha) && alpha <= 0.01;\n    }\n    return false;\n  }\n\n  // hex and other formats are opaque\n  return false;\n}\n\n/**\n * Check if element has direct text content (not just whitespace)\n */\nfunction hasDirectNonWhitespaceText(element: Element): boolean {\n  for (const node of Array.from(element.childNodes)) {\n    if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * Get parent element, crossing Shadow DOM boundaries\n */\nfunction getParentElementOrHost(element: Element): Element | null {\n  if (element.parentElement) return element.parentElement;\n\n  try {\n    const root = element.getRootNode?.();\n    if (root instanceof ShadowRoot) {\n      return root.host;\n    }\n  } catch {\n    // Ignore and fall back to null\n  }\n\n  return null;\n}\n\n/**\n * Get elements at a viewport point\n */\nfunction getHitElementsAtPoint(x: number, y: number): Element[] {\n  if (!Number.isFinite(x) || !Number.isFinite(y)) return [];\n\n  try {\n    if (typeof document.elementsFromPoint === 'function') {\n      return document.elementsFromPoint(x, y);\n    }\n  } catch {\n    // Fall back to elementFromPoint\n  }\n\n  const el = document.elementFromPoint(x, y);\n  return el ? [el] : [];\n}\n\n/**\n * Get viewport area for size ratio calculations\n */\nfunction getViewportArea(): number {\n  const w = Math.max(1, window.innerWidth || 1);\n  const h = Math.max(1, window.innerHeight || 1);\n  return w * h;\n}\n\n/**\n * Safely read element rect\n */\nfunction readRect(element: Element): DOMRectReadOnly | null {\n  try {\n    const rect = element.getBoundingClientRect();\n    if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null;\n    if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) return null;\n    return rect;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Check if element is effectively invisible\n */\nfunction isEffectivelyInvisible(style: CSSStyleDeclaration, rect: DOMRectReadOnly): boolean {\n  if (style.display === 'none') return true;\n  if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;\n  if (parseNumber(style.opacity) <= 0.01) return true;\n\n  // Check contentVisibility (non-standard property)\n  const contentVisibility = (style as unknown as Record<string, unknown>).contentVisibility;\n  if (contentVisibility === 'hidden') return true;\n\n  // Zero-dimension elements\n  if (rect.width <= RECT_EPSILON || rect.height <= RECT_EPSILON) return true;\n\n  return false;\n}\n\n/**\n * Score element based on visual boundary presence\n */\nfunction getVisualBoundaryScore(\n  element: Element,\n  style: CSSStyleDeclaration,\n): { points: number; reasons: string[] } {\n  let points = 0;\n  const reasons: string[] = [];\n\n  // Background color or image\n  if (!isTransparentColor(style.backgroundColor) || style.backgroundImage !== 'none') {\n    points += 2;\n    reasons.push('visual:background:+2');\n  }\n\n  // Border\n  const borderWidths = [\n    parseNumber(style.borderTopWidth),\n    parseNumber(style.borderRightWidth),\n    parseNumber(style.borderBottomWidth),\n    parseNumber(style.borderLeftWidth),\n  ];\n  const hasBorder =\n    borderWidths.some((w) => w > RECT_EPSILON) &&\n    (style.borderTopStyle !== 'none' ||\n      style.borderRightStyle !== 'none' ||\n      style.borderBottomStyle !== 'none' ||\n      style.borderLeftStyle !== 'none');\n  if (hasBorder) {\n    points += 3;\n    reasons.push('visual:border:+3');\n  }\n\n  // Box shadow\n  if (style.boxShadow && style.boxShadow !== 'none') {\n    points += 2;\n    reasons.push('visual:shadow:+2');\n  }\n\n  // Outline\n  if (style.outlineStyle !== 'none' && parseNumber(style.outlineWidth) > RECT_EPSILON) {\n    points += 1;\n    reasons.push('visual:outline:+1');\n  }\n\n  // Media elements are visually meaningful\n  const tag = element.tagName.toUpperCase();\n  if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'SVG') {\n    points += 2;\n    reasons.push('visual:media:+2');\n  }\n\n  // SVG sub-elements usually aren't meaningful targets\n  if (element instanceof SVGElement && tag !== 'SVG') {\n    points -= 1;\n    reasons.push('visual:svg-sub:-1');\n  }\n\n  return { points, reasons };\n}\n\n/**\n * Score element based on interactivity\n */\nfunction getInteractivityScore(\n  element: Element,\n  style: CSSStyleDeclaration,\n): { points: number; reasons: string[] } {\n  let points = 0;\n  const reasons: string[] = [];\n\n  const tag = element.tagName.toUpperCase();\n\n  // Interactive tags\n  if (INTERACTIVE_TAGS.has(tag)) {\n    points += 6;\n    reasons.push(`type:${tag.toLowerCase()}:+6`);\n  }\n\n  // Interactive roles\n  const role = element.getAttribute('role')?.toLowerCase() ?? '';\n  if (role && INTERACTIVE_ROLES.has(role)) {\n    points += 4;\n    reasons.push(`role:${role}:+4`);\n  }\n\n  // Anchor with href\n  if (element instanceof HTMLAnchorElement && element.href) {\n    points += 2;\n    reasons.push('attr:href:+2');\n  }\n\n  // Content editable\n  if (element instanceof HTMLElement) {\n    if (element.isContentEditable) {\n      points += 5;\n      reasons.push('attr:contenteditable:+5');\n    }\n\n    // Focusable\n    if (element.tabIndex >= 0) {\n      points += 2;\n      reasons.push('focusable:+2');\n    }\n  }\n\n  // Pointer cursor often indicates clickability\n  if (style.cursor === 'pointer') {\n    points += 2;\n    reasons.push('cursor:pointer:+2');\n  }\n\n  return { points, reasons };\n}\n\n/**\n * Score element based on size\n */\nfunction getSizeScore(\n  rect: DOMRectReadOnly,\n  viewportArea: number,\n): { points: number; reasons: string[] } {\n  let points = 0;\n  const reasons: string[] = [];\n\n  const area = rect.width * rect.height;\n  if (!Number.isFinite(area) || area <= 0) {\n    points -= 6;\n    reasons.push('size:invalid:-6');\n    return { points, reasons };\n  }\n\n  // Too small: hard to interact with\n  if (rect.width < 4 || rect.height < 4) {\n    points -= 6;\n    reasons.push('size:tiny:-6');\n  } else if (area < 16 * 16) {\n    points -= 4;\n    reasons.push('size:small:-4');\n  } else if (area < 44 * 44) {\n    // Below recommended tap target size\n    points -= 1;\n    reasons.push('size:below-tap-target:-1');\n  }\n\n  // Too large: likely a layout container\n  const ratio = viewportArea > 0 ? area / viewportArea : 0;\n  if (ratio > 0.85) {\n    points -= 8;\n    reasons.push('size:huge:-8');\n  } else if (ratio > 0.6) {\n    points -= 4;\n    reasons.push('size:very-large:-4');\n  }\n\n  return { points, reasons };\n}\n\n/**\n * Check if element has meaningful padding\n */\nfunction hasMeaningfulPadding(style: CSSStyleDeclaration): boolean {\n  return (\n    parseNumber(style.paddingTop) > RECT_EPSILON ||\n    parseNumber(style.paddingRight) > RECT_EPSILON ||\n    parseNumber(style.paddingBottom) > RECT_EPSILON ||\n    parseNumber(style.paddingLeft) > RECT_EPSILON\n  );\n}\n\n/**\n * Check if element is a wrapper-only container\n */\nfunction isWrapperOnly(\n  element: Element,\n  style: CSSStyleDeclaration,\n  visualScore: number,\n  interactivityScore: number,\n): boolean {\n  // display: contents has no box\n  if (style.display === 'contents') return true;\n\n  // Interactive elements are never pure wrappers\n  if (interactivityScore > 0) return false;\n\n  // Only check common wrapper tags\n  const tag = element.tagName.toUpperCase();\n  if (!WRAPPER_TAGS.has(tag)) return false;\n\n  // Must have exactly one child element\n  if (element.children.length !== 1) return false;\n\n  // Has direct text content = meaningful\n  if (hasDirectNonWhitespaceText(element)) return false;\n\n  // Has visual boundary = meaningful\n  if (visualScore > 0) return false;\n\n  // Has padding = meaningful\n  if (hasMeaningfulPadding(style)) return false;\n\n  return true;\n}\n\n/** Metadata for candidate ordering */\ninterface CandidateMeta {\n  hitOrder: number;\n  depthFromHit: number;\n}\n\n/**\n * Compare candidate metadata for ordering\n */\nfunction compareMeta(a: CandidateMeta, b: CandidateMeta): number {\n  if (a.hitOrder !== b.hitOrder) return a.hitOrder - b.hitOrder;\n  return a.depthFromHit - b.depthFromHit;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a selection engine for intelligent element picking.\n */\nexport function createSelectionEngine(options: SelectionEngineOptions): SelectionEngine {\n  const disposer = new Disposer();\n  const { isOverlayElement } = options;\n\n  /**\n   * Score a single element\n   */\n  function scoreElement(\n    element: Element,\n    styleCache: Map<Element, CSSStyleDeclaration>,\n    viewportArea: number,\n  ): SelectionCandidate | null {\n    // Basic filters\n    if (!element.isConnected) return null;\n    if (isOverlayElement(element)) return null;\n\n    const tag = element.tagName.toUpperCase();\n    if (tag === 'HTML' || tag === 'BODY') return null;\n\n    const rect = readRect(element);\n    if (!rect) return null;\n\n    // Get or cache computed style\n    let style = styleCache.get(element);\n    if (!style) {\n      style = window.getComputedStyle(element);\n      styleCache.set(element, style);\n    }\n\n    if (isEffectivelyInvisible(style, rect)) return null;\n\n    // Calculate scores\n    const reasons: string[] = [];\n    let score = 0;\n\n    const interactivity = getInteractivityScore(element, style);\n    score += interactivity.points;\n    reasons.push(...interactivity.reasons);\n\n    const visual = getVisualBoundaryScore(element, style);\n    score += visual.points;\n    reasons.push(...visual.reasons);\n\n    const size = getSizeScore(rect, viewportArea);\n    score += size.points;\n    reasons.push(...size.reasons);\n\n    // Check wrapper-only status and penalize\n    const wrapperOnly = isWrapperOnly(element, style, visual.points, interactivity.points);\n    if (wrapperOnly) {\n      score -= 8;\n      reasons.push('wrapperOnly:-8');\n    }\n\n    // De-prioritize generic inline spans\n    if (tag === 'SPAN' && interactivity.points === 0 && visual.points === 0) {\n      score -= 2;\n      reasons.push('inline:span:-2');\n    }\n\n    // Large fixed elements are often overlays/headers\n    const area = rect.width * rect.height;\n    const ratio = viewportArea > 0 ? area / viewportArea : 0;\n    if (style.position === 'fixed' && ratio > 0.3) {\n      score -= 2;\n      reasons.push('position:fixed-large:-2');\n    }\n\n    return { element, score, reasons, wrapperOnly };\n  }\n\n  /**\n   * Get all scored candidates at a point\n   */\n  function getCandidatesAtPoint(x: number, y: number): SelectionCandidate[] {\n    const hit = getHitElementsAtPoint(x, y);\n    if (hit.length === 0) return [];\n\n    // Collect candidates with metadata\n    const map = new Map<Element, CandidateMeta>();\n\n    function addCandidate(element: Element, meta: CandidateMeta): void {\n      if (isOverlayElement(element)) return;\n      if (map.size >= MAX_CANDIDATES && !map.has(element)) return;\n\n      const prev = map.get(element);\n      if (!prev || compareMeta(meta, prev) < 0) {\n        map.set(element, meta);\n      }\n    }\n\n    // Process hit elements and their ancestors\n    const limit = Math.min(hit.length, MAX_HIT_ELEMENTS);\n    for (let i = 0; i < limit; i++) {\n      const el = hit[i];\n      addCandidate(el, { hitOrder: i, depthFromHit: 0 });\n\n      // Traverse ancestors\n      let current: Element | null = el;\n      for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {\n        current = current ? getParentElementOrHost(current) : null;\n        if (!current) break;\n        addCandidate(current, { hitOrder: i, depthFromHit: depth });\n      }\n    }\n\n    // Score all candidates\n    const viewportArea = getViewportArea();\n    const styleCache = new Map<Element, CSSStyleDeclaration>();\n\n    const scored: Array<SelectionCandidate & CandidateMeta> = [];\n    for (const [element, meta] of map) {\n      const candidate = scoreElement(element, styleCache, viewportArea);\n      if (!candidate) continue;\n      scored.push({ ...candidate, ...meta });\n    }\n\n    // Sort by score (descending), then by DOM order\n    scored.sort((a, b) => {\n      if (b.score !== a.score) return b.score - a.score;\n      return compareMeta(a, b);\n    });\n\n    // Strip metadata from result\n    return scored.map(({ hitOrder: _, depthFromHit: __, ...c }) => c);\n  }\n\n  /**\n   * Get a meaningful parent candidate for drill-up\n   */\n  function getParentCandidate(current: Element): Element | null {\n    let parent = getParentElementOrHost(current);\n    if (!parent) return null;\n\n    const viewportArea = getViewportArea();\n    const styleCache = new Map<Element, CSSStyleDeclaration>();\n\n    while (parent) {\n      if (isOverlayElement(parent)) return null;\n\n      const tag = parent.tagName.toUpperCase();\n      if (tag === 'HTML' || tag === 'BODY') return null;\n\n      const rect = readRect(parent);\n      if (!rect) {\n        parent = getParentElementOrHost(parent);\n        continue;\n      }\n\n      let style = styleCache.get(parent);\n      if (!style) {\n        style = window.getComputedStyle(parent);\n        styleCache.set(parent, style);\n      }\n\n      if (isEffectivelyInvisible(style, rect)) {\n        parent = getParentElementOrHost(parent);\n        continue;\n      }\n\n      const interactivity = getInteractivityScore(parent, style);\n      const visual = getVisualBoundaryScore(parent, style);\n\n      // Return first non-wrapper parent\n      if (!isWrapperOnly(parent, style, visual.points, interactivity.points)) {\n        return parent;\n      }\n\n      parent = getParentElementOrHost(parent);\n    }\n\n    return null;\n  }\n\n  /**\n   * Find the best target at a point with modifier support\n   */\n  function findBestTarget(x: number, y: number, modifiers: Modifiers): Element | null {\n    const candidates = getCandidatesAtPoint(x, y);\n    const best = candidates[0]?.element ?? null;\n    if (!best) return null;\n\n    // Alt modifier: drill up to parent\n    if (modifiers.alt) {\n      return getParentCandidate(best) ?? best;\n    }\n\n    return best;\n  }\n\n  // ===========================================================================\n  // Shadow DOM (composedPath) Support - Phase 2.1\n  // ===========================================================================\n\n  /**\n   * Extract Element nodes from an event's composedPath(), filtering overlay elements.\n   * Returns elements ordered from innermost to outermost.\n   *\n   * Why composedPath?\n   * - When events bubble from inside Shadow DOM, they get \"retargeted\" at shadow boundaries\n   * - By the time a document-level listener receives the event, event.target points to the shadow host\n   * - composedPath() exposes the original event path before retargeting\n   */\n  function getComposedPathElements(event: Event): Element[] {\n    try {\n      const path = typeof event.composedPath === 'function' ? event.composedPath() : null;\n      if (!Array.isArray(path) || path.length === 0) return [];\n\n      const elements: Element[] = [];\n      for (const node of path) {\n        // Skip non-Element nodes (Text, Document, Window, etc.)\n        if (!(node instanceof Element)) continue;\n        // Skip overlay UI elements\n        if (isOverlayElement(node)) continue;\n        // Skip HTML and BODY\n        const tag = node.tagName.toUpperCase();\n        if (tag === 'HTML' || tag === 'BODY') continue;\n\n        elements.push(node);\n      }\n      return elements;\n    } catch {\n      // composedPath() may throw in edge cases (e.g., detached nodes)\n      return [];\n    }\n  }\n\n  /**\n   * Extract viewport coordinates from a MouseEvent/PointerEvent.\n   */\n  function extractClientPoint(event: Event): { x: number; y: number } | null {\n    const e = event as unknown as { clientX?: unknown; clientY?: unknown };\n    const x = typeof e.clientX === 'number' ? e.clientX : Number.NaN;\n    const y = typeof e.clientY === 'number' ? e.clientY : Number.NaN;\n    if (!Number.isFinite(x) || !Number.isFinite(y)) return null;\n    return { x, y };\n  }\n\n  /** Max elements to scan for innermost visible (performance guard) */\n  const MAX_INNERMOST_SCAN = 32;\n\n  /**\n   * Find innermost visible element from composedPath (for Ctrl/Cmd + Click drill-in).\n   * Returns the first element that passes visibility checks.\n   * Limited to MAX_INNERMOST_SCAN elements to prevent performance issues in deep DOMs.\n   */\n  function findInnermostVisible(pathElements: Element[]): Element | null {\n    const viewportArea = getViewportArea();\n    const styleCache = new Map<Element, CSSStyleDeclaration>();\n    const limit = Math.min(pathElements.length, MAX_INNERMOST_SCAN);\n\n    for (let i = 0; i < limit; i++) {\n      const element = pathElements[i];\n      // scoreElement returns null for invisible/invalid elements\n      const candidate = scoreElement(element, styleCache, viewportArea);\n      if (candidate) return candidate.element;\n    }\n    return null;\n  }\n\n  /**\n   * Get candidates from composedPath elements using the same scoring logic.\n   * Merges with point-based candidates for comprehensive selection.\n   */\n  function getCandidatesFromPath(\n    pathElements: Element[],\n    point: { x: number; y: number } | null,\n  ): SelectionCandidate[] {\n    if (pathElements.length === 0 && !point) return [];\n\n    const map = new Map<Element, CandidateMeta>();\n\n    function addCandidate(element: Element, meta: CandidateMeta): void {\n      if (isOverlayElement(element)) return;\n      if (map.size >= MAX_CANDIDATES && !map.has(element)) return;\n\n      const prev = map.get(element);\n      if (!prev || compareMeta(meta, prev) < 0) {\n        map.set(element, meta);\n      }\n    }\n\n    // Add composedPath elements with high priority (hitOrder = 0)\n    // These are the \"seeds\" from the actual event path\n    for (let i = 0; i < pathElements.length && i < MAX_HIT_ELEMENTS; i++) {\n      const el = pathElements[i];\n      addCandidate(el, { hitOrder: 0, depthFromHit: i });\n\n      // Also traverse ancestors (cross Shadow DOM boundaries)\n      let current: Element | null = el;\n      for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {\n        current = current ? getParentElementOrHost(current) : null;\n        if (!current) break;\n        addCandidate(current, { hitOrder: 0, depthFromHit: i + depth });\n      }\n    }\n\n    // Merge with point-based candidates if available (for better coverage)\n    if (point) {\n      const hitElements = getHitElementsAtPoint(point.x, point.y);\n      const limit = Math.min(hitElements.length, MAX_HIT_ELEMENTS);\n      for (let i = 0; i < limit; i++) {\n        const el = hitElements[i];\n        // hitOrder = 1 to give composedPath elements priority in tie-breaking\n        addCandidate(el, { hitOrder: 1, depthFromHit: 0 });\n\n        let current: Element | null = el;\n        for (let depth = 1; depth <= MAX_ANCESTOR_DEPTH; depth++) {\n          current = current ? getParentElementOrHost(current) : null;\n          if (!current) break;\n          addCandidate(current, { hitOrder: 1, depthFromHit: depth });\n        }\n      }\n    }\n\n    // Score all candidates\n    const viewportArea = getViewportArea();\n    const styleCache = new Map<Element, CSSStyleDeclaration>();\n\n    const scored: Array<SelectionCandidate & CandidateMeta> = [];\n    for (const [element, meta] of map) {\n      const candidate = scoreElement(element, styleCache, viewportArea);\n      if (!candidate) continue;\n      scored.push({ ...candidate, ...meta });\n    }\n\n    // Sort by score (descending), then by DOM order\n    scored.sort((a, b) => {\n      if (b.score !== a.score) return b.score - a.score;\n      return compareMeta(a, b);\n    });\n\n    return scored.map(({ hitOrder: _, depthFromHit: __, ...c }) => c);\n  }\n\n  /**\n   * Event-aware selection entry point (Shadow DOM support).\n   *\n   * Uses composedPath() to access elements inside Shadow DOM that would\n   * otherwise be inaccessible due to event retargeting.\n   *\n   * Strategy: \"Hit-first, climb-if-meaningless\"\n   * - Default: Select the direct hit element (what user clicked)\n   * - If direct hit is wrapper-only (no visual meaning), climb to first meaningful parent\n   * - This ensures clicking on boxC selects boxC, not boxA\n   *\n   * Modifier behavior:\n   * - Ctrl/Cmd + Click: Select innermost visible element (drill-in)\n   * - Alt + Click: Select parent of best target (drill-up)\n   */\n  function findBestTargetFromEvent(event: Event, modifiers: Modifiers): Element | null {\n    const pathElements = getComposedPathElements(event);\n    const point = extractClientPoint(event);\n\n    // Ctrl/Cmd + Click: drill-in to innermost visible element\n    // Takes precedence over Alt (if both pressed, drill-in wins)\n    if (modifiers.ctrl || modifiers.meta) {\n      const innermost = findInnermostVisible(pathElements);\n      if (innermost) return innermost;\n      // Fallback to point-based selection\n      return point ? findBestTarget(point.x, point.y, modifiers) : null;\n    }\n\n    // Get the direct hit element (what user actually clicked)\n    // Priority: composedPath[0] > elementsFromPoint[0]\n    const directHit =\n      pathElements[0] ?? (point ? getHitElementsAtPoint(point.x, point.y)[0] : null);\n\n    if (!directHit) {\n      // No hit at all, fallback to old scoring-based approach\n      return point ? findBestTarget(point.x, point.y, modifiers) : null;\n    }\n\n    // Score the direct hit to check if it's meaningful\n    const viewportArea = getViewportArea();\n    const styleCache = new Map<Element, CSSStyleDeclaration>();\n    const directCandidate = scoreElement(directHit, styleCache, viewportArea);\n\n    // Determine the base target:\n    // - If direct hit is valid and not a wrapper-only, use it\n    // - Otherwise, climb to the first meaningful parent\n    let base: Element;\n    if (directCandidate && !directCandidate.wrapperOnly) {\n      base = directHit;\n    } else {\n      // Direct hit is invalid or wrapper-only, climb to meaningful parent\n      base = getParentCandidate(directHit) ?? directHit;\n    }\n\n    // Alt + Click: drill-up to parent\n    if (modifiers.alt) {\n      return getParentCandidate(base) ?? base;\n    }\n\n    return base;\n  }\n\n  return {\n    findBestTarget,\n    findBestTargetFromEvent,\n    getCandidatesAtPoint,\n    getParentCandidate,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/breadcrumbs.ts",
    "content": "/**\n * Breadcrumbs UI (Phase 2.2)\n *\n * Displays the composed ancestor chain for the currently selected element.\n * Supports Shadow DOM boundaries by walking getRootNode() and shadowRoot.host.\n *\n * Design:\n * - Anchored to the selected element's bounding box (above-left preferred)\n * - Uses CSS classes defined in shadow-host.ts\n * - Buttons select ancestor elements\n * - \"⬡\" separator marks Shadow DOM boundaries\n */\n\nimport type { ViewportRect } from '../overlay/canvas-overlay';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Position of the breadcrumbs bar */\nexport type BreadcrumbsDock = 'top' | 'bottom';\n\n/** Options for creating the breadcrumbs component */\nexport interface BreadcrumbsOptions {\n  /** Container element to append breadcrumbs to */\n  container: HTMLElement;\n  /** Position of the breadcrumbs bar */\n  dock?: BreadcrumbsDock;\n  /** Callback when a breadcrumb item is clicked */\n  onSelect: (element: Element) => void;\n}\n\n/** Breadcrumbs public interface */\nexport interface Breadcrumbs {\n  /** Set the target element to show breadcrumbs for */\n  setTarget(element: Element | null): void;\n  /** Set the anchor rectangle (viewport coordinates) for positioning */\n  setAnchorRect(rect: ViewportRect | null): void;\n  /** Cleanup */\n  dispose(): void;\n}\n\n/** Internal representation of a breadcrumb item */\ninterface BreadcrumbItem {\n  /** The actual DOM element */\n  element: Element;\n  /** Short display label */\n  label: string;\n  /** Full label for tooltip */\n  fullLabel: string;\n  /** True if there's a Shadow DOM boundary before this item */\n  boundaryBefore: boolean;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Max depth to traverse the composed tree */\nconst MAX_COMPOSED_DEPTH = 64;\n\n/** Max characters for a truncated label */\nconst MAX_LABEL_CHARS = 36;\n\n/** Max class parts to include in label */\nconst MAX_CLASS_PARTS = 2;\n\n/** Separator between normal parent-child relationships */\nconst NORMAL_SEPARATOR = '›';\n\n/** Separator when crossing Shadow DOM boundary */\nconst SHADOW_SEPARATOR = '⬡';\n\n/** Gap between anchor element and breadcrumbs bar */\nconst ANCHOR_GAP_PX = 10;\n\n/** Padding from viewport edges */\nconst SAFE_PADDING_PX = 8;\n\n/** Assumed property panel width for safe area calculation */\nconst PROPERTY_PANEL_WIDTH = 320;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Truncate a label string with ellipsis\n */\nfunction truncateLabel(text: string, maxChars: number): string {\n  const t = text.trim();\n  if (t.length <= maxChars) return t;\n  return `${t.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;\n}\n\n/**\n * Format an element's display label (tag + id or classes)\n */\nfunction formatElementLabel(element: Element): { label: string; fullLabel: string } {\n  const tag = element.tagName.toLowerCase();\n  const id = element.id?.trim();\n\n  let suffix = '';\n  if (id) {\n    suffix = `#${id}`;\n  } else {\n    const classes = Array.from(element.classList ?? [])\n      .map((c) => c.trim())\n      .filter(Boolean)\n      .slice(0, MAX_CLASS_PARTS);\n    if (classes.length > 0) {\n      suffix = `.${classes.join('.')}`;\n    }\n  }\n\n  const fullLabel = `${tag}${suffix}`;\n  return { fullLabel, label: truncateLabel(fullLabel, MAX_LABEL_CHARS) };\n}\n\n/**\n * Build breadcrumb items from target element to composed tree root.\n * Returns items in outer-to-inner order (root first, target last).\n */\nfunction buildComposedBreadcrumbs(target: Element): BreadcrumbItem[] {\n  const raw: Array<{ element: Element; crossToParent: boolean }> = [];\n\n  let current: Element | null = target;\n  for (let i = 0; current && i < MAX_COMPOSED_DEPTH; i++) {\n    const tag = current.tagName.toUpperCase();\n    // Stop at document root elements\n    if (tag === 'HTML' || tag === 'BODY') break;\n\n    const parent: Element | null = current.parentElement;\n    if (parent) {\n      raw.push({ element: current, crossToParent: false });\n      current = parent;\n      continue;\n    }\n\n    // Check for Shadow DOM boundary\n    const rootNode = current.getRootNode?.();\n    if (rootNode instanceof ShadowRoot && rootNode.host instanceof Element) {\n      // Mark that we're crossing a Shadow DOM boundary to reach parent\n      raw.push({ element: current, crossToParent: true });\n      current = rootNode.host;\n      continue;\n    }\n\n    // Reached document root or a non-element root\n    raw.push({ element: current, crossToParent: false });\n    break;\n  }\n\n  // Reverse to get outer-to-inner order\n  // crossToParent indicates we crossed a Shadow DOM boundary to reach the parent\n  // After reverse, this means the edge FROM THIS ITEM to its visual predecessor\n  // has a boundary, so we mark boundaryBefore on this item\n  return raw.reverse().map(({ element, crossToParent }) => {\n    const { label, fullLabel } = formatElementLabel(element);\n    return {\n      element,\n      label,\n      fullLabel,\n      // boundaryBefore: true means there's a Shadow DOM boundary between this item\n      // and the previous item in the breadcrumb list\n      boundaryBefore: crossToParent,\n    };\n  });\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a breadcrumbs component for displaying element ancestry.\n */\nexport function createBreadcrumbs(options: BreadcrumbsOptions): Breadcrumbs {\n  const disposer = new Disposer();\n  const dock = options.dock ?? 'top';\n\n  let currentTarget: Element | null = null;\n  let items: BreadcrumbItem[] = [];\n  let anchorRect: ViewportRect | null = null;\n\n  // Cached bar dimensions (measured only after content changes)\n  let barW = 0;\n  let barH = 0;\n\n  // Create root nav element\n  const root = document.createElement('nav');\n  root.className = 'we-breadcrumbs';\n  root.dataset.position = dock;\n  root.dataset.hidden = 'true';\n  root.setAttribute('aria-label', 'Selection breadcrumbs');\n\n  options.container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Positioning Logic\n  // ==========================================================================\n\n  /**\n   * Clamp a number between min and max\n   */\n  function clampNumber(value: number, min: number, max: number): number {\n    if (max < min) return min;\n    return Math.min(max, Math.max(min, value));\n  }\n\n  /**\n   * Get the safe right boundary X (avoiding property panel)\n   */\n  function getSafeRightX(viewportW: number): number {\n    // Reserve space for property panel on the right (16px margin + 320px panel + 16px gap)\n    const panelReserved = 16 + PROPERTY_PANEL_WIDTH + 16;\n    return viewportW - panelReserved;\n  }\n\n  /**\n   * Measure bar dimensions after content change\n   */\n  function measureBarDimensions(): void {\n    const rect = root.getBoundingClientRect();\n    barW = rect.width;\n    barH = rect.height;\n  }\n\n  /**\n   * Update position based on anchor rect\n   */\n  function updatePosition(): void {\n    if (!currentTarget) return;\n    if (!anchorRect) return;\n    if (!(barW > 0 && barH > 0)) return;\n\n    const viewportW = window.innerWidth;\n    const viewportH = window.innerHeight;\n    const safeRightX = getSafeRightX(viewportW);\n\n    // Prefer placing at the anchor's left edge, but clamp within safe area\n    const maxLeft = Math.min(viewportW - SAFE_PADDING_PX - barW, safeRightX - barW);\n    const left = clampNumber(anchorRect.left, SAFE_PADDING_PX, maxLeft);\n\n    // Prefer above-left; if not enough room, switch to below-left\n    const aboveTop = anchorRect.top - ANCHOR_GAP_PX - barH;\n    const belowTop = anchorRect.top + anchorRect.height + ANCHOR_GAP_PX;\n    const preferredTop = aboveTop >= SAFE_PADDING_PX ? aboveTop : belowTop;\n    const top = clampNumber(preferredTop, SAFE_PADDING_PX, viewportH - SAFE_PADDING_PX - barH);\n\n    root.style.left = `${Math.round(left)}px`;\n    root.style.top = `${Math.round(top)}px`;\n  }\n\n  /**\n   * Render breadcrumb items to DOM\n   */\n  function render(): void {\n    root.textContent = '';\n\n    if (!currentTarget) {\n      root.dataset.hidden = 'true';\n      return;\n    }\n    root.dataset.hidden = 'false';\n\n    const frag = document.createDocumentFragment();\n\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i];\n\n      // Add separator before each item (except first)\n      if (i > 0) {\n        const sep = document.createElement('span');\n        const isShadowBoundary = item.boundaryBefore;\n        sep.className = isShadowBoundary ? 'we-crumb-sep we-crumb-sep--shadow' : 'we-crumb-sep';\n        sep.textContent = isShadowBoundary ? SHADOW_SEPARATOR : NORMAL_SEPARATOR;\n        sep.setAttribute('aria-hidden', 'true');\n        frag.append(sep);\n      }\n\n      // Create button for this crumb\n      const btn = document.createElement('button');\n      btn.type = 'button';\n      btn.className = 'we-crumb';\n      btn.dataset.index = String(i);\n      btn.textContent = item.label;\n      btn.title = item.fullLabel;\n\n      // Mark current (last) item\n      if (i === items.length - 1) {\n        btn.classList.add('we-crumb--current');\n        btn.setAttribute('aria-current', 'page');\n      }\n\n      frag.append(btn);\n    }\n\n    root.append(frag);\n\n    // Measure bar dimensions after content change and update position\n    measureBarDimensions();\n    updatePosition();\n  }\n\n  // Event delegation for crumb clicks\n  disposer.listen(root, 'click', (event) => {\n    const target = event.target;\n    if (!(target instanceof Element)) return;\n\n    const btn = target.closest('button.we-crumb');\n    if (!(btn instanceof HTMLButtonElement)) return;\n\n    event.preventDefault();\n\n    const rawIndex = btn.dataset.index ?? '';\n    const index = Number(rawIndex);\n    if (!Number.isInteger(index) || index < 0) return;\n\n    const item = items[index];\n    if (!item) return;\n\n    // Only select if element is still connected\n    if (item.element.isConnected) {\n      options.onSelect(item.element);\n    }\n  });\n\n  /**\n   * Set the target element to build breadcrumbs for\n   */\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    currentTarget = element;\n    items = element ? buildComposedBreadcrumbs(element) : [];\n    render();\n  }\n\n  /**\n   * Set the anchor rectangle for positioning (called by editor on position updates)\n   */\n  function setAnchorRect(rect: ViewportRect | null): void {\n    if (disposer.isDisposed) return;\n    anchorRect = rect;\n    updatePosition();\n  }\n\n  return {\n    setTarget,\n    setAnchorRect,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/floating-drag.ts",
    "content": "/**\n * Floating Drag Utility\n *\n * A helper for making Shadow DOM floating UI draggable via a dedicated handle.\n *\n * Features:\n * - Pointer capture for robust tracking across the viewport\n * - Viewport clamping with a configurable margin\n * - Escape key cancels the active drag and restores the start position\n *\n * Notes:\n * - This utility blocks pointer events during an active drag to\n *   prevent page interactions while moving the editor UI.\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface FloatingPosition {\n  left: number;\n  top: number;\n}\n\nexport interface FloatingDragOptions {\n  /** Element that triggers the drag (handle) */\n  handleEl: HTMLElement;\n  /** Element to be moved */\n  targetEl: HTMLElement;\n  /** Called when position changes during or after drag */\n  onPositionChange: (position: FloatingPosition) => void;\n  /** Margin from viewport edges in pixels */\n  clampMargin: number;\n  /**\n   * Delay drag activation to allow click interactions on the handle.\n   *\n   * When > 0, drag is only activated after:\n   * - Pointer held for at least this duration (ms), OR\n   * - Pointer moved beyond `moveThresholdPx`\n   *\n   * Use case: minimized toolbar where short click restores, long press drags.\n   * @default 0 (immediate drag)\n   */\n  clickThresholdMs?: number;\n  /**\n   * Movement threshold (px) that activates drag when clickThresholdMs > 0.\n   * @default 0\n   */\n  moveThresholdPx?: number;\n}\n\ninterface DragSession {\n  pointerId: number;\n  startPosition: FloatingPosition;\n  offsetX: number;\n  offsetY: number;\n  targetWidth: number;\n  targetHeight: number;\n  /** Starting client coordinates for move threshold calculation */\n  startClientX: number;\n  startClientY: number;\n  /** Whether drag has been activated (always true when clickThresholdMs=0) */\n  activated: boolean;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst WINDOW_CAPTURE: AddEventListenerOptions = { capture: true, passive: false };\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction blockEvent(event: Event): void {\n  if (event.cancelable) {\n    event.preventDefault();\n  }\n  event.stopImmediatePropagation();\n  event.stopPropagation();\n}\n\nfunction clampNumber(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min;\n  const lo = Math.min(min, max);\n  const hi = Math.max(min, max);\n  return Math.min(hi, Math.max(lo, value));\n}\n\nfunction clampPosition(\n  position: FloatingPosition,\n  size: { width: number; height: number },\n  clampMargin: number,\n  viewport: { width: number; height: number },\n): FloatingPosition {\n  const margin = Number.isFinite(clampMargin) ? Math.max(0, clampMargin) : 0;\n  const maxLeft = Math.max(margin, viewport.width - margin - size.width);\n  const maxTop = Math.max(margin, viewport.height - margin - size.height);\n\n  return {\n    left: clampNumber(position.left, margin, maxLeft),\n    top: clampNumber(position.top, margin, maxTop),\n  };\n}\n\nfunction roundPosition(position: FloatingPosition): FloatingPosition {\n  return { left: Math.round(position.left), top: Math.round(position.top) };\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Install drag behavior on a floating UI element.\n *\n * @returns Cleanup function to remove all event listeners\n */\nexport function installFloatingDrag(options: FloatingDragOptions): () => void {\n  const { handleEl, targetEl, onPositionChange, clampMargin } = options;\n\n  // Parse delayed activation options\n  const clickThresholdMs = Math.max(0, options.clickThresholdMs ?? 0);\n  const moveThresholdPx = Math.max(0, options.moveThresholdPx ?? 0);\n  const delayedActivation = clickThresholdMs > 0;\n  const moveThresholdSq = moveThresholdPx * moveThresholdPx;\n\n  let session: DragSession | null = null;\n  let disposed = false;\n  let activationTimer: number | null = null;\n\n  function teardownWindowListeners(): void {\n    window.removeEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE);\n    window.removeEventListener('pointerup', onWindowPointerUp, WINDOW_CAPTURE);\n    window.removeEventListener('pointercancel', onWindowPointerCancel, WINDOW_CAPTURE);\n    window.removeEventListener('keydown', onWindowKeyDown, WINDOW_CAPTURE);\n    window.removeEventListener('blur', onWindowBlur, WINDOW_CAPTURE);\n    document.removeEventListener('visibilitychange', onVisibilityChange);\n  }\n\n  function clearActivationTimer(): void {\n    if (activationTimer !== null) {\n      window.clearTimeout(activationTimer);\n      activationTimer = null;\n    }\n  }\n\n  function endDrag(pointerId: number): void {\n    const s = session;\n    if (!s) return;\n    if (s.pointerId !== pointerId) return;\n\n    clearActivationTimer();\n\n    try {\n      handleEl.releasePointerCapture(pointerId);\n    } catch {\n      // Pointer capture may be unavailable or already released\n    }\n\n    teardownWindowListeners();\n    session = null;\n    handleEl.dataset.dragging = 'false';\n  }\n\n  function applyNextPosition(next: FloatingPosition): void {\n    const s = session;\n    const viewport = { width: window.innerWidth, height: window.innerHeight };\n\n    const size = s\n      ? { width: s.targetWidth, height: s.targetHeight }\n      : (() => {\n          const rect = targetEl.getBoundingClientRect();\n          return { width: rect.width, height: rect.height };\n        })();\n\n    const clamped = clampPosition(next, size, clampMargin, viewport);\n    onPositionChange(roundPosition(clamped));\n  }\n\n  function cancelDrag(): void {\n    const s = session;\n    if (!s) return;\n    applyNextPosition(s.startPosition);\n    endDrag(s.pointerId);\n  }\n\n  /**\n   * Suppress the next click event on handle to prevent accidental click after drag.\n   */\n  function suppressClickOnce(): void {\n    const onClick = (e: MouseEvent) => {\n      blockEvent(e);\n    };\n    handleEl.addEventListener('click', onClick, { capture: true, once: true });\n    // Safety cleanup if no click fires (extended timeout for touch devices)\n    window.setTimeout(() => {\n      handleEl.removeEventListener('click', onClick, { capture: true });\n    }, 300);\n  }\n\n  /**\n   * Activate drag mode (when using delayed activation).\n   */\n  function activateDrag(pointerId: number): void {\n    const s = session;\n    if (!s || s.pointerId !== pointerId || s.activated) return;\n\n    s.activated = true;\n    handleEl.dataset.dragging = 'true';\n    clearActivationTimer();\n\n    try {\n      handleEl.setPointerCapture(pointerId);\n    } catch {\n      // Pointer capture may fail on some elements/browsers\n    }\n  }\n\n  function onWindowPointerMove(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    // Check if drag needs activation (delayed mode)\n    if (!s.activated) {\n      if (!delayedActivation || moveThresholdSq <= 0) return;\n\n      const dx = event.clientX - s.startClientX;\n      const dy = event.clientY - s.startClientY;\n      if (dx * dx + dy * dy < moveThresholdSq) return;\n\n      activateDrag(event.pointerId);\n    }\n\n    blockEvent(event);\n\n    applyNextPosition({\n      left: event.clientX - s.offsetX,\n      top: event.clientY - s.offsetY,\n    });\n  }\n\n  function onWindowPointerUp(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    // Only block event and suppress click if drag was activated\n    if (s.activated) {\n      blockEvent(event);\n      suppressClickOnce();\n    }\n    endDrag(event.pointerId);\n  }\n\n  function onWindowPointerCancel(event: PointerEvent): void {\n    const s = session;\n    if (!s) return;\n    if (event.pointerId !== s.pointerId) return;\n\n    if (s.activated) {\n      blockEvent(event);\n      cancelDrag();\n    } else {\n      endDrag(event.pointerId);\n    }\n  }\n\n  function onWindowKeyDown(event: KeyboardEvent): void {\n    if (event.key !== 'Escape') return;\n    const s = session;\n    if (!s) return;\n\n    if (s.activated) {\n      event.preventDefault();\n      event.stopImmediatePropagation();\n      event.stopPropagation();\n      cancelDrag();\n    } else {\n      endDrag(s.pointerId);\n    }\n  }\n\n  function onWindowBlur(): void {\n    const s = session;\n    if (!s) return;\n\n    if (s.activated) {\n      cancelDrag();\n    } else {\n      endDrag(s.pointerId);\n    }\n  }\n\n  function onVisibilityChange(): void {\n    const s = session;\n    if (!s) return;\n    if (document.visibilityState !== 'hidden') return;\n\n    if (s.activated) {\n      cancelDrag();\n    } else {\n      endDrag(s.pointerId);\n    }\n  }\n\n  function onHandlePointerDown(event: PointerEvent): void {\n    if (disposed) return;\n    if (!targetEl.isConnected) return;\n\n    // Prevent re-entry if drag is already in progress\n    if (session) return;\n\n    // Left click only (touch typically reports button=0 too)\n    if (event.button !== 0) return;\n    if (!event.isPrimary) return;\n\n    // Only block event immediately if not using delayed activation\n    if (!delayedActivation) {\n      blockEvent(event);\n    }\n\n    const rect = targetEl.getBoundingClientRect();\n    const startPosition = roundPosition({ left: rect.left, top: rect.top });\n\n    session = {\n      pointerId: event.pointerId,\n      startPosition,\n      offsetX: event.clientX - rect.left,\n      offsetY: event.clientY - rect.top,\n      targetWidth: rect.width,\n      targetHeight: rect.height,\n      startClientX: event.clientX,\n      startClientY: event.clientY,\n      activated: !delayedActivation,\n    };\n\n    handleEl.dataset.dragging = session.activated ? 'true' : 'false';\n\n    try {\n      handleEl.setPointerCapture(event.pointerId);\n    } catch {\n      // Pointer capture may fail on some elements/browsers\n    }\n\n    // Start activation timer for delayed mode\n    if (delayedActivation) {\n      clearActivationTimer();\n      const pointerId = event.pointerId;\n      activationTimer = window.setTimeout(() => {\n        activateDrag(pointerId);\n      }, clickThresholdMs);\n    }\n\n    window.addEventListener('pointermove', onWindowPointerMove, WINDOW_CAPTURE);\n    window.addEventListener('pointerup', onWindowPointerUp, WINDOW_CAPTURE);\n    window.addEventListener('pointercancel', onWindowPointerCancel, WINDOW_CAPTURE);\n    window.addEventListener('keydown', onWindowKeyDown, WINDOW_CAPTURE);\n    window.addEventListener('blur', onWindowBlur, WINDOW_CAPTURE);\n    document.addEventListener('visibilitychange', onVisibilityChange);\n  }\n\n  // Initialize\n  handleEl.dataset.dragging = 'false';\n  handleEl.addEventListener('pointerdown', onHandlePointerDown);\n\n  // Return cleanup function\n  return () => {\n    disposed = true;\n    handleEl.removeEventListener('pointerdown', onHandlePointerDown);\n\n    // Best-effort teardown if a drag is active\n    if (session) {\n      try {\n        if (session.activated) {\n          cancelDrag();\n        } else {\n          endDrag(session.pointerId);\n        }\n      } catch {\n        // ignore\n      }\n    }\n\n    teardownWindowListeners();\n    clearActivationTimer();\n    session = null;\n    handleEl.dataset.dragging = 'false';\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/icons.ts",
    "content": "/**\n * Shared SVG Icons for Web Editor UI\n *\n * All icons are created as inline SVG elements to:\n * - Avoid external asset dependencies\n * - Support theming via `currentColor`\n * - Enable direct DOM manipulation\n *\n * Design standards:\n * - ViewBox: 20x20 (default) or 24x24 (for specific icons)\n * - Stroke width: 2px\n * - Line caps/joins: round\n */\n\n// =============================================================================\n// Icon Factory Helpers\n// =============================================================================\n\nfunction createSvgElement(): SVGElement {\n  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n  svg.setAttribute('viewBox', '0 0 20 20');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  return svg;\n}\n\nfunction createSvgElement24(): SVGElement {\n  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n  svg.setAttribute('viewBox', '0 0 24 24');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  return svg;\n}\n\nfunction createStrokePath(d: string): SVGPathElement {\n  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n  path.setAttribute('d', d);\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '2');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n  return path;\n}\n\n// =============================================================================\n// Icon Creators\n// =============================================================================\n\n/**\n * Minus icon (—) for minimize button\n */\nexport function createMinusIcon(): SVGElement {\n  const svg = createSvgElement();\n  svg.append(createStrokePath('M5 10h10'));\n  return svg;\n}\n\n/**\n * Plus icon (+) for restore/expand button\n */\nexport function createPlusIcon(): SVGElement {\n  const svg = createSvgElement();\n  svg.append(createStrokePath('M10 5v10M5 10h10'));\n  return svg;\n}\n\n/**\n * Close icon (×) for close button\n */\nexport function createCloseIcon(): SVGElement {\n  const svg = createSvgElement();\n  svg.append(createStrokePath('M6 6l8 8M14 6l-8 8'));\n  return svg;\n}\n\n/**\n * Grip icon (6 dots) for drag handle\n */\nexport function createGripIcon(): SVGElement {\n  const svg = createSvgElement();\n\n  const DOT_POSITIONS: ReadonlyArray<readonly [number, number]> = [\n    [7, 6],\n    [13, 6],\n    [7, 10],\n    [13, 10],\n    [7, 14],\n    [13, 14],\n  ];\n\n  for (const [cx, cy] of DOT_POSITIONS) {\n    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');\n    circle.setAttribute('cx', String(cx));\n    circle.setAttribute('cy', String(cy));\n    circle.setAttribute('r', '1.4');\n    circle.setAttribute('fill', 'currentColor');\n    svg.append(circle);\n  }\n\n  return svg;\n}\n\n/**\n * Chevron icon (▼) for collapse/expand indicator\n */\nexport function createChevronIcon(): SVGElement {\n  const svg = createSvgElement();\n  svg.classList.add('we-chevron');\n  svg.append(createStrokePath('M7 8l3 3 3-3'));\n  return svg;\n}\n\n/**\n * Undo icon (↶) for undo button\n * Uses 24x24 viewBox matching toolbar-ui.html design spec\n */\nexport function createUndoIcon(): SVGElement {\n  const svg = createSvgElement24();\n  svg.append(createStrokePath('M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6'));\n  return svg;\n}\n\n/**\n * Redo icon (↷) for redo button\n * Uses 24x24 viewBox matching toolbar-ui.html design spec\n */\nexport function createRedoIcon(): SVGElement {\n  const svg = createSvgElement24();\n  svg.append(createStrokePath('M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6'));\n  return svg;\n}\n\n/**\n * Chevron Up icon (^) for minimize/restore button\n * Rotates 180deg when minimized to point down\n */\nexport function createChevronUpIcon(): SVGElement {\n  const svg = createSvgElement();\n  svg.append(createStrokePath('M6 12l4-4 4 4'));\n  return svg;\n}\n\n/**\n * Chevron Down icon (small, 24x24 viewBox) for dropdown buttons\n * Matches toolbar-ui.html design spec\n */\nexport function createChevronDownSmallIcon(): SVGElement {\n  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n  svg.setAttribute('viewBox', '0 0 24 24');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n\n  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n  path.setAttribute('d', 'M19 9l-7 7-7-7');\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '2');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n  svg.append(path);\n\n  return svg;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/class-editor.ts",
    "content": "/**\n * Class Editor (Phase 4.7)\n *\n * DevTools-like class chips editor for the CSS panel.\n * Displays element's class list as interactive chips with add/remove capability.\n *\n * Features:\n * - Chips display for each class token\n * - Input field for adding new classes\n * - Backspace to remove last chip when input is empty\n * - Enter/Space to commit input\n * - Paste support for multiple classes\n * - Optional autocomplete suggestions\n *\n * This component is UI-only: it does not mutate the DOM element directly.\n * Instead, it emits the next class list via `onClassChange` callback.\n */\n\nimport { Disposer } from '../../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface ClassEditorOptions {\n  /** Container element to mount the editor */\n  container: HTMLElement;\n  /** Called when the user requests a class list change */\n  onClassChange: (classes: string[]) => void;\n  /** Optional suggestion source (returns unescaped class tokens) */\n  getSuggestions?: () => string[];\n}\n\nexport interface ClassEditor {\n  /** Set the target element (reads its current classes) */\n  setTarget(element: Element | null): void;\n  /** Manually set the class list (for external sync) */\n  setClasses(classes: string[]): void;\n  /** Refresh from current target element */\n  refresh(): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst MAX_SUGGESTIONS = 8;\nconst MAX_SUGGESTION_CACHE = 400;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Normalize class list: deduplicate, trim, remove empty tokens\n */\nfunction normalizeClassList(input: readonly string[]): string[] {\n  const out: string[] = [];\n  const seen = new Set<string>();\n\n  for (const raw of input ?? []) {\n    const token = String(raw ?? '').trim();\n    if (!token) continue;\n    if (seen.has(token)) continue;\n    seen.add(token);\n    out.push(token);\n  }\n\n  return out;\n}\n\n/**\n * Check if two string arrays are equal (order-sensitive)\n */\nfunction isSameStringList(a: readonly string[], b: readonly string[]): boolean {\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) return false;\n  }\n  return true;\n}\n\n/**\n * Split input string into class tokens\n */\nfunction splitTokens(raw: string): string[] {\n  return String(raw ?? '')\n    .split(/\\s+/)\n    .map((t) => t.trim())\n    .filter(Boolean);\n}\n\n/**\n * Read class list from element (compatible with SVG elements)\n */\nfunction readElementClasses(element: Element): string[] {\n  try {\n    const list = (element as HTMLElement).classList;\n    if (list && typeof list[Symbol.iterator] === 'function') {\n      return Array.from(list).filter(Boolean);\n    }\n  } catch {\n    // Fall back to attribute parsing\n  }\n\n  try {\n    const raw = element.getAttribute('class') ?? '';\n    return raw\n      .split(/\\s+/)\n      .map((t) => t.trim())\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a Class Editor component\n */\nexport function createClassEditor(options: ClassEditorOptions): ClassEditor {\n  const { container, onClassChange, getSuggestions } = options;\n  const disposer = new Disposer();\n\n  // State\n  let currentTarget: Element | null = null;\n  let currentClasses: string[] = [];\n  let isComposing = false;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-class-editor';\n  root.setAttribute('role', 'group');\n  root.setAttribute('aria-label', 'Class editor');\n\n  const chipsContainer = document.createElement('div');\n  chipsContainer.className = 'we-class-chips';\n\n  const input = document.createElement('input');\n  input.type = 'text';\n  input.className = 'we-input we-class-input';\n  input.autocomplete = 'off';\n  input.spellcheck = false;\n  input.placeholder = 'Add class';\n  input.setAttribute('aria-label', 'Add class');\n\n  const suggestionsContainer = document.createElement('div');\n  suggestionsContainer.className = 'we-class-suggestions';\n  suggestionsContainer.hidden = true;\n\n  root.append(chipsContainer, input, suggestionsContainer);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Rendering\n  // ==========================================================================\n\n  /**\n   * Render class chips\n   */\n  function renderChips(): void {\n    chipsContainer.innerHTML = '';\n\n    for (const cls of currentClasses) {\n      const chip = document.createElement('span');\n      chip.className = 'we-class-chip';\n\n      const text = document.createElement('span');\n      text.className = 'we-class-chip-text';\n      text.textContent = cls;\n\n      const removeBtn = document.createElement('button');\n      removeBtn.type = 'button';\n      removeBtn.className = 'we-class-chip-remove';\n      removeBtn.textContent = '×';\n      removeBtn.dataset.action = 'remove';\n      removeBtn.dataset.value = cls;\n      removeBtn.setAttribute('aria-label', `Remove class ${cls}`);\n\n      chip.append(text, removeBtn);\n      chipsContainer.append(chip);\n    }\n  }\n\n  /**\n   * Hide suggestions dropdown\n   */\n  function hideSuggestions(): void {\n    suggestionsContainer.hidden = true;\n    suggestionsContainer.innerHTML = '';\n  }\n\n  /**\n   * Render suggestions dropdown based on current input prefix\n   */\n  function renderSuggestions(): void {\n    if (input.disabled) {\n      hideSuggestions();\n      return;\n    }\n\n    const prefix = input.value.trim();\n    if (!prefix) {\n      hideSuggestions();\n      return;\n    }\n\n    const allSuggestions = getSuggestions?.() ?? [];\n    if (!Array.isArray(allSuggestions) || allSuggestions.length === 0) {\n      hideSuggestions();\n      return;\n    }\n\n    // Filter suggestions: not already in list, matches prefix\n    const existingSet = new Set(currentClasses);\n    const seenSet = new Set<string>();\n    const filtered: string[] = [];\n\n    for (const raw of allSuggestions) {\n      const s = String(raw ?? '').trim();\n      if (!s) continue;\n      if (existingSet.has(s)) continue;\n      if (!s.startsWith(prefix)) continue;\n      if (seenSet.has(s)) continue;\n      seenSet.add(s);\n      filtered.push(s);\n      if (filtered.length >= MAX_SUGGESTIONS) break;\n    }\n\n    if (filtered.length === 0) {\n      hideSuggestions();\n      return;\n    }\n\n    suggestionsContainer.hidden = false;\n    suggestionsContainer.innerHTML = '';\n\n    for (const suggestion of filtered) {\n      const btn = document.createElement('button');\n      btn.type = 'button';\n      btn.className = 'we-class-suggestion';\n      btn.textContent = suggestion;\n      btn.dataset.action = 'suggestion';\n      btn.dataset.value = suggestion;\n      suggestionsContainer.append(btn);\n    }\n  }\n\n  /**\n   * Internal setter for class list (updates UI only)\n   */\n  function setClassesInternal(classes: string[]): void {\n    currentClasses = normalizeClassList(classes);\n    renderChips();\n    renderSuggestions();\n  }\n\n  // ==========================================================================\n  // Mutations (emit onClassChange)\n  // ==========================================================================\n\n  /**\n   * Commit a new class list\n   */\n  function commitClassList(next: string[]): void {\n    if (!currentTarget || !currentTarget.isConnected) return;\n\n    const normalized = normalizeClassList(next);\n    if (isSameStringList(normalized, currentClasses)) {\n      renderSuggestions();\n      return;\n    }\n\n    currentClasses = normalized;\n    renderChips();\n    renderSuggestions();\n    onClassChange(currentClasses.slice());\n  }\n\n  /**\n   * Add one or more class tokens\n   */\n  function addTokens(tokens: string[]): void {\n    if (!tokens.length) return;\n\n    const next = currentClasses.slice();\n    const seenSet = new Set(next);\n\n    for (const raw of tokens) {\n      const t = String(raw ?? '').trim();\n      if (!t) continue;\n      if (seenSet.has(t)) continue;\n      seenSet.add(t);\n      next.push(t);\n    }\n\n    commitClassList(next);\n  }\n\n  /**\n   * Remove a specific class token\n   */\n  function removeToken(token: string): void {\n    const t = String(token ?? '').trim();\n    if (!t) return;\n    const next = currentClasses.filter((c) => c !== t);\n    commitClassList(next);\n  }\n\n  /**\n   * Remove the last class token\n   */\n  function removeLastToken(): void {\n    if (currentClasses.length === 0) return;\n    commitClassList(currentClasses.slice(0, -1));\n  }\n\n  /**\n   * Commit tokens from input field and clear it\n   */\n  function commitInputTokens(): void {\n    const tokens = splitTokens(input.value);\n    if (tokens.length === 0) return;\n    addTokens(tokens);\n    input.value = '';\n    renderSuggestions();\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  // Prevent blur when clicking suggestions (keeps input focused)\n  disposer.listen(suggestionsContainer, 'mousedown', (e) => {\n    e.preventDefault();\n  });\n\n  // Handle chip remove button clicks\n  disposer.listen(chipsContainer, 'click', (e) => {\n    const target = e.target as HTMLElement | null;\n    const btn = target?.closest?.('button[data-action=\"remove\"]') as HTMLButtonElement | null;\n    const value = btn?.dataset?.value;\n    if (!btn || !value) return;\n    e.preventDefault();\n    removeToken(value);\n  });\n\n  // Handle suggestion clicks\n  disposer.listen(suggestionsContainer, 'click', (e) => {\n    const target = e.target as HTMLElement | null;\n    const btn = target?.closest?.('button[data-action=\"suggestion\"]') as HTMLButtonElement | null;\n    const value = btn?.dataset?.value;\n    if (!btn || !value) return;\n    e.preventDefault();\n    addTokens([value]);\n    input.value = '';\n    renderSuggestions();\n    input.focus();\n  });\n\n  // Track composition state (IME input)\n  disposer.listen(input, 'compositionstart', () => {\n    isComposing = true;\n  });\n\n  disposer.listen(input, 'compositionend', () => {\n    isComposing = false;\n    renderSuggestions();\n  });\n\n  // Update suggestions on input\n  disposer.listen(input, 'input', () => {\n    renderSuggestions();\n  });\n\n  // Hide suggestions on blur\n  disposer.listen(input, 'blur', () => {\n    hideSuggestions();\n  });\n\n  // Handle paste events (split multiple tokens)\n  disposer.listen(input, 'paste', () => {\n    // Allow the paste to update the input value first\n    window.setTimeout(() => {\n      if (disposer.isDisposed) return;\n      const tokens = splitTokens(input.value);\n      if (tokens.length > 1) {\n        commitInputTokens();\n      } else {\n        renderSuggestions();\n      }\n    }, 0);\n  });\n\n  // Handle keyboard interactions\n  disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n    if (input.disabled) return;\n\n    // Enter: commit current input\n    if (e.key === 'Enter') {\n      if (isComposing) return;\n      e.preventDefault();\n      commitInputTokens();\n      return;\n    }\n\n    // Space: commit current input (if not empty)\n    if (e.key === ' ') {\n      if (isComposing) return;\n      if (input.value.trim()) {\n        e.preventDefault();\n        commitInputTokens();\n      }\n      return;\n    }\n\n    // Backspace: remove last chip when input is empty\n    if (e.key === 'Backspace') {\n      if (!input.value && currentClasses.length > 0) {\n        e.preventDefault();\n        removeLastToken();\n      }\n      return;\n    }\n\n    // Escape: hide suggestions or clear input\n    if (e.key === 'Escape') {\n      if (!suggestionsContainer.hidden) {\n        e.preventDefault();\n        hideSuggestions();\n      } else if (input.value) {\n        e.preventDefault();\n        input.value = '';\n        renderSuggestions();\n      }\n    }\n  });\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    currentTarget = element && element.isConnected ? element : null;\n    input.value = '';\n    hideSuggestions();\n    input.disabled = !currentTarget;\n\n    if (!currentTarget) {\n      setClassesInternal([]);\n      return;\n    }\n\n    setClassesInternal(readElementClasses(currentTarget));\n  }\n\n  function setClasses(classes: string[]): void {\n    if (disposer.isDisposed) return;\n    setClassesInternal(classes);\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) {\n      setTarget(null);\n      return;\n    }\n\n    setClassesInternal(readElementClasses(target));\n  }\n\n  function dispose(): void {\n    currentTarget = null;\n    currentClasses = [];\n    disposer.dispose();\n  }\n\n  // Initial state\n  setTarget(null);\n\n  return {\n    setTarget,\n    setClasses,\n    refresh,\n    dispose,\n  };\n}\n\nexport { MAX_SUGGESTION_CACHE };\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/alignment-grid.ts",
    "content": "/**\n * Alignment Grid (Phase 4.2)\n *\n * 3×3 single-select grid that maps:\n * - columns -> `justify-content` (flex-start, center, flex-end)\n * - rows    -> `align-items` (flex-start, center, flex-end)\n *\n * Design spec pattern (attr-ui.html:166-176):\n * ```html\n * <div class=\"p-2 bg-[#F9F9F9] border border-gray-100 rounded grid grid-cols-3 gap-3\">\n *   <div class=\"w-0.5 h-0.5 bg-gray-400 rounded-full\"></div>  <!-- inactive dot -->\n *   <div class=\"w-3 h-3 flex flex-col justify-between ...\">   <!-- active marker -->\n *     <div class=\"w-2 h-0.5 bg-blue-500\"></div>\n *     ...\n *   </div>\n *   ...\n * </div>\n * ```\n *\n * Notes:\n * - `setValue()` updates UI without calling `onChange`.\n * - `onChange` fires only on user interaction.\n */\n\nimport { Disposer } from '../../../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AlignmentGridValue {\n  justifyContent: string;\n  alignItems: string;\n}\n\nexport interface AlignmentGridOptions {\n  /** Container element to mount the grid */\n  container: HTMLElement;\n  /** Accessible label for the grid */\n  ariaLabel: string;\n  /** Left/center/right values for `justify-content` (default: flex-start/center/flex-end) */\n  justifyValues?: readonly [string, string, string];\n  /** Top/center/bottom values for `align-items` (default: flex-start/center/flex-end) */\n  alignValues?: readonly [string, string, string];\n  /** Initial value */\n  value?: AlignmentGridValue | null;\n  /** Disable the grid */\n  disabled?: boolean;\n  /** Called when selection changes via user interaction */\n  onChange?: (value: AlignmentGridValue) => void;\n}\n\nexport interface AlignmentGrid {\n  /** Root container element */\n  root: HTMLDivElement;\n  /** Get current selected value */\n  getValue(): AlignmentGridValue | null;\n  /** Set selected value (does not trigger onChange) */\n  setValue(value: AlignmentGridValue | null): void;\n  /** Enable/disable the grid */\n  setDisabled(disabled: boolean): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_AXIS_VALUES: readonly [string, string, string] = ['flex-start', 'center', 'flex-end'];\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction indexOf3(values: readonly [string, string, string], v: string): number {\n  for (let i = 0; i < 3; i++) {\n    if (values[i] === v) return i;\n  }\n  return -1;\n}\n\nfunction copyValue(v: AlignmentGridValue): AlignmentGridValue {\n  return { justifyContent: String(v.justifyContent), alignItems: String(v.alignItems) };\n}\n\n/** Build the visual marker (3 bars representing alignment) */\nfunction buildMarker(): HTMLElement {\n  const marker = document.createElement('span');\n  marker.className = 'we-alignment-grid__marker';\n  marker.setAttribute('aria-hidden', 'true');\n\n  // Three bars of different widths to show alignment direction\n  const bar1 = document.createElement('span');\n  bar1.className = 'we-alignment-grid__bar we-alignment-grid__bar--1';\n  const bar2 = document.createElement('span');\n  bar2.className = 'we-alignment-grid__bar we-alignment-grid__bar--2';\n  const bar3 = document.createElement('span');\n  bar3.className = 'we-alignment-grid__bar we-alignment-grid__bar--3';\n\n  marker.append(bar1, bar2, bar3);\n  return marker;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createAlignmentGrid(options: AlignmentGridOptions): AlignmentGrid {\n  const { container, ariaLabel, onChange } = options;\n  const disposer = new Disposer();\n\n  const justifyValues = options.justifyValues ?? DEFAULT_AXIS_VALUES;\n  const alignValues = options.alignValues ?? DEFAULT_AXIS_VALUES;\n\n  let isDisabled = Boolean(options.disabled);\n  let currentValue: AlignmentGridValue | null = null;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-alignment-grid';\n  root.setAttribute('role', 'grid');\n  root.setAttribute('aria-label', ariaLabel);\n\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // Cell data structure\n  interface CellData {\n    button: HTMLButtonElement;\n    dot: HTMLElement;\n    marker: HTMLElement;\n    value: AlignmentGridValue;\n    index: number;\n  }\n  const cells: CellData[] = [];\n\n  // ==========================================================================\n  // Sync Functions\n  // ==========================================================================\n\n  function syncDisabled(): void {\n    root.setAttribute('aria-disabled', String(isDisabled));\n    for (const c of cells) {\n      c.button.disabled = isDisabled;\n    }\n  }\n\n  function syncSelection(): void {\n    const selectedKey = currentValue\n      ? `${currentValue.justifyContent}|||${currentValue.alignItems}`\n      : null;\n\n    let tabIndex = -1;\n    for (let i = 0; i < cells.length; i++) {\n      const c = cells[i]!;\n      const key = `${c.value.justifyContent}|||${c.value.alignItems}`;\n      const selected = selectedKey !== null && key === selectedKey;\n\n      c.button.dataset.selected = selected ? 'true' : 'false';\n      c.button.setAttribute('aria-selected', selected ? 'true' : 'false');\n      c.dot.hidden = selected;\n      c.marker.hidden = !selected;\n\n      if (selected && !c.button.disabled) tabIndex = i;\n    }\n\n    // Default to first cell if nothing selected\n    if (tabIndex < 0) {\n      tabIndex = isDisabled ? -1 : 0;\n    }\n\n    for (let i = 0; i < cells.length; i++) {\n      cells[i]!.button.tabIndex = i === tabIndex ? 0 : -1;\n    }\n  }\n\n  function setValueInternal(next: AlignmentGridValue | null, emit: boolean): void {\n    const nextKey = next ? `${next.justifyContent}|||${next.alignItems}` : null;\n    const prevKey = currentValue\n      ? `${currentValue.justifyContent}|||${currentValue.alignItems}`\n      : null;\n\n    currentValue = next ? copyValue(next) : null;\n    syncSelection();\n\n    if (emit && nextKey !== null && nextKey !== prevKey) {\n      onChange?.(copyValue(currentValue!));\n    }\n  }\n\n  // ==========================================================================\n  // Keyboard Navigation\n  // ==========================================================================\n\n  function getActiveIndex(): number {\n    // Use getRootNode() for Shadow DOM compatibility\n    const rootNode = root.getRootNode();\n    const active = rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;\n    const focusIndex = cells.findIndex((c) => c.button === active);\n    if (focusIndex >= 0) return focusIndex;\n\n    if (currentValue) {\n      const key = `${currentValue.justifyContent}|||${currentValue.alignItems}`;\n      const selectedIndex = cells.findIndex(\n        (c) => `${c.value.justifyContent}|||${c.value.alignItems}` === key,\n      );\n      if (selectedIndex >= 0) return selectedIndex;\n    }\n\n    return 0;\n  }\n\n  function focusAndSelect(index: number, emit: boolean): void {\n    const c = cells[index];\n    if (!c) return;\n    if (c.button.disabled) return;\n    setValueInternal(c.value, emit);\n    c.button.focus();\n  }\n\n  function handleKeyDown(e: KeyboardEvent): void {\n    if (isDisabled) return;\n    if (cells.length !== 9) return;\n\n    const active = getActiveIndex();\n    const row = Math.floor(active / 3);\n    const col = active % 3;\n\n    let nextIndex: number | null = null;\n\n    switch (e.key) {\n      case 'ArrowLeft':\n        nextIndex = row * 3 + Math.max(0, col - 1);\n        break;\n      case 'ArrowRight':\n        nextIndex = row * 3 + Math.min(2, col + 1);\n        break;\n      case 'ArrowUp':\n        nextIndex = Math.max(0, row - 1) * 3 + col;\n        break;\n      case 'ArrowDown':\n        nextIndex = Math.min(2, row + 1) * 3 + col;\n        break;\n      case 'Home':\n        nextIndex = 0;\n        break;\n      case 'End':\n        nextIndex = 8;\n        break;\n      case 'Enter':\n      case ' ':\n        e.preventDefault();\n        focusAndSelect(active, true);\n        return;\n      default:\n        return;\n    }\n\n    if (nextIndex !== null) {\n      e.preventDefault();\n      focusAndSelect(nextIndex, true);\n    }\n  }\n\n  // ==========================================================================\n  // Build 3×3 Grid (row-major order)\n  // ==========================================================================\n\n  for (let r = 0; r < 3; r++) {\n    for (let c = 0; c < 3; c++) {\n      const justifyContent = justifyValues[c]!;\n      const alignItems = alignValues[r]!;\n\n      const button = document.createElement('button');\n      button.type = 'button';\n      button.className = 'we-alignment-grid__cell';\n      button.setAttribute('role', 'gridcell');\n      button.setAttribute(\n        'aria-label',\n        `justify-content: ${justifyContent}; align-items: ${alignItems}`,\n      );\n      button.dataset.row = String(r);\n      button.dataset.col = String(c);\n\n      // Inactive state: small dot\n      const dot = document.createElement('span');\n      dot.className = 'we-alignment-grid__dot';\n      dot.setAttribute('aria-hidden', 'true');\n\n      // Active state: alignment marker (3 bars)\n      const marker = buildMarker();\n      marker.hidden = true;\n\n      button.append(dot, marker);\n\n      const index = r * 3 + c;\n      const value: AlignmentGridValue = { justifyContent, alignItems };\n      cells.push({ button, dot, marker, value, index });\n\n      disposer.listen(button, 'click', (ev: MouseEvent) => {\n        ev.preventDefault();\n        if (isDisabled) return;\n        focusAndSelect(index, true);\n      });\n      disposer.listen(button, 'keydown', handleKeyDown);\n\n      root.append(button);\n    }\n  }\n\n  // ==========================================================================\n  // Initial State\n  // ==========================================================================\n\n  syncDisabled();\n  if (options.value) {\n    const j = indexOf3(justifyValues, options.value.justifyContent);\n    const a = indexOf3(alignValues, options.value.alignItems);\n    if (j >= 0 && a >= 0) {\n      setValueInternal({ justifyContent: justifyValues[j]!, alignItems: alignValues[a]! }, false);\n    } else {\n      setValueInternal(null, false);\n    }\n  } else {\n    setValueInternal(null, false);\n  }\n\n  // ==========================================================================\n  // Public Interface\n  // ==========================================================================\n\n  return {\n    root,\n\n    getValue(): AlignmentGridValue | null {\n      return currentValue ? copyValue(currentValue) : null;\n    },\n\n    setValue(value: AlignmentGridValue | null): void {\n      if (!value) {\n        setValueInternal(null, false);\n        return;\n      }\n      const j = indexOf3(justifyValues, String(value.justifyContent));\n      const a = indexOf3(alignValues, String(value.alignItems));\n      if (j < 0 || a < 0) {\n        setValueInternal(null, false);\n        return;\n      }\n      setValueInternal({ justifyContent: justifyValues[j]!, alignItems: alignValues[a]! }, false);\n    },\n\n    setDisabled(disabled: boolean): void {\n      isDisabled = disabled;\n      syncDisabled();\n      syncSelection();\n    },\n\n    dispose(): void {\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/icon-button-group.ts",
    "content": "/**\n * Icon Button Group (Phase 4.1)\n *\n * A single-select grid of icon buttons (e.g. Flow controls for `flex-direction`).\n *\n * Design spec pattern (attr-ui.html:136-141):\n * ```html\n * <div class=\"grid grid-cols-4 gap-1\">\n *   <button class=\"bg-[#F3F3F3] hover:bg-gray-200 rounded p-1 flex justify-center items-center\">\n *     <svg>...</svg>\n *   </button>\n *   ...\n * </div>\n * ```\n *\n * Notes:\n * - `setValue()` updates UI without calling `onChange`.\n * - `onChange` fires only on user interaction.\n */\n\nimport { Disposer } from '../../../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface IconButtonGroupItem<T extends string = string> {\n  /** Value associated with this button */\n  value: T;\n  /** Accessible label for screen readers */\n  ariaLabel: string;\n  /** Tooltip text */\n  title?: string;\n  /** Icon node (SVG element) - will be cloned for each button */\n  icon: Node;\n  /** Disable this specific button */\n  disabled?: boolean;\n}\n\nexport interface IconButtonGroupOptions<T extends string = string> {\n  /** Container element to mount the group */\n  container: HTMLElement;\n  /** Accessible label for the group */\n  ariaLabel: string;\n  /** Button items */\n  items: readonly IconButtonGroupItem<T>[];\n  /** Grid columns (default: items.length) */\n  columns?: number;\n  /** Initial selected value */\n  value?: T | null;\n  /** Disable the entire group */\n  disabled?: boolean;\n  /** Called when selection changes via user interaction */\n  onChange?: (value: T) => void;\n}\n\nexport interface IconButtonGroup<T extends string = string> {\n  /** Root container element */\n  root: HTMLDivElement;\n  /** Get current selected value */\n  getValue(): T | null;\n  /** Set selected value (does not trigger onChange) */\n  setValue(value: T | null): void;\n  /** Enable/disable the entire group */\n  setDisabled(disabled: boolean): void;\n  /** Cleanup resources */\n  dispose(): void;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction cloneForDom(node: Node): Node {\n  try {\n    return node.cloneNode(true);\n  } catch {\n    return node;\n  }\n}\n\nfunction findSelectedIndex<T extends string>(\n  items: readonly IconButtonGroupItem<T>[],\n  value: T | null,\n): number {\n  if (value === null) return -1;\n  for (let i = 0; i < items.length; i++) {\n    if (items[i]!.value === value) return i;\n  }\n  return -1;\n}\n\nfunction findFirstEnabledIndex(buttons: readonly HTMLButtonElement[]): number {\n  for (let i = 0; i < buttons.length; i++) {\n    if (!buttons[i]!.disabled) return i;\n  }\n  return -1;\n}\n\nfunction findLastEnabledIndex(buttons: readonly HTMLButtonElement[]): number {\n  for (let i = buttons.length - 1; i >= 0; i--) {\n    if (!buttons[i]!.disabled) return i;\n  }\n  return -1;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createIconButtonGroup<T extends string = string>(\n  options: IconButtonGroupOptions<T>,\n): IconButtonGroup<T> {\n  const { container, ariaLabel, items, onChange } = options;\n  const disposer = new Disposer();\n\n  let isDisabled = Boolean(options.disabled);\n  let currentValue: T | null = null;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-icon-button-group';\n  root.setAttribute('role', 'radiogroup');\n  root.setAttribute('aria-label', ariaLabel);\n\n  // Grid layout\n  const columns = Math.max(1, options.columns ?? items.length);\n  root.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;\n\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  const buttons: HTMLButtonElement[] = [];\n\n  // ==========================================================================\n  // Sync Functions\n  // ==========================================================================\n\n  function syncDisabled(): void {\n    root.setAttribute('aria-disabled', String(isDisabled));\n    for (let i = 0; i < buttons.length; i++) {\n      const btn = buttons[i]!;\n      const item = items[i]!;\n      btn.disabled = isDisabled || Boolean(item.disabled);\n    }\n  }\n\n  function syncSelection(): void {\n    const selectedIndex = findSelectedIndex(items, currentValue);\n    const tabIndex =\n      selectedIndex >= 0 && !buttons[selectedIndex]!.disabled\n        ? selectedIndex\n        : findFirstEnabledIndex(buttons);\n\n    for (let i = 0; i < buttons.length; i++) {\n      const btn = buttons[i]!;\n      const item = items[i]!;\n      const selected = currentValue !== null && item.value === currentValue;\n      btn.setAttribute('aria-checked', selected ? 'true' : 'false');\n      btn.dataset.selected = selected ? 'true' : 'false';\n      btn.tabIndex = i === tabIndex ? 0 : -1;\n    }\n  }\n\n  function setValueInternal(next: T | null, emit: boolean): void {\n    const nextIndex = findSelectedIndex(items, next);\n    if (next !== null && nextIndex < 0) next = null;\n\n    const changed = next !== currentValue;\n    currentValue = next;\n    syncSelection();\n\n    if (emit && changed && currentValue !== null) {\n      onChange?.(currentValue);\n    }\n  }\n\n  // ==========================================================================\n  // Keyboard Navigation\n  // ==========================================================================\n\n  function getActiveIndex(): number {\n    // Use getRootNode() for Shadow DOM compatibility\n    const rootNode = root.getRootNode();\n    const active = rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;\n    const focusIndex = buttons.findIndex((b) => b === active);\n    if (focusIndex >= 0) return focusIndex;\n\n    const selectedIndex = findSelectedIndex(items, currentValue);\n    if (selectedIndex >= 0) return selectedIndex;\n\n    const firstEnabled = findFirstEnabledIndex(buttons);\n    return firstEnabled >= 0 ? firstEnabled : 0;\n  }\n\n  function findEnabledFrom(start: number, delta: number): number {\n    if (delta === 0) return -1;\n    for (let i = start; i >= 0 && i < buttons.length; i += delta) {\n      if (!buttons[i]!.disabled) return i;\n    }\n    return -1;\n  }\n\n  function selectByIndex(nextIndex: number, emit: boolean): void {\n    if (nextIndex < 0 || nextIndex >= items.length) return;\n    const btn = buttons[nextIndex]!;\n    if (btn.disabled) return;\n    setValueInternal(items[nextIndex]!.value, emit);\n    btn.focus();\n  }\n\n  function handleKeyDown(e: KeyboardEvent): void {\n    if (isDisabled) return;\n    if (buttons.length === 0) return;\n\n    const active = getActiveIndex();\n    let next: number | null = null;\n\n    switch (e.key) {\n      case 'ArrowLeft':\n        next = findEnabledFrom(active - 1, -1);\n        break;\n      case 'ArrowRight':\n        next = findEnabledFrom(active + 1, 1);\n        break;\n      case 'ArrowUp':\n        next = findEnabledFrom(active - columns, -columns);\n        break;\n      case 'ArrowDown':\n        next = findEnabledFrom(active + columns, columns);\n        break;\n      case 'Home':\n        next = findFirstEnabledIndex(buttons);\n        break;\n      case 'End':\n        next = findLastEnabledIndex(buttons);\n        break;\n      case 'Enter':\n      case ' ':\n        e.preventDefault();\n        selectByIndex(active, true);\n        return;\n      default:\n        return;\n    }\n\n    if (next !== null && next >= 0) {\n      e.preventDefault();\n      selectByIndex(next, true);\n    }\n  }\n\n  // ==========================================================================\n  // Build Buttons\n  // ==========================================================================\n\n  for (let i = 0; i < items.length; i++) {\n    const item = items[i]!;\n\n    const btn = document.createElement('button');\n    btn.type = 'button';\n    btn.className = 'we-icon-button-group__btn';\n    btn.setAttribute('role', 'radio');\n    btn.setAttribute('aria-label', item.ariaLabel);\n    if (item.title) btn.dataset.tooltip = item.title;\n    btn.dataset.value = item.value;\n    btn.append(cloneForDom(item.icon));\n\n    disposer.listen(btn, 'click', (ev: MouseEvent) => {\n      ev.preventDefault();\n      if (isDisabled || btn.disabled) return;\n      setValueInternal(item.value, true);\n      btn.focus();\n    });\n\n    disposer.listen(btn, 'keydown', handleKeyDown);\n\n    buttons.push(btn);\n    root.append(btn);\n  }\n\n  // Initial state\n  syncDisabled();\n  const initialValue = options.value ?? items[0]?.value ?? null;\n  setValueInternal(initialValue, false);\n\n  // ==========================================================================\n  // Public Interface\n  // ==========================================================================\n\n  return {\n    root,\n\n    getValue(): T | null {\n      return currentValue;\n    },\n\n    setValue(value: T | null): void {\n      setValueInternal(value, false);\n    },\n\n    setDisabled(disabled: boolean): void {\n      isDisabled = disabled;\n      syncDisabled();\n      syncSelection();\n    },\n\n    dispose(): void {\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/input-container.ts",
    "content": "/**\n * Input Container Component\n *\n * A reusable wrapper for inputs aligned with the attr-ui.html design spec.\n * Provides container-level hover/focus-within styling with optional prefix/suffix.\n *\n * Design spec pattern:\n * ```html\n * <div class=\"input-bg rounded h-[28px] flex items-center px-2\">\n *   <span class=\"text-gray-400 mr-2\">X</span>  <!-- prefix -->\n *   <input type=\"text\" class=\"bg-transparent w-full outline-none\">\n *   <span class=\"text-gray-400 text-[10px]\">px</span>  <!-- suffix -->\n * </div>\n * ```\n *\n * CSS classes (defined in shadow-host.ts):\n * - `.we-input-container` - wrapper with hover/focus-within styles\n * - `.we-input-container__input` - transparent input\n * - `.we-input-container__prefix` - prefix element\n * - `.we-input-container__suffix` - suffix element (typically unit)\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Content for prefix/suffix: text string or DOM node (e.g., SVG icon) */\nexport type InputAffix = string | Node;\n\nexport interface InputContainerOptions {\n  /** Accessible label for the input element */\n  ariaLabel: string;\n  /** Input type (default: \"text\") */\n  type?: string;\n  /** Input mode for virtual keyboard (e.g., \"decimal\", \"numeric\") */\n  inputMode?: string;\n  /** Optional prefix content (text label or icon) */\n  prefix?: InputAffix | null;\n  /** Optional suffix content (unit text or icon) */\n  suffix?: InputAffix | null;\n  /** Additional class name(s) for root container */\n  rootClassName?: string;\n  /** Additional class name(s) for input element */\n  inputClassName?: string;\n  /** Input autocomplete attribute (default: \"off\") */\n  autocomplete?: string;\n  /** Input spellcheck attribute (default: false) */\n  spellcheck?: boolean;\n  /** Initial placeholder text */\n  placeholder?: string;\n}\n\nexport interface InputContainer {\n  /** Root container element */\n  root: HTMLDivElement;\n  /** Input element for wiring events */\n  input: HTMLInputElement;\n  /** Update prefix content */\n  setPrefix(content: InputAffix | null): void;\n  /** Update suffix content */\n  setSuffix(content: InputAffix | null): void;\n  /** Get current suffix text (null if no suffix or if suffix is a Node) */\n  getSuffixText(): string | null;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction hasAffix(value: InputAffix | null | undefined): value is InputAffix {\n  if (value === null || value === undefined) return false;\n  return typeof value === 'string' ? value.trim().length > 0 : true;\n}\n\nfunction joinClassNames(...parts: Array<string | null | undefined | false>): string {\n  return parts.filter(isNonEmptyString).join(' ');\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create an input container with optional prefix/suffix\n */\nexport function createInputContainer(options: InputContainerOptions): InputContainer {\n  const {\n    ariaLabel,\n    type = 'text',\n    inputMode,\n    prefix,\n    suffix,\n    rootClassName,\n    inputClassName,\n    autocomplete = 'off',\n    spellcheck = false,\n    placeholder,\n  } = options;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = joinClassNames('we-input-container', rootClassName);\n\n  // Prefix element (created lazily)\n  let prefixEl: HTMLSpanElement | null = null;\n\n  // Input element\n  const input = document.createElement('input');\n  input.type = type;\n  input.className = joinClassNames('we-input-container__input', inputClassName);\n  input.setAttribute('autocomplete', autocomplete);\n  input.spellcheck = spellcheck;\n  input.setAttribute('aria-label', ariaLabel);\n  if (inputMode) {\n    input.inputMode = inputMode;\n  }\n  if (placeholder !== undefined) {\n    input.placeholder = placeholder;\n  }\n\n  // Suffix element (created lazily)\n  let suffixEl: HTMLSpanElement | null = null;\n\n  // Helper: create/update affix element\n  function updateAffix(\n    kind: 'prefix' | 'suffix',\n    content: InputAffix | null,\n    existingEl: HTMLSpanElement | null,\n  ): HTMLSpanElement | null {\n    if (!hasAffix(content)) {\n      // Remove existing element if present\n      if (existingEl) {\n        existingEl.remove();\n      }\n      return null;\n    }\n\n    // Create element if needed\n    const el = existingEl ?? document.createElement('span');\n    el.className = `we-input-container__${kind}`;\n\n    // Clear and set content\n    el.textContent = '';\n    if (typeof content === 'string') {\n      el.textContent = content;\n    } else {\n      el.append(content);\n    }\n\n    return el;\n  }\n\n  // Initial prefix\n  if (hasAffix(prefix)) {\n    prefixEl = updateAffix('prefix', prefix, null);\n    if (prefixEl) root.append(prefixEl);\n  }\n\n  // Append input\n  root.append(input);\n\n  // Initial suffix\n  if (hasAffix(suffix)) {\n    suffixEl = updateAffix('suffix', suffix, null);\n    if (suffixEl) root.append(suffixEl);\n  }\n\n  // Public interface\n  return {\n    root,\n    input,\n\n    setPrefix(content: InputAffix | null): void {\n      const newEl = updateAffix('prefix', content, prefixEl);\n      if (newEl && !prefixEl) {\n        // Insert before input\n        root.insertBefore(newEl, input);\n      }\n      prefixEl = newEl;\n    },\n\n    setSuffix(content: InputAffix | null): void {\n      const newEl = updateAffix('suffix', content, suffixEl);\n      if (newEl && !suffixEl) {\n        // Append after input\n        root.append(newEl);\n      }\n      suffixEl = newEl;\n    },\n\n    getSuffixText(): string | null {\n      if (!suffixEl) return null;\n      // Only return text content, not Node content\n      const text = suffixEl.textContent?.trim();\n      return text || null;\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/slider-input.ts",
    "content": "/**\n * Slider Input Component\n *\n * A reusable \"slider + input\" control for numeric values:\n * - Left: native range slider for visual manipulation\n * - Right: InputContainer-backed numeric input for precise values\n *\n * Features:\n * - Bidirectional synchronization between slider and input\n * - Supports disabled state\n * - Accessible with ARIA labels\n *\n * Styling is defined in shadow-host.ts:\n * - `.we-slider-input`\n * - `.we-slider-input__slider`\n * - `.we-slider-input__number`\n */\n\nimport { createInputContainer, type InputContainer } from './input-container';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface SliderInputOptions {\n  /** Accessible label for the range slider */\n  sliderAriaLabel: string;\n  /** Accessible label for the numeric input */\n  inputAriaLabel: string;\n  /** Minimum value for the slider */\n  min: number;\n  /** Maximum value for the slider */\n  max: number;\n  /** Step increment for the slider */\n  step: number;\n  /** Input mode for the numeric input (default: \"decimal\") */\n  inputMode?: string;\n  /** Fixed width for the numeric input in pixels (default: 72) */\n  inputWidthPx?: number;\n}\n\nexport interface SliderInput {\n  /** Root container element */\n  root: HTMLDivElement;\n  /** Range slider element */\n  slider: HTMLInputElement;\n  /** Numeric input element */\n  input: HTMLInputElement;\n  /** Input container instance for advanced customization */\n  inputContainer: InputContainer;\n  /** Set disabled state for both controls */\n  setDisabled(disabled: boolean): void;\n  /** Set disabled state for slider only */\n  setSliderDisabled(disabled: boolean): void;\n  /** Set value for both controls */\n  setValue(value: number): void;\n  /** Set slider value only (without affecting input) */\n  setSliderValue(value: number): void;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a slider input component with synchronized slider and input\n */\nexport function createSliderInput(options: SliderInputOptions): SliderInput {\n  const {\n    sliderAriaLabel,\n    inputAriaLabel,\n    min,\n    max,\n    step,\n    inputMode = 'decimal',\n    inputWidthPx = 72,\n  } = options;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-slider-input';\n\n  // Range slider\n  const slider = document.createElement('input');\n  slider.type = 'range';\n  slider.className = 'we-slider-input__slider';\n  slider.min = String(min);\n  slider.max = String(max);\n  slider.step = String(step);\n  slider.value = String(min);\n  slider.setAttribute('aria-label', sliderAriaLabel);\n\n  /**\n   * Update the slider's progress color based on current value.\n   * Uses CSS custom property --progress for the gradient.\n   */\n  function updateSliderProgress(): void {\n    const value = parseFloat(slider.value);\n    const minVal = parseFloat(slider.min);\n    const maxVal = parseFloat(slider.max);\n    const percent = ((value - minVal) / (maxVal - minVal)) * 100;\n    slider.style.setProperty('--progress', `${percent}%`);\n  }\n\n  // Initialize progress\n  updateSliderProgress();\n\n  // Update progress on input\n  slider.addEventListener('input', updateSliderProgress);\n\n  // Numeric input using InputContainer\n  const inputContainer = createInputContainer({\n    ariaLabel: inputAriaLabel,\n    inputMode,\n    prefix: null,\n    suffix: null,\n    rootClassName: 'we-slider-input__number',\n  });\n  inputContainer.root.style.width = `${inputWidthPx}px`;\n  inputContainer.root.style.flex = '0 0 auto';\n\n  root.append(slider, inputContainer.root);\n\n  // Public methods\n  function setDisabled(disabled: boolean): void {\n    slider.disabled = disabled;\n    inputContainer.input.disabled = disabled;\n  }\n\n  function setSliderDisabled(disabled: boolean): void {\n    slider.disabled = disabled;\n  }\n\n  function setValue(value: number): void {\n    const stringValue = String(value);\n    slider.value = stringValue;\n    inputContainer.input.value = stringValue;\n    updateSliderProgress();\n  }\n\n  function setSliderValue(value: number): void {\n    slider.value = String(value);\n    updateSliderProgress();\n  }\n\n  return {\n    root,\n    slider,\n    input: inputContainer.input,\n    inputContainer,\n    setDisabled,\n    setSliderDisabled,\n    setValue,\n    setSliderValue,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components/token-pill.ts",
    "content": "/**\n * Token Pill Component (Phase 5.3)\n *\n * A compact pill UI for displaying a CSS custom property (var()) reference.\n * Used in ColorField and potentially other inputs to show token-bound values.\n *\n * Features:\n * - Displays token name with optional color swatch preview\n * - Click pill to open Token Picker\n * - Hover to reveal clear (×) button for detaching token\n * - Supports external leading element injection (e.g., ColorField swatch)\n *\n * Design reference: token.png and attr-ui.html:699-727\n */\n\nimport { Disposer } from '../../../utils/disposables';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\n// Link icon path (rotated 45° via CSS to indicate \"variable binding\")\nconst LINK_ICON_PATH =\n  'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m0-5.656' +\n  'a4 4 0 015.656 0l4 4a4 4 0 11-5.656 5.656l-1.1-1.102';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface TokenPillOptions {\n  /** Container element to mount the pill into */\n  container: HTMLElement;\n  /** Accessible label for the pill */\n  ariaLabel: string;\n  /** Token name to display (e.g., \"--color-primary\") */\n  tokenName: string;\n  /** Preview color for internal swatch (used when no leadingElement provided) */\n  previewColor?: string | null;\n  /** External leading element (e.g., ColorField swatch button) - overrides internal swatch */\n  leadingElement?: HTMLElement | null;\n  /** Whether the pill is disabled */\n  disabled?: boolean;\n  /** Callback when pill main area is clicked (typically opens Token Picker) */\n  onClick?: () => void;\n  /** Callback when clear button is clicked (detaches token) */\n  onClear?: () => void;\n}\n\nexport interface TokenPill {\n  /** Root element of the pill */\n  root: HTMLDivElement;\n  /** Update the token name display */\n  setTokenName(name: string): void;\n  /** Update the preview color (for internal swatch) */\n  setPreviewColor(color: string | null): void;\n  /** Set external leading element (null to use internal swatch) */\n  setLeadingElement(el: HTMLElement | null): void;\n  /** Set disabled state */\n  setDisabled(disabled: boolean): void;\n  /** Focus the pill main button */\n  focus(): void;\n  /** Cleanup and remove the pill */\n  dispose(): void;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a token pill component.\n */\nexport function createTokenPill(options: TokenPillOptions): TokenPill {\n  const {\n    container,\n    ariaLabel,\n    tokenName,\n    previewColor = null,\n    leadingElement = null,\n    disabled = false,\n    onClick,\n    onClear,\n  } = options;\n\n  const disposer = new Disposer();\n\n  // Internal state\n  let isDisabled = Boolean(disabled);\n  let currentTokenName = String(tokenName ?? '');\n  let currentPreviewColor = typeof previewColor === 'string' ? previewColor : null;\n  let currentLeadingElement: HTMLElement | null = leadingElement ?? null;\n\n  // ===========================================================================\n  // DOM Structure\n  // ===========================================================================\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-token-pill';\n  root.dataset.disabled = isDisabled ? 'true' : 'false';\n  root.setAttribute('role', 'group');\n  root.setAttribute('aria-label', ariaLabel);\n\n  // Leading slot (holds either external element or internal swatch)\n  const leadingSlot = document.createElement('div');\n  leadingSlot.className = 'we-token-pill__leading';\n\n  // Internal swatch (used when no external leading element)\n  const internalSwatch = document.createElement('div');\n  internalSwatch.className = 'we-token-pill__swatch';\n  internalSwatch.setAttribute('aria-hidden', 'true');\n\n  // Main button (click to open picker)\n  const mainBtn = document.createElement('button');\n  mainBtn.type = 'button';\n  mainBtn.className = 'we-token-pill__main';\n  mainBtn.setAttribute('aria-label', ariaLabel);\n  mainBtn.dataset.tooltip = 'Change token';\n\n  // Token name text\n  const nameEl = document.createElement('span');\n  nameEl.className = 'we-token-pill__name';\n\n  // Link icon (indicates variable binding)\n  const linkIcon = document.createElementNS(SVG_NS, 'svg');\n  linkIcon.setAttribute('viewBox', '0 0 24 24');\n  linkIcon.setAttribute('fill', 'none');\n  linkIcon.setAttribute('aria-hidden', 'true');\n  linkIcon.classList.add('we-token-pill__icon');\n\n  const iconPath = document.createElementNS(SVG_NS, 'path');\n  iconPath.setAttribute('d', LINK_ICON_PATH);\n  iconPath.setAttribute('stroke', 'currentColor');\n  iconPath.setAttribute('stroke-width', '2');\n  iconPath.setAttribute('stroke-linecap', 'round');\n  iconPath.setAttribute('stroke-linejoin', 'round');\n  linkIcon.append(iconPath);\n\n  mainBtn.append(nameEl, linkIcon);\n\n  // Clear button (detach token)\n  const clearBtn = document.createElement('button');\n  clearBtn.type = 'button';\n  clearBtn.className = 'we-token-pill__clear';\n  clearBtn.setAttribute('aria-label', 'Clear token');\n  clearBtn.dataset.tooltip = 'Clear token';\n  clearBtn.textContent = '×';\n\n  // Assemble structure\n  root.append(leadingSlot, mainBtn, clearBtn);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ===========================================================================\n  // Sync Functions\n  // ===========================================================================\n\n  /** Sync leading slot content (external element or internal swatch) */\n  function syncLeading(): void {\n    // Clear existing content\n    while (leadingSlot.firstChild) {\n      leadingSlot.removeChild(leadingSlot.firstChild);\n    }\n\n    if (currentLeadingElement) {\n      // Use external leading element\n      leadingSlot.append(currentLeadingElement);\n    } else {\n      // Use internal swatch with preview color\n      internalSwatch.style.backgroundColor = currentPreviewColor ?? '';\n      leadingSlot.append(internalSwatch);\n    }\n  }\n\n  /** Sync token name display */\n  function syncText(): void {\n    nameEl.textContent = currentTokenName;\n  }\n\n  /** Sync disabled state */\n  function syncDisabled(): void {\n    root.dataset.disabled = isDisabled ? 'true' : 'false';\n    mainBtn.disabled = isDisabled;\n    clearBtn.disabled = isDisabled;\n\n    // Also disable external leading element if it's a button\n    if (currentLeadingElement instanceof HTMLButtonElement) {\n      currentLeadingElement.disabled = isDisabled;\n    }\n  }\n\n  // ===========================================================================\n  // Event Handlers\n  // ===========================================================================\n\n  // Main button click -> open token picker\n  disposer.listen(mainBtn, 'click', (e: MouseEvent) => {\n    e.preventDefault();\n    if (isDisabled) return;\n    onClick?.();\n  });\n\n  // Clear button click -> detach token\n  disposer.listen(clearBtn, 'click', (e: MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    if (isDisabled) return;\n    onClear?.();\n  });\n\n  // Keyboard support on pill root\n  disposer.listen(root, 'keydown', (e: KeyboardEvent) => {\n    if (isDisabled) return;\n\n    // Backspace/Delete on pill -> clear token\n    if (e.key === 'Backspace' || e.key === 'Delete') {\n      // Use composedPath for Shadow DOM compatibility\n      const path = e.composedPath();\n      if (path.includes(mainBtn) || path.includes(root)) {\n        e.preventDefault();\n        onClear?.();\n      }\n    }\n  });\n\n  // ===========================================================================\n  // Initial Sync\n  // ===========================================================================\n\n  syncLeading();\n  syncText();\n  syncDisabled();\n\n  // ===========================================================================\n  // Public Interface\n  // ===========================================================================\n\n  return {\n    root,\n\n    setTokenName(name: string): void {\n      currentTokenName = String(name ?? '');\n      syncText();\n    },\n\n    setPreviewColor(color: string | null): void {\n      currentPreviewColor = typeof color === 'string' ? color : null;\n      // Only update if using internal swatch\n      if (!currentLeadingElement) {\n        internalSwatch.style.backgroundColor = currentPreviewColor ?? '';\n      }\n    },\n\n    setLeadingElement(el: HTMLElement | null): void {\n      currentLeadingElement = el ?? null;\n      syncLeading();\n      syncDisabled();\n    },\n\n    setDisabled(disabled: boolean): void {\n      isDisabled = Boolean(disabled);\n      syncDisabled();\n    },\n\n    focus(): void {\n      try {\n        mainBtn.focus();\n      } catch {\n        // Best-effort focus\n      }\n    },\n\n    dispose(): void {\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/components-tree.ts",
    "content": "/**\n * Components Tree (Phase 3.2)\n *\n * Displays DOM hierarchy for the selected element.\n * Shows:\n * - Ancestor path from document.body to selected element\n * - Direct children of selected element\n * - Highlights selected element in tree\n * - Click to select any visible element\n *\n * MVP Design:\n * - Simple tree structure\n * - No virtual scrolling (limit to reasonable depth)\n * - Compact display with tag#id.class format\n */\n\nimport { Disposer } from '../../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface ComponentsTreeOptions {\n  container: HTMLElement;\n  onSelect?: (element: Element) => void;\n}\n\nexport interface ComponentsTree {\n  setTarget(element: Element | null): void;\n  refresh(): void;\n  dispose(): void;\n}\n\ninterface TreeNode {\n  element: Element;\n  label: string;\n  depth: number;\n  isSelected: boolean;\n  isAncestor: boolean;\n  isChild: boolean;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst MAX_ANCESTORS = 10;\nconst MAX_CHILDREN = 20;\nconst MAX_CLASSES = 2;\nconst MAX_TEXT_LENGTH = 20;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Format element for display: tag#id.class1.class2\n */\nfunction formatElementLabel(element: Element): string {\n  const tag = element.tagName.toLowerCase();\n  const htmlEl = element as HTMLElement;\n\n  let label = tag;\n\n  // Add ID if present\n  const id = htmlEl.id?.trim();\n  if (id) {\n    label += `#${id}`;\n  }\n\n  // Add first few classes\n  const classes = Array.from(element.classList ?? [])\n    .slice(0, MAX_CLASSES)\n    .filter(Boolean);\n  if (classes.length > 0) {\n    label += `.${classes.join('.')}`;\n  }\n\n  // Add text hint for elements with short text content\n  if (!element.children.length) {\n    const text = element.textContent?.trim() ?? '';\n    if (text.length > 0 && text.length <= MAX_TEXT_LENGTH) {\n      label += ` \"${text}\"`;\n    } else if (text.length > MAX_TEXT_LENGTH) {\n      label += ` \"${text.slice(0, MAX_TEXT_LENGTH - 3)}...\"`;\n    }\n  }\n\n  return label;\n}\n\n/**\n * Get ancestor chain from body to element\n */\nfunction getAncestorChain(element: Element): Element[] {\n  const ancestors: Element[] = [];\n  let current: Element | null = element.parentElement;\n\n  while (current && ancestors.length < MAX_ANCESTORS) {\n    // Stop at body or html\n    if (current === document.body || current === document.documentElement) {\n      ancestors.unshift(current);\n      break;\n    }\n\n    // Skip shadow hosts for now (MVP)\n    if (current.shadowRoot) {\n      break;\n    }\n\n    ancestors.unshift(current);\n    current = current.parentElement;\n  }\n\n  return ancestors;\n}\n\n/**\n * Get direct children\n */\nfunction getDirectChildren(element: Element): Element[] {\n  return Array.from(element.children).slice(0, MAX_CHILDREN);\n}\n\n/**\n * Build tree nodes for display\n */\nfunction buildTreeNodes(target: Element | null): TreeNode[] {\n  if (!target || !target.isConnected) {\n    return [];\n  }\n\n  const nodes: TreeNode[] = [];\n  const ancestors = getAncestorChain(target);\n\n  // Add ancestors\n  for (let i = 0; i < ancestors.length; i++) {\n    nodes.push({\n      element: ancestors[i],\n      label: formatElementLabel(ancestors[i]),\n      depth: i,\n      isSelected: false,\n      isAncestor: true,\n      isChild: false,\n    });\n  }\n\n  // Add selected element\n  const selectedDepth = ancestors.length;\n  nodes.push({\n    element: target,\n    label: formatElementLabel(target),\n    depth: selectedDepth,\n    isSelected: true,\n    isAncestor: false,\n    isChild: false,\n  });\n\n  // Add children\n  const children = getDirectChildren(target);\n  for (const child of children) {\n    nodes.push({\n      element: child,\n      label: formatElementLabel(child),\n      depth: selectedDepth + 1,\n      isSelected: false,\n      isAncestor: false,\n      isChild: true,\n    });\n  }\n\n  return nodes;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createComponentsTree(options: ComponentsTreeOptions): ComponentsTree {\n  const { container, onSelect } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-tree';\n\n  // Empty state\n  const emptyState = document.createElement('div');\n  emptyState.className = 'we-tree-empty';\n  emptyState.textContent = 'Select an element to view its DOM hierarchy.';\n\n  // Tree list\n  const treeList = document.createElement('div');\n  treeList.className = 'we-tree-list';\n  treeList.setAttribute('role', 'tree');\n\n  root.append(emptyState, treeList);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Render\n  // ==========================================================================\n\n  function render(): void {\n    const nodes = buildTreeNodes(currentTarget);\n\n    // Update visibility\n    const hasTarget = nodes.length > 0;\n    emptyState.hidden = hasTarget;\n    treeList.hidden = !hasTarget;\n\n    if (!hasTarget) {\n      treeList.innerHTML = '';\n      return;\n    }\n\n    // Build tree items\n    treeList.innerHTML = '';\n\n    for (const node of nodes) {\n      const item = document.createElement('div');\n      item.className = 'we-tree-item';\n      item.setAttribute('role', 'treeitem');\n      item.style.paddingLeft = `${8 + node.depth * 16}px`;\n\n      if (node.isSelected) {\n        item.classList.add('we-tree-item--selected');\n        item.setAttribute('aria-selected', 'true');\n      }\n\n      if (node.isAncestor) {\n        item.classList.add('we-tree-item--ancestor');\n      }\n\n      if (node.isChild) {\n        item.classList.add('we-tree-item--child');\n      }\n\n      // Indent marker\n      if (node.depth > 0) {\n        const indent = document.createElement('span');\n        indent.className = 'we-tree-indent';\n        indent.textContent = node.isChild ? '└' : '├';\n        item.append(indent);\n      }\n\n      // Tag icon\n      const icon = document.createElement('span');\n      icon.className = 'we-tree-icon';\n      icon.textContent = '◇';\n      item.append(icon);\n\n      // Label\n      const label = document.createElement('span');\n      label.className = 'we-tree-label';\n      label.textContent = node.label;\n      item.append(label);\n\n      // Click handler\n      disposer.listen(item, 'click', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        if (node.element.isConnected && onSelect) {\n          onSelect(node.element);\n        }\n      });\n\n      // Hover effect - highlight element\n      disposer.listen(item, 'mouseenter', () => {\n        if (node.element.isConnected) {\n          node.element.classList.add('we-tree-hover-highlight');\n        }\n      });\n\n      disposer.listen(item, 'mouseleave', () => {\n        node.element.classList.remove('we-tree-hover-highlight');\n      });\n\n      treeList.append(item);\n    }\n  }\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    currentTarget = element;\n    render();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    render();\n  }\n\n  function dispose(): void {\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  // Initial render\n  render();\n\n  return {\n    setTarget,\n    refresh,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/appearance-control.ts",
    "content": "/**\n * Appearance Control\n *\n * Edits general appearance styles:\n * - overflow (select)\n * - box-sizing (select)\n * - opacity (input)\n *\n * Note: Border and Background controls have been split into separate controls\n * (border-control.ts and background-control.ts) for better organization.\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport { wireNumberStepping } from './number-stepping';\nimport type { DesignControl } from '../types';\nimport { createSliderInput, type SliderInput } from '../components/slider-input';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst OVERFLOW_VALUES = ['visible', 'hidden', 'scroll', 'auto'] as const;\nconst BOX_SIZING_VALUES = ['content-box', 'border-box'] as const;\n\n// =============================================================================\n// Types\n// =============================================================================\n\ntype AppearanceProperty = 'overflow' | 'box-sizing' | 'opacity';\n\ninterface OpacityFieldState {\n  kind: 'opacity';\n  property: 'opacity';\n  control: SliderInput;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface SelectFieldState {\n  kind: 'select';\n  property: Exclude<AppearanceProperty, 'opacity'>;\n  element: HTMLSelectElement;\n  handle: StyleTransactionHandle | null;\n}\n\ntype FieldState = OpacityFieldState | SelectFieldState;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction normalizeOpacity(raw: string): string {\n  return raw.trim();\n}\n\n/** Regex to match valid numeric values for opacity (including decimal) */\nconst OPACITY_NUMBER_REGEX = /^-?(?:(?:\\d+\\.\\d+)|(?:\\d+\\.)|(?:\\d+)|(?:\\.\\d+))$/;\n\n/**\n * Clamp opacity value to valid range [0, 1]\n */\nfunction clampOpacity(value: number): number {\n  if (!Number.isFinite(value)) return 1;\n  const clamped = Math.min(1, Math.max(0, value));\n  // Handle negative zero\n  return Object.is(clamped, -0) ? 0 : clamped;\n}\n\n/**\n * Parse a string to a numeric opacity value\n * Returns null if the string is not a valid number\n */\nfunction parseOpacityNumber(raw: string): number | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n  if (!OPACITY_NUMBER_REGEX.test(trimmed)) return null;\n  // Handle trailing decimal point (e.g., \"0.\")\n  const normalized = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;\n  const value = Number(normalized);\n  if (!Number.isFinite(value)) return null;\n  return value;\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface AppearanceControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n}\n\nexport function createAppearanceControl(options: AppearanceControlOptions): DesignControl {\n  const { container, transactionManager } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ===========================================================================\n  // DOM Helpers\n  // ===========================================================================\n\n  function createSelectRow(\n    labelText: string,\n    ariaLabel: string,\n    values: readonly string[],\n  ): { row: HTMLDivElement; select: HTMLSelectElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n    const select = document.createElement('select');\n    select.className = 'we-select';\n    select.setAttribute('aria-label', ariaLabel);\n    for (const v of values) {\n      const opt = document.createElement('option');\n      opt.value = v;\n      opt.textContent = v;\n      select.appendChild(opt);\n    }\n    row.append(label, select);\n    return { row, select };\n  }\n\n  // ===========================================================================\n  // Build DOM\n  // ===========================================================================\n\n  const { row: overflowRow, select: overflowSelect } = createSelectRow(\n    'Overflow',\n    'Overflow',\n    OVERFLOW_VALUES,\n  );\n  const { row: boxSizingRow, select: boxSizingSelect } = createSelectRow(\n    'Box Sizing',\n    'Box Sizing',\n    BOX_SIZING_VALUES,\n  );\n\n  // ---------------------------------------------------------------------------\n  // Opacity row with slider + input\n  // ---------------------------------------------------------------------------\n  const opacityRow = document.createElement('div');\n  opacityRow.className = 'we-field';\n\n  const opacityLabel = document.createElement('span');\n  opacityLabel.className = 'we-field-label';\n  opacityLabel.textContent = 'Opacity';\n\n  const opacityMount = document.createElement('div');\n  opacityMount.className = 'we-field-content';\n\n  opacityRow.append(opacityLabel, opacityMount);\n\n  const opacityControl = createSliderInput({\n    sliderAriaLabel: 'Opacity slider',\n    inputAriaLabel: 'Opacity value',\n    min: 0,\n    max: 1,\n    step: 0.01,\n    inputMode: 'decimal',\n    inputWidthPx: 72,\n  });\n  opacityMount.append(opacityControl.root);\n\n  wireNumberStepping(disposer, opacityControl.input, {\n    mode: 'number',\n    min: 0,\n    max: 1,\n    step: 0.01,\n    shiftStep: 0.1,\n    altStep: 0.001,\n  });\n\n  root.append(overflowRow, boxSizingRow, opacityRow);\n  container.appendChild(root);\n  disposer.add(() => root.remove());\n\n  // ===========================================================================\n  // Field State Map\n  // ===========================================================================\n\n  const fields: Record<AppearanceProperty, FieldState> = {\n    overflow: { kind: 'select', property: 'overflow', element: overflowSelect, handle: null },\n    'box-sizing': {\n      kind: 'select',\n      property: 'box-sizing',\n      element: boxSizingSelect,\n      handle: null,\n    },\n    opacity: { kind: 'opacity', property: 'opacity', control: opacityControl, handle: null },\n  };\n\n  const PROPS: readonly AppearanceProperty[] = ['overflow', 'box-sizing', 'opacity'];\n\n  // ===========================================================================\n  // Transaction Management\n  // ===========================================================================\n\n  function beginTransaction(property: AppearanceProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: AppearanceProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: AppearanceProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of PROPS) commitTransaction(p);\n  }\n\n  // ===========================================================================\n  // Field Synchronization\n  // ===========================================================================\n\n  function syncField(property: AppearanceProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n\n    if (field.kind === 'opacity') {\n      const { slider, input } = field.control;\n\n      if (!target || !target.isConnected) {\n        field.control.setDisabled(true);\n        slider.value = '0';\n        input.value = '';\n        input.placeholder = '';\n        return;\n      }\n\n      field.control.setDisabled(false);\n\n      const isEditing = field.handle !== null || isFieldFocused(slider) || isFieldFocused(input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      const displayValue = inlineValue || computedValue;\n\n      input.value = displayValue;\n      input.placeholder = '';\n\n      const inlineNumeric = parseOpacityNumber(displayValue);\n      const computedNumeric = parseOpacityNumber(computedValue);\n\n      // If inline value is non-numeric (e.g., var(...)), keep the text input\n      // but disable the slider (it cannot represent non-numeric values)\n      if (inlineValue && inlineNumeric === null) {\n        field.control.setSliderDisabled(true);\n        if (computedNumeric !== null) {\n          field.control.setSliderValue(clampOpacity(computedNumeric));\n        }\n        return;\n      }\n\n      const numeric = inlineNumeric ?? computedNumeric ?? 1;\n      field.control.setSliderDisabled(false);\n      field.control.setSliderValue(clampOpacity(numeric));\n    } else {\n      // Handle select field (overflow / box-sizing)\n      const select = field.element;\n\n      if (!target || !target.isConnected) {\n        select.disabled = true;\n        return;\n      }\n\n      select.disabled = false;\n\n      const isEditing = field.handle !== null || isFieldFocused(select);\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, property);\n      const computed = readComputedValue(target, property);\n      const val = inline || computed;\n      const hasOption = Array.from(select.options).some((o) => o.value === val);\n      select.value = hasOption ? val : (select.options[0]?.value ?? '');\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const p of PROPS) syncField(p);\n  }\n\n  // ===========================================================================\n  // Event Wiring\n  // ===========================================================================\n\n  function wireOpacity(): void {\n    const field = fields.opacity;\n    if (field.kind !== 'opacity') return;\n\n    const { slider, input } = field.control;\n\n    /**\n     * Commit opacity value with optional clamping for numeric values.\n     * Non-numeric values (like CSS variables) are preserved as-is.\n     */\n    const commit = () => {\n      // Normalize and clamp numeric values before committing\n      const raw = normalizeOpacity(input.value);\n      const numeric = parseOpacityNumber(raw);\n      if (numeric !== null) {\n        const clamped = clampOpacity(numeric);\n        const clampedStr = String(clamped);\n        // Update both input and style if value was clamped\n        if (raw !== clampedStr) {\n          input.value = clampedStr;\n          const handle = field.handle;\n          if (handle) handle.set(clampedStr);\n        }\n      }\n      commitTransaction('opacity');\n      syncAllFields();\n    };\n\n    // Slider events\n    disposer.listen(slider, 'input', () => {\n      if (slider.disabled) return;\n      input.value = slider.value;\n      const handle = beginTransaction('opacity');\n      if (handle) handle.set(normalizeOpacity(slider.value));\n    });\n\n    disposer.listen(slider, 'change', commit);\n    disposer.listen(slider, 'blur', commit);\n\n    disposer.listen(slider, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction('opacity');\n        syncAllFields();\n        slider.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction('opacity');\n        syncField('opacity', true);\n      }\n    });\n\n    // Input events\n    disposer.listen(input, 'input', () => {\n      const raw = normalizeOpacity(input.value);\n      const handle = beginTransaction('opacity');\n      if (handle) handle.set(raw);\n\n      const numeric = parseOpacityNumber(raw);\n      if (numeric === null) {\n        // Empty keeps slider enabled; non-numeric disables the slider\n        field.control.setSliderDisabled(raw.length > 0);\n        return;\n      }\n\n      field.control.setSliderDisabled(false);\n      field.control.setSliderValue(clampOpacity(numeric));\n    });\n\n    disposer.listen(input, 'blur', commit);\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction('opacity');\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction('opacity');\n        syncField('opacity', true);\n      }\n    });\n  }\n\n  function wireSelect(property: Exclude<AppearanceProperty, 'opacity'>): void {\n    const field = fields[property];\n    if (field.kind !== 'select') return;\n\n    const select = field.element;\n\n    const preview = () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(select.value);\n    };\n\n    disposer.listen(select, 'input', preview);\n    disposer.listen(select, 'change', preview);\n    disposer.listen(select, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  wireSelect('overflow');\n  wireSelect('box-sizing');\n  wireOpacity();\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/background-control.ts",
    "content": "/**\n * Background Control\n *\n * Edits inline background styles:\n * - type selector (solid/gradient/image)\n * - solid: background-color picker\n * - gradient: gradient editor (reuses gradient-control.ts)\n * - image: background-image URL input\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignTokensService } from '../../../core/design-tokens';\nimport { createColorField, type ColorField } from './color-field';\nimport { createGradientControl } from './gradient-control';\nimport type { DesignControl } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst BACKGROUND_TYPE_VALUES = ['solid', 'gradient', 'image'] as const;\ntype BackgroundType = (typeof BACKGROUND_TYPE_VALUES)[number];\n\n// =============================================================================\n// Types\n// =============================================================================\n\ntype BackgroundProperty = 'background-color' | 'background-image';\n\ninterface TextFieldState {\n  kind: 'text';\n  property: BackgroundProperty;\n  element: HTMLInputElement;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface ColorFieldState {\n  kind: 'color';\n  property: BackgroundProperty;\n  field: ColorField;\n  handle: StyleTransactionHandle | null;\n}\n\ntype FieldState = TextFieldState | ColorFieldState;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction inferBackgroundType(bgImage: string): BackgroundType {\n  const trimmed = bgImage.trim().toLowerCase();\n  if (!trimmed || trimmed === 'none') return 'solid';\n  if (/\\b(?:linear|radial|conic)-gradient\\s*\\(/i.test(trimmed)) return 'gradient';\n  if (/\\burl\\s*\\(/i.test(trimmed)) return 'image';\n  return 'solid';\n}\n\nfunction extractUrlFromBackgroundImage(raw: string): string {\n  const match = raw.trim().match(/\\burl\\(\\s*(['\"]?)(.*?)\\1\\s*\\)/i);\n  return match?.[2]?.trim() ?? '';\n}\n\nfunction normalizeBackgroundImageUrl(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return '';\n  if (/^none$/i.test(trimmed)) return 'none';\n  if (/^url\\s*\\(/i.test(trimmed)) return trimmed;\n  const escaped = trimmed.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n  return `url(\"${escaped}\")`;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface BackgroundControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n  tokensService?: DesignTokensService;\n}\n\nexport function createBackgroundControl(options: BackgroundControlOptions): DesignControl {\n  const { container, transactionManager, tokensService } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n  let currentBackgroundType: BackgroundType = 'solid';\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ===========================================================================\n  // DOM Helpers\n  // ===========================================================================\n\n  function createInputRow(\n    labelText: string,\n    ariaLabel: string,\n  ): { row: HTMLDivElement; input: HTMLInputElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'we-input';\n    input.autocomplete = 'off';\n    input.setAttribute('aria-label', ariaLabel);\n    row.append(label, input);\n    return { row, input };\n  }\n\n  function createColorRow(labelText: string): {\n    row: HTMLDivElement;\n    colorFieldContainer: HTMLDivElement;\n  } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n    const colorFieldContainer = document.createElement('div');\n    colorFieldContainer.style.flex = '1';\n    colorFieldContainer.style.minWidth = '0';\n    row.append(label, colorFieldContainer);\n    return { row, colorFieldContainer };\n  }\n\n  // ===========================================================================\n  // Build DOM\n  // ===========================================================================\n\n  // Type selector\n  const bgTypeRow = document.createElement('div');\n  bgTypeRow.className = 'we-field';\n  const bgTypeLabel = document.createElement('span');\n  bgTypeLabel.className = 'we-field-label';\n  bgTypeLabel.textContent = 'Type';\n  const bgTypeSelect = document.createElement('select');\n  bgTypeSelect.className = 'we-select';\n  bgTypeSelect.setAttribute('aria-label', 'Background Type');\n  for (const v of BACKGROUND_TYPE_VALUES) {\n    const opt = document.createElement('option');\n    opt.value = v;\n    opt.textContent = v.charAt(0).toUpperCase() + v.slice(1);\n    bgTypeSelect.appendChild(opt);\n  }\n  bgTypeRow.append(bgTypeLabel, bgTypeSelect);\n\n  // Solid color row\n  const { row: bgColorRow, colorFieldContainer: bgColorContainer } = createColorRow('Color');\n\n  // Gradient mount\n  const bgGradientMount = document.createElement('div');\n\n  // Image URL row\n  const { row: bgImageRow, input: bgImageInput } = createInputRow('URL', 'Background Image URL');\n  bgImageInput.placeholder = 'https://...';\n  bgImageInput.spellcheck = false;\n\n  root.append(bgTypeRow, bgColorRow, bgGradientMount, bgImageRow);\n  container.appendChild(root);\n  disposer.add(() => root.remove());\n\n  // ===========================================================================\n  // Gradient Control\n  // ===========================================================================\n\n  const gradientControl = createGradientControl({\n    container: bgGradientMount,\n    transactionManager,\n    tokensService,\n  });\n  disposer.add(() => gradientControl.dispose());\n\n  // ===========================================================================\n  // Color Field\n  // ===========================================================================\n\n  const bgColorField = createColorField({\n    container: bgColorContainer,\n    ariaLabel: 'Background Color',\n    tokensService,\n    getTokenTarget: () => currentTarget,\n    onInput: (value) => {\n      const handle = beginTransaction('background-color');\n      if (handle) handle.set(value);\n    },\n    onCommit: () => {\n      commitTransaction('background-color');\n      syncAllFields();\n    },\n    onCancel: () => {\n      rollbackTransaction('background-color');\n      syncField('background-color', true);\n    },\n  });\n  disposer.add(() => bgColorField.dispose());\n\n  // ===========================================================================\n  // Field State Map\n  // ===========================================================================\n\n  const fields: Record<BackgroundProperty, FieldState> = {\n    'background-color': {\n      kind: 'color',\n      property: 'background-color',\n      field: bgColorField,\n      handle: null,\n    },\n    'background-image': {\n      kind: 'text',\n      property: 'background-image',\n      element: bgImageInput,\n      handle: null,\n    },\n  };\n\n  const PROPS: readonly BackgroundProperty[] = ['background-color', 'background-image'];\n\n  // ===========================================================================\n  // Transaction Management\n  // ===========================================================================\n\n  function beginTransaction(property: BackgroundProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: BackgroundProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: BackgroundProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of PROPS) commitTransaction(p);\n  }\n\n  // ===========================================================================\n  // Background Type Visibility\n  // ===========================================================================\n\n  function updateBackgroundVisibility(): void {\n    bgColorRow.hidden = currentBackgroundType !== 'solid';\n    bgGradientMount.hidden = currentBackgroundType !== 'gradient';\n    bgImageRow.hidden = currentBackgroundType !== 'image';\n  }\n\n  function setBackgroundType(type: BackgroundType): void {\n    const target = currentTarget;\n    currentBackgroundType = type;\n    bgTypeSelect.value = type;\n    updateBackgroundVisibility();\n\n    if (!target || !target.isConnected) return;\n\n    // Clear conflicting background-image when switching to solid\n    if (type === 'solid') {\n      commitTransaction('background-image');\n      const handle = transactionManager.beginStyle(target, 'background-image');\n      if (handle) {\n        handle.set('none');\n        handle.commit({ merge: true });\n      }\n    }\n  }\n\n  disposer.listen(bgTypeSelect, 'change', () => {\n    const type = bgTypeSelect.value as BackgroundType;\n    setBackgroundType(type);\n    gradientControl.refresh();\n    syncAllFields();\n  });\n\n  // ===========================================================================\n  // Field Synchronization\n  // ===========================================================================\n\n  function syncField(property: BackgroundProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n\n    if (field.kind === 'text') {\n      const input = field.element;\n\n      if (!target || !target.isConnected) {\n        input.disabled = true;\n        input.value = '';\n        input.placeholder = '';\n        return;\n      }\n\n      input.disabled = false;\n\n      const isEditing = field.handle !== null || isFieldFocused(input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      const displayValue = inlineValue || computedValue;\n\n      if (property === 'background-image') {\n        input.value = extractUrlFromBackgroundImage(displayValue);\n      } else {\n        input.value = displayValue;\n      }\n      input.placeholder = '';\n    } else {\n      const colorField = field.field;\n\n      if (!target || !target.isConnected) {\n        colorField.setDisabled(true);\n        colorField.setValue('');\n        colorField.setPlaceholder('');\n        return;\n      }\n\n      colorField.setDisabled(false);\n\n      const isEditing = field.handle !== null || colorField.isFocused();\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      if (inlineValue) {\n        colorField.setValue(inlineValue);\n        colorField.setPlaceholder(/\\bvar\\s*\\(/i.test(inlineValue) ? computedValue : '');\n      } else {\n        colorField.setValue(computedValue);\n        colorField.setPlaceholder('');\n      }\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const p of PROPS) syncField(p);\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n    bgTypeSelect.disabled = !hasTarget;\n    updateBackgroundVisibility();\n  }\n\n  // ===========================================================================\n  // Event Wiring\n  // ===========================================================================\n\n  function wireTextInput(property: BackgroundProperty): void {\n    const field = fields[property];\n    if (field.kind !== 'text') return;\n\n    const input = field.element;\n    const normalize =\n      property === 'background-image' ? normalizeBackgroundImageUrl : (v: string) => v.trim();\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(normalize(input.value));\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  wireTextInput('background-image');\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n\n    if (element && element.isConnected) {\n      const bgImage =\n        readInlineValue(element, 'background-image') ||\n        readComputedValue(element, 'background-image');\n      currentBackgroundType = inferBackgroundType(bgImage);\n      bgTypeSelect.value = currentBackgroundType;\n    } else {\n      currentBackgroundType = 'solid';\n      bgTypeSelect.value = 'solid';\n    }\n\n    gradientControl.setTarget(element);\n    updateBackgroundVisibility();\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    gradientControl.refresh();\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  updateBackgroundVisibility();\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/border-control.ts",
    "content": "/**\n * Border Control\n *\n * Edits inline border styles:\n * - edge selector (all/top/right/bottom/left)\n * - border-width (input)\n * - border-style (select: solid/dashed/dotted/none)\n * - border-color (color picker)\n * - border-radius (unified + per-corner editing)\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type {\n  MultiStyleTransactionHandle,\n  StyleTransactionHandle,\n  TransactionManager,\n} from '../../../core/transaction-manager';\nimport type { DesignTokensService } from '../../../core/design-tokens';\nimport { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { createColorField, type ColorField } from './color-field';\nimport { createGradientControl } from './gradient-control';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\nimport type { DesignControl } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\nconst BORDER_STYLE_VALUES = ['solid', 'dashed', 'dotted', 'none'] as const;\n\n/** Color type for border: solid uses border-color, gradient uses border-image-source */\nconst BORDER_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const;\ntype BorderColorType = (typeof BORDER_COLOR_TYPE_VALUES)[number];\n\nconst BORDER_EDGE_VALUES = ['all', 'top', 'right', 'bottom', 'left'] as const;\ntype BorderEdge = (typeof BORDER_EDGE_VALUES)[number];\n\nconst BORDER_RADIUS_CORNERS = ['top-left', 'top-right', 'bottom-right', 'bottom-left'] as const;\ntype BorderRadiusCorner = (typeof BORDER_RADIUS_CORNERS)[number];\n\nconst BORDER_RADIUS_CORNER_PROPERTIES: Record<BorderRadiusCorner, string> = {\n  'top-left': 'border-top-left-radius',\n  'top-right': 'border-top-right-radius',\n  'bottom-right': 'border-bottom-right-radius',\n  'bottom-left': 'border-bottom-left-radius',\n};\n\nconst BORDER_RADIUS_TRANSACTION_PROPERTIES = [\n  'border-radius',\n  'border-top-left-radius',\n  'border-top-right-radius',\n  'border-bottom-right-radius',\n  'border-bottom-left-radius',\n] as const;\n\n// =============================================================================\n// Types\n// =============================================================================\n\ntype BorderProperty = 'border-width' | 'border-style' | 'border-color' | 'border-radius';\n\ninterface TextFieldState {\n  kind: 'text';\n  property: BorderProperty;\n  element: HTMLInputElement;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface SelectFieldState {\n  kind: 'select';\n  property: BorderProperty;\n  element: HTMLSelectElement;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface ColorFieldState {\n  kind: 'color';\n  property: BorderProperty;\n  field: ColorField;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface BorderRadiusFieldState {\n  kind: 'border-radius';\n  property: 'border-radius';\n  root: HTMLDivElement;\n  unified: InputContainer;\n  toggleButton: HTMLButtonElement;\n  cornersGrid: HTMLDivElement;\n  corners: Record<BorderRadiusCorner, InputContainer>;\n  handle: MultiStyleTransactionHandle | null;\n  expanded: boolean;\n  mode: 'unified' | 'corners' | null;\n  cornersMaterialized: boolean;\n}\n\ntype FieldState = TextFieldState | SelectFieldState | ColorFieldState | BorderRadiusFieldState;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Infer border color type from border-image-source value.\n * Returns 'gradient' if a gradient is detected, 'solid' otherwise.\n */\nfunction inferBorderColorType(borderImageSource: string): BorderColorType {\n  const trimmed = borderImageSource.trim().toLowerCase();\n  if (!trimmed || trimmed === 'none') return 'solid';\n  if (/\\b(?:linear|radial|conic)-gradient\\s*\\(/i.test(trimmed)) return 'gradient';\n  return 'solid';\n}\n\nfunction createBorderEdgeIcon(edge: BorderEdge): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n\n  const outline = document.createElementNS(SVG_NS, 'rect');\n  outline.setAttribute('x', '3.5');\n  outline.setAttribute('y', '3.5');\n  outline.setAttribute('width', '8');\n  outline.setAttribute('height', '8');\n  outline.setAttribute('stroke', 'currentColor');\n  outline.setAttribute('stroke-width', '1');\n  outline.setAttribute('opacity', '0.4');\n  svg.appendChild(outline);\n\n  const highlight = document.createElementNS(SVG_NS, 'path');\n  highlight.setAttribute('stroke', 'currentColor');\n  highlight.setAttribute('stroke-width', '2');\n  highlight.setAttribute('stroke-linecap', 'round');\n\n  switch (edge) {\n    case 'all':\n      highlight.setAttribute('d', 'M3.5 3.5h8v8h-8z');\n      break;\n    case 'top':\n      highlight.setAttribute('d', 'M3.5 3.5h8');\n      break;\n    case 'right':\n      highlight.setAttribute('d', 'M11.5 3.5v8');\n      break;\n    case 'bottom':\n      highlight.setAttribute('d', 'M3.5 11.5h8');\n      break;\n    case 'left':\n      highlight.setAttribute('d', 'M3.5 3.5v8');\n      break;\n  }\n\n  svg.appendChild(highlight);\n  return svg;\n}\n\nfunction createEditCornersIcon(): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '1.5');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n  path.setAttribute('d', 'M4 6V4H6 M9 4H11V6 M11 9V11H9 M6 11H4V9');\n  svg.appendChild(path);\n\n  return svg;\n}\n\nfunction createCornerIcon(corner: BorderRadiusCorner): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '1.5');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n\n  switch (corner) {\n    case 'top-left':\n      path.setAttribute('d', 'M11 4H6Q4 4 4 6V11');\n      break;\n    case 'top-right':\n      path.setAttribute('d', 'M4 4H9Q11 4 11 6V11');\n      break;\n    case 'bottom-right':\n      path.setAttribute('d', 'M11 4V9Q11 11 9 11H4');\n      break;\n    case 'bottom-left':\n      path.setAttribute('d', 'M4 4V9Q4 11 6 11H11');\n      break;\n  }\n\n  svg.appendChild(path);\n  return svg;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface BorderControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n  tokensService?: DesignTokensService;\n}\n\nexport function createBorderControl(options: BorderControlOptions): DesignControl {\n  const { container, transactionManager, tokensService } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n  let currentBorderEdge: BorderEdge = 'all';\n  let currentColorType: BorderColorType = 'solid';\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ===========================================================================\n  // DOM Helpers\n  // ===========================================================================\n\n  function createSelectRow(\n    labelText: string,\n    ariaLabel: string,\n    values: readonly string[],\n  ): { row: HTMLDivElement; select: HTMLSelectElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n    const select = document.createElement('select');\n    select.className = 'we-select';\n    select.setAttribute('aria-label', ariaLabel);\n    for (const v of values) {\n      const opt = document.createElement('option');\n      opt.value = v;\n      opt.textContent = v;\n      select.appendChild(opt);\n    }\n    row.append(label, select);\n    return { row, select };\n  }\n\n  function createColorRow(labelText: string): {\n    row: HTMLDivElement;\n    colorFieldContainer: HTMLDivElement;\n  } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n    const colorFieldContainer = document.createElement('div');\n    colorFieldContainer.style.flex = '1';\n    colorFieldContainer.style.minWidth = '0';\n    row.append(label, colorFieldContainer);\n    return { row, colorFieldContainer };\n  }\n\n  // ===========================================================================\n  // Build DOM\n  // ===========================================================================\n\n  // Edge selector row\n  const borderEdgeRow = document.createElement('div');\n  borderEdgeRow.className = 'we-field';\n  const borderEdgeLabel = document.createElement('span');\n  borderEdgeLabel.className = 'we-field-label';\n  borderEdgeLabel.textContent = 'Edge';\n  const borderEdgeMount = document.createElement('div');\n  borderEdgeMount.style.flex = '1';\n  borderEdgeRow.append(borderEdgeLabel, borderEdgeMount);\n\n  // Border Width with InputContainer\n  const borderWidthRow = document.createElement('div');\n  borderWidthRow.className = 'we-field';\n  const borderWidthLabel = document.createElement('span');\n  borderWidthLabel.className = 'we-field-label';\n  borderWidthLabel.textContent = 'Width';\n  const borderWidthContainer = createInputContainer({\n    ariaLabel: 'Border Width',\n    inputMode: 'decimal',\n    prefix: null,\n    suffix: 'px',\n  });\n  borderWidthRow.append(borderWidthLabel, borderWidthContainer.root);\n  const borderWidthInput = borderWidthContainer.input;\n\n  const { row: borderStyleRow, select: borderStyleSelect } = createSelectRow(\n    'Style',\n    'Border Style',\n    BORDER_STYLE_VALUES,\n  );\n\n  // Color Type selector (solid/gradient)\n  const { row: colorTypeRow, select: colorTypeSelect } = createSelectRow(\n    'Type',\n    'Border Color Type',\n    BORDER_COLOR_TYPE_VALUES,\n  );\n\n  // Solid color row\n  const { row: borderColorRow, colorFieldContainer: borderColorContainer } =\n    createColorRow('Color');\n\n  // Gradient mount for border-image-source\n  const borderGradientMount = document.createElement('div');\n\n  // Border Radius (unified + per-corner editing)\n  const borderRadiusRow = document.createElement('div');\n  borderRadiusRow.className = 'we-field';\n\n  const borderRadiusLabel = document.createElement('span');\n  borderRadiusLabel.className = 'we-field-label';\n  borderRadiusLabel.textContent = 'Radius';\n\n  const borderRadiusControl = document.createElement('div');\n  borderRadiusControl.className = 'we-radius-control';\n\n  const borderRadiusUnifiedRow = document.createElement('div');\n  borderRadiusUnifiedRow.className = 'we-field-row';\n\n  const borderRadiusUnified = createInputContainer({\n    ariaLabel: 'Border Radius',\n    inputMode: 'decimal',\n    prefix: null,\n    suffix: 'px',\n  });\n  borderRadiusUnified.root.style.flex = '1';\n\n  const borderRadiusToggleButton = document.createElement('button');\n  borderRadiusToggleButton.type = 'button';\n  borderRadiusToggleButton.className = 'we-toggle-btn';\n  borderRadiusToggleButton.setAttribute('aria-label', 'Edit corners');\n  borderRadiusToggleButton.setAttribute('aria-pressed', 'false');\n  borderRadiusToggleButton.dataset.tooltip = 'Edit corners';\n  borderRadiusToggleButton.append(createEditCornersIcon());\n\n  borderRadiusUnifiedRow.append(borderRadiusUnified.root, borderRadiusToggleButton);\n\n  const borderRadiusCornersGrid = document.createElement('div');\n  borderRadiusCornersGrid.className = 'we-radius-corners-grid';\n  borderRadiusCornersGrid.hidden = true;\n\n  const borderRadiusCorners: Record<BorderRadiusCorner, InputContainer> = {\n    'top-left': createInputContainer({\n      ariaLabel: 'Top-left radius',\n      inputMode: 'decimal',\n      prefix: createCornerIcon('top-left'),\n      suffix: 'px',\n    }),\n    'top-right': createInputContainer({\n      ariaLabel: 'Top-right radius',\n      inputMode: 'decimal',\n      prefix: createCornerIcon('top-right'),\n      suffix: 'px',\n    }),\n    'bottom-left': createInputContainer({\n      ariaLabel: 'Bottom-left radius',\n      inputMode: 'decimal',\n      prefix: createCornerIcon('bottom-left'),\n      suffix: 'px',\n    }),\n    'bottom-right': createInputContainer({\n      ariaLabel: 'Bottom-right radius',\n      inputMode: 'decimal',\n      prefix: createCornerIcon('bottom-right'),\n      suffix: 'px',\n    }),\n  };\n\n  borderRadiusCornersGrid.append(\n    borderRadiusCorners['top-left'].root,\n    borderRadiusCorners['top-right'].root,\n    borderRadiusCorners['bottom-left'].root,\n    borderRadiusCorners['bottom-right'].root,\n  );\n\n  // Keep corners grid separate from the unified row for full-width display when expanded\n  borderRadiusControl.append(borderRadiusUnifiedRow);\n  borderRadiusRow.append(borderRadiusLabel, borderRadiusControl);\n\n  const borderRadiusField: BorderRadiusFieldState = {\n    kind: 'border-radius',\n    property: 'border-radius',\n    root: borderRadiusRow,\n    unified: borderRadiusUnified,\n    toggleButton: borderRadiusToggleButton,\n    cornersGrid: borderRadiusCornersGrid,\n    corners: borderRadiusCorners,\n    handle: null,\n    expanded: false,\n    mode: null,\n    cornersMaterialized: false,\n  };\n\n  // Create combined row for Width and Radius\n  const widthAndRadiusRow = document.createElement('div');\n  widthAndRadiusRow.className = 'we-field-row';\n  borderWidthRow.style.flex = '1';\n  borderWidthRow.style.minWidth = '0';\n  borderRadiusRow.style.flex = '1';\n  borderRadiusRow.style.minWidth = '0';\n  widthAndRadiusRow.append(borderWidthRow, borderRadiusRow);\n\n  wireNumberStepping(disposer, borderWidthInput, { mode: 'css-length' });\n  wireNumberStepping(disposer, borderRadiusUnified.input, { mode: 'css-length' });\n  for (const corner of BORDER_RADIUS_CORNERS) {\n    wireNumberStepping(disposer, borderRadiusCorners[corner].input, { mode: 'css-length' });\n  }\n\n  // borderRadiusCornersGrid placed after widthAndRadiusRow to span full width when expanded\n  root.append(\n    borderEdgeRow,\n    widthAndRadiusRow,\n    borderRadiusCornersGrid,\n    borderStyleRow,\n    colorTypeRow,\n    borderColorRow,\n    borderGradientMount,\n  );\n  container.appendChild(root);\n  disposer.add(() => root.remove());\n\n  // ===========================================================================\n  // Border Edge Selector\n  // ===========================================================================\n\n  const borderEdgeGroup = createIconButtonGroup<BorderEdge>({\n    container: borderEdgeMount,\n    ariaLabel: 'Border edge',\n    columns: 5,\n    value: currentBorderEdge,\n    items: BORDER_EDGE_VALUES.map((edge) => ({\n      value: edge,\n      ariaLabel: edge,\n      title: edge.charAt(0).toUpperCase() + edge.slice(1),\n      icon: createBorderEdgeIcon(edge),\n    })),\n    onChange: (edge) => {\n      if (edge === currentBorderEdge) return;\n      commitTransaction('border-width');\n      commitTransaction('border-style');\n      commitTransaction('border-color');\n      currentBorderEdge = edge;\n      syncAllFields();\n    },\n  });\n  disposer.add(() => borderEdgeGroup.dispose());\n\n  // ===========================================================================\n  // Color Field\n  // ===========================================================================\n\n  const borderColorField = createColorField({\n    container: borderColorContainer,\n    ariaLabel: 'Border Color',\n    tokensService,\n    getTokenTarget: () => currentTarget,\n    onInput: (value) => {\n      const handle = beginTransaction('border-color');\n      if (handle) handle.set(value);\n    },\n    onCommit: () => {\n      commitTransaction('border-color');\n      syncAllFields();\n    },\n    onCancel: () => {\n      rollbackTransaction('border-color');\n      syncField('border-color', true);\n    },\n  });\n  disposer.add(() => borderColorField.dispose());\n\n  // ===========================================================================\n  // Gradient Control (for border-image-source)\n  // ===========================================================================\n\n  const borderGradientControl = createGradientControl({\n    container: borderGradientMount,\n    transactionManager,\n    tokensService,\n    property: 'border-image-source',\n    allowNone: true,\n  });\n  disposer.add(() => borderGradientControl.dispose());\n\n  // ===========================================================================\n  // Field State Map\n  // ===========================================================================\n\n  const fields: Record<BorderProperty, FieldState> = {\n    'border-width': {\n      kind: 'text',\n      property: 'border-width',\n      element: borderWidthInput,\n      handle: null,\n    },\n    'border-style': {\n      kind: 'select',\n      property: 'border-style',\n      element: borderStyleSelect,\n      handle: null,\n    },\n    'border-color': {\n      kind: 'color',\n      property: 'border-color',\n      field: borderColorField,\n      handle: null,\n    },\n    'border-radius': borderRadiusField,\n  };\n\n  const PROPS: readonly BorderProperty[] = [\n    'border-width',\n    'border-style',\n    'border-color',\n    'border-radius',\n  ];\n\n  // ===========================================================================\n  // CSS Property Resolution\n  // ===========================================================================\n\n  function resolveBorderProperty(kind: 'width' | 'style' | 'color'): string {\n    if (currentBorderEdge === 'all') return `border-${kind}`;\n    return `border-${currentBorderEdge}-${kind}`;\n  }\n\n  function resolveCssProperty(property: BorderProperty): string {\n    if (property === 'border-width') return resolveBorderProperty('width');\n    if (property === 'border-style') return resolveBorderProperty('style');\n    if (property === 'border-color') return resolveBorderProperty('color');\n    return property;\n  }\n\n  // ===========================================================================\n  // Transaction Management\n  // ===========================================================================\n\n  function beginTransaction(property: BorderProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.kind === 'border-radius') return null;\n    if (field.handle) return field.handle;\n\n    const cssProperty = resolveCssProperty(property);\n    const handle = transactionManager.beginStyle(target, cssProperty);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: BorderProperty): void {\n    const field = fields[property];\n    if (field.kind === 'border-radius') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: BorderProperty): void {\n    const field = fields[property];\n    if (field.kind === 'border-radius') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function beginBorderRadiusTransaction(): MultiStyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const field = fields['border-radius'];\n    if (field.kind !== 'border-radius') return null;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginMultiStyle(target, [\n      ...BORDER_RADIUS_TRANSACTION_PROPERTIES,\n    ]);\n    field.handle = handle;\n    field.mode = null;\n    field.cornersMaterialized = false;\n    return handle;\n  }\n\n  function commitBorderRadiusTransaction(): void {\n    const field = fields['border-radius'];\n    if (field.kind !== 'border-radius') return;\n    const handle = field.handle;\n    field.handle = null;\n    field.mode = null;\n    field.cornersMaterialized = false;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackBorderRadiusTransaction(): void {\n    const field = fields['border-radius'];\n    if (field.kind !== 'border-radius') return;\n    const handle = field.handle;\n    field.handle = null;\n    field.mode = null;\n    field.cornersMaterialized = false;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of PROPS) commitTransaction(p);\n    commitBorderRadiusTransaction();\n  }\n\n  // ===========================================================================\n  // Color Type (Solid / Gradient)\n  // ===========================================================================\n\n  /**\n   * Update visibility of color-related rows based on currentColorType.\n   */\n  function updateColorTypeVisibility(): void {\n    borderColorRow.hidden = currentColorType !== 'solid';\n    borderGradientMount.hidden = currentColorType !== 'gradient';\n  }\n\n  /**\n   * Update edge selector disabled state based on color type.\n   * Gradient mode requires 'all' edges (border-image doesn't support per-edge).\n   */\n  function updateEdgeSelectorState(): void {\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n\n    // In gradient mode, lock edge to 'all' since border-image applies to all edges\n    if (currentColorType === 'gradient') {\n      if (currentBorderEdge !== 'all') {\n        commitTransaction('border-width');\n        commitTransaction('border-style');\n        commitTransaction('border-color');\n        currentBorderEdge = 'all';\n      }\n      borderEdgeGroup.setValue('all');\n    }\n\n    borderEdgeGroup.setDisabled(!hasTarget || currentColorType === 'gradient');\n  }\n\n  /**\n   * Set border color type and apply necessary CSS changes.\n   * Uses multiStyle transaction to atomically set border-image properties.\n   */\n  function setColorType(type: BorderColorType): void {\n    const target = currentTarget;\n\n    currentColorType = type;\n    colorTypeSelect.value = type;\n\n    updateColorTypeVisibility();\n    updateEdgeSelectorState();\n\n    if (!target || !target.isConnected) return;\n\n    // Use multiStyle to atomically manage border-image properties\n    const handle = transactionManager.beginMultiStyle(target, [\n      'border-image-source',\n      'border-image-slice',\n    ]);\n    if (!handle) return;\n\n    if (type === 'solid') {\n      // Clear border-image when switching to solid color\n      handle.set({\n        'border-image-source': 'none',\n        'border-image-slice': '',\n      });\n    } else {\n      // Set up border-image for gradient mode\n      const inlineSource = readInlineValue(target, 'border-image-source');\n      const computedSource = readComputedValue(target, 'border-image-source');\n      const currentSource = inlineSource || computedSource;\n\n      // Use existing gradient or provide a default\n      const hasValidGradient =\n        currentSource &&\n        currentSource.trim() &&\n        currentSource.trim().toLowerCase() !== 'none' &&\n        /\\b(?:linear|radial|conic)-gradient\\s*\\(/i.test(currentSource);\n\n      const gradientValue = hasValidGradient\n        ? currentSource\n        : 'linear-gradient(90deg, #000000, #ffffff)';\n\n      handle.set({\n        'border-image-source': gradientValue,\n        'border-image-slice': '1',\n      });\n    }\n\n    handle.commit({ merge: true });\n  }\n\n  // Wire color type selector change event\n  disposer.listen(colorTypeSelect, 'change', () => {\n    const type = colorTypeSelect.value as BorderColorType;\n    setColorType(type);\n    borderGradientControl.refresh();\n    syncAllFields();\n  });\n\n  // ===========================================================================\n  // Field Synchronization\n  // ===========================================================================\n\n  function syncField(property: BorderProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n    const cssProperty = resolveCssProperty(property);\n\n    if (field.kind === 'border-radius') {\n      const hasTarget = Boolean(target && target.isConnected);\n\n      field.unified.input.disabled = !hasTarget;\n      field.toggleButton.disabled = !hasTarget;\n      for (const corner of BORDER_RADIUS_CORNERS) {\n        field.corners[corner].input.disabled = !hasTarget;\n      }\n\n      if (!hasTarget || !target) {\n        field.unified.input.value = '';\n        field.unified.input.placeholder = '';\n        field.unified.setSuffix('px');\n        for (const corner of BORDER_RADIUS_CORNERS) {\n          field.corners[corner].input.value = '';\n          field.corners[corner].input.placeholder = '';\n          field.corners[corner].setSuffix('px');\n        }\n        return;\n      }\n\n      const isCornerFocused = BORDER_RADIUS_CORNERS.some((c) =>\n        isFieldFocused(field.corners[c].input),\n      );\n      const isEditing =\n        field.handle !== null || isFieldFocused(field.unified.input) || isCornerFocused;\n      if (isEditing && !force) return;\n\n      const inlineUnified = readInlineValue(target, 'border-radius');\n      if (inlineUnified) {\n        const formatted = formatLengthForDisplay(inlineUnified);\n        field.unified.input.value = formatted.value;\n        field.unified.setSuffix(formatted.suffix);\n      } else {\n        const tl = readComputedValue(target, BORDER_RADIUS_CORNER_PROPERTIES['top-left']);\n        const tr = readComputedValue(target, BORDER_RADIUS_CORNER_PROPERTIES['top-right']);\n        const br = readComputedValue(target, BORDER_RADIUS_CORNER_PROPERTIES['bottom-right']);\n        const bl = readComputedValue(target, BORDER_RADIUS_CORNER_PROPERTIES['bottom-left']);\n        const displayValue =\n          tl === tr && tl === br && tl === bl ? tl : readComputedValue(target, 'border-radius');\n        const formatted = formatLengthForDisplay(displayValue);\n        field.unified.input.value = formatted.value;\n        field.unified.setSuffix(formatted.suffix);\n      }\n      field.unified.input.placeholder = '';\n\n      for (const corner of BORDER_RADIUS_CORNERS) {\n        const propName = BORDER_RADIUS_CORNER_PROPERTIES[corner];\n        const inlineValue = readInlineValue(target, propName);\n        const computedValue = readComputedValue(target, propName);\n        const displayValue = inlineValue || computedValue;\n        const formatted = formatLengthForDisplay(displayValue);\n        field.corners[corner].input.value = formatted.value;\n        field.corners[corner].input.placeholder = '';\n        field.corners[corner].setSuffix(formatted.suffix);\n      }\n      return;\n    }\n\n    if (field.kind === 'text') {\n      const input = field.element;\n\n      if (!target || !target.isConnected) {\n        input.disabled = true;\n        input.value = '';\n        input.placeholder = '';\n        if (property === 'border-width') borderWidthContainer.setSuffix('px');\n        return;\n      }\n\n      input.disabled = false;\n\n      const isEditing = field.handle !== null || isFieldFocused(input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, cssProperty);\n      const computedValue = readComputedValue(target, cssProperty);\n\n      // Use formatLengthForDisplay for border-width to set proper suffix\n      if (property === 'border-width') {\n        const formatted = formatLengthForDisplay(inlineValue || computedValue);\n        input.value = formatted.value;\n        borderWidthContainer.setSuffix(formatted.suffix);\n      } else {\n        input.value = inlineValue || computedValue;\n      }\n      input.placeholder = '';\n    } else if (field.kind === 'select') {\n      const select = field.element;\n\n      if (!target || !target.isConnected) {\n        select.disabled = true;\n        return;\n      }\n\n      select.disabled = false;\n\n      const isEditing = field.handle !== null || isFieldFocused(select);\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, cssProperty);\n      const computed = readComputedValue(target, cssProperty);\n      const val = inline || computed;\n      const hasOption = Array.from(select.options).some((o) => o.value === val);\n      select.value = hasOption ? val : (select.options[0]?.value ?? '');\n    } else {\n      const colorField = field.field;\n\n      if (!target || !target.isConnected) {\n        colorField.setDisabled(true);\n        colorField.setValue('');\n        colorField.setPlaceholder('');\n        return;\n      }\n\n      colorField.setDisabled(false);\n\n      const isEditing = field.handle !== null || colorField.isFocused();\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, cssProperty);\n      const computedValue = readComputedValue(target, cssProperty);\n      if (inlineValue) {\n        colorField.setValue(inlineValue);\n        colorField.setPlaceholder(/\\bvar\\s*\\(/i.test(inlineValue) ? computedValue : '');\n      } else {\n        colorField.setValue(computedValue);\n        colorField.setPlaceholder('');\n      }\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const p of PROPS) syncField(p);\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n    colorTypeSelect.disabled = !hasTarget;\n    updateColorTypeVisibility();\n    updateEdgeSelectorState();\n  }\n\n  // ===========================================================================\n  // Event Wiring\n  // ===========================================================================\n\n  function wireTextInput(property: BorderProperty): void {\n    const field = fields[property];\n    if (field.kind !== 'text') return;\n\n    const input = field.element;\n\n    // Use combineLengthValue for border-width to include suffix\n    const getNextValue =\n      property === 'border-width'\n        ? () => combineLengthValue(input.value, borderWidthContainer.getSuffixText())\n        : () => input.value.trim();\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(getNextValue());\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  function wireSelect(property: BorderProperty): void {\n    const field = fields[property];\n    if (field.kind !== 'select') return;\n\n    const select = field.element;\n\n    const preview = () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(select.value);\n    };\n\n    disposer.listen(select, 'input', preview);\n    disposer.listen(select, 'change', preview);\n    disposer.listen(select, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  function wireBorderRadiusControl(): void {\n    const field = fields['border-radius'];\n    if (field.kind !== 'border-radius') return;\n\n    const setExpanded = (expanded: boolean) => {\n      field.expanded = expanded;\n      field.cornersGrid.hidden = !expanded;\n      field.toggleButton.setAttribute('aria-pressed', expanded ? 'true' : 'false');\n    };\n    setExpanded(false);\n\n    disposer.listen(field.toggleButton, 'click', () => {\n      setExpanded(!field.expanded);\n    });\n\n    const previewUnified = () => {\n      const handle = beginBorderRadiusTransaction();\n      if (!handle) return;\n\n      field.mode = 'unified';\n      field.cornersMaterialized = false;\n\n      const v = combineLengthValue(field.unified.input.value, field.unified.getSuffixText());\n      // Step 1: Clear longhand properties first\n      handle.set({\n        'border-top-left-radius': '',\n        'border-top-right-radius': '',\n        'border-bottom-right-radius': '',\n        'border-bottom-left-radius': '',\n        'border-radius': '',\n      });\n      // Step 2: Set shorthand value after longhands are cleared\n      // This ensures the shorthand is applied last and not overwritten by empty longhands\n      handle.set({\n        'border-radius': v,\n      });\n    };\n\n    const previewCorner = (corner: BorderRadiusCorner) => {\n      const target = currentTarget;\n      if (!target || !target.isConnected) return;\n\n      const handle = beginBorderRadiusTransaction();\n      if (!handle) return;\n\n      const cornerProp = BORDER_RADIUS_CORNER_PROPERTIES[corner];\n      const container = field.corners[corner];\n      const next = combineLengthValue(container.input.value, container.getSuffixText());\n\n      if (field.mode !== 'corners' || !field.cornersMaterialized) {\n        const initialValues: Record<string, string> = {\n          'border-radius': '',\n          'border-top-left-radius':\n            readInlineValue(target, 'border-top-left-radius') ||\n            readComputedValue(target, 'border-top-left-radius'),\n          'border-top-right-radius':\n            readInlineValue(target, 'border-top-right-radius') ||\n            readComputedValue(target, 'border-top-right-radius'),\n          'border-bottom-right-radius':\n            readInlineValue(target, 'border-bottom-right-radius') ||\n            readComputedValue(target, 'border-bottom-right-radius'),\n          'border-bottom-left-radius':\n            readInlineValue(target, 'border-bottom-left-radius') ||\n            readComputedValue(target, 'border-bottom-left-radius'),\n        };\n        initialValues[cornerProp] = next;\n        handle.set(initialValues);\n        field.mode = 'corners';\n        field.cornersMaterialized = true;\n        return;\n      }\n\n      handle.set({ 'border-radius': '', [cornerProp]: next });\n    };\n\n    disposer.listen(field.unified.input, 'input', previewUnified);\n    for (const corner of BORDER_RADIUS_CORNERS) {\n      disposer.listen(field.corners[corner].input, 'input', () => previewCorner(corner));\n    }\n\n    disposer.listen(field.root, 'focusout', (e: FocusEvent) => {\n      const next = e.relatedTarget;\n      if (next instanceof Node && field.root.contains(next)) return;\n      commitBorderRadiusTransaction();\n      syncAllFields();\n    });\n\n    const wireKeydown = (input: HTMLInputElement) => {\n      disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n        if (e.key === 'Enter') {\n          e.preventDefault();\n          commitBorderRadiusTransaction();\n          syncAllFields();\n          input.blur();\n        } else if (e.key === 'Escape') {\n          e.preventDefault();\n          rollbackBorderRadiusTransaction();\n          syncField('border-radius', true);\n        }\n      });\n    };\n\n    wireKeydown(field.unified.input);\n    for (const corner of BORDER_RADIUS_CORNERS) {\n      wireKeydown(field.corners[corner].input);\n    }\n  }\n\n  wireTextInput('border-width');\n  wireSelect('border-style');\n  wireBorderRadiusControl();\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n\n    // Infer color type from border-image-source\n    if (element && element.isConnected) {\n      const borderImageSource =\n        readInlineValue(element, 'border-image-source') ||\n        readComputedValue(element, 'border-image-source');\n      currentColorType = inferBorderColorType(borderImageSource);\n    } else {\n      currentColorType = 'solid';\n    }\n    colorTypeSelect.value = currentColorType;\n\n    // In gradient mode, ensure edge is set to 'all'\n    if (currentColorType === 'gradient') {\n      currentBorderEdge = 'all';\n      borderEdgeGroup.setValue('all');\n    }\n\n    // Update gradient control target\n    borderGradientControl.setTarget(element);\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n\n    // Re-infer color type from element to handle external changes (CSS panel, Undo/Redo)\n    const target = currentTarget;\n    if (target && target.isConnected) {\n      const borderImageSource =\n        readInlineValue(target, 'border-image-source') ||\n        readComputedValue(target, 'border-image-source');\n      const inferredType = inferBorderColorType(borderImageSource);\n      if (inferredType !== currentColorType) {\n        currentColorType = inferredType;\n        colorTypeSelect.value = inferredType;\n        if (inferredType === 'gradient') {\n          currentBorderEdge = 'all';\n          borderEdgeGroup.setValue('all');\n        }\n      }\n    }\n\n    borderGradientControl.refresh();\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/color-field.ts",
    "content": "/**\n * Color Field (Phase 5.3 - Token Support)\n *\n * A reusable color field component for the Web Editor property panel.\n *\n * Features:\n * - Swatch button opens the native system color picker\n * - Hidden <input type=\"color\"> provides native UX\n * - Text input accepts hex/rgb/var(...) formats\n * - Token Pill mode: displays var(--token) as a pill with swatch preview\n * - Integrated Token Picker for selecting design tokens\n *\n * Mode switching:\n * - When value is a standalone var(--xxx), displays as Token Pill\n * - When value is a literal color or complex expression, displays as text input\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { CssVarName, DesignTokensService } from '../../../core/design-tokens';\nimport { createTokenPicker, type TokenPicker } from './token-picker';\nimport { createTokenPill, type TokenPill } from '../components/token-pill';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface ColorFieldOptions {\n  /** Container element to mount the field into */\n  container: HTMLElement;\n  /** Accessible label for the text input */\n  ariaLabel: string;\n  /** Called for live preview as the value changes */\n  onInput?: (value: string) => void;\n  /** Called when the user commits changes (blur/Enter or picker change) */\n  onCommit?: () => void;\n  /** Called when the user cancels editing (Escape) */\n  onCancel?: () => void;\n\n  // Token integration (Phase 5.3)\n  /** Optional: Design tokens service for TokenPill/TokenPicker integration */\n  tokensService?: DesignTokensService;\n  /** Optional: Provides current element context for token filtering */\n  getTokenTarget?: () => Element | null;\n  /** Optional: Max visible rows in token picker dropdown */\n  tokenPickerMaxVisible?: number;\n}\n\nexport interface ColorField {\n  /** Set the current value */\n  setValue(value: string): void;\n  /** Set placeholder (computed value) */\n  setPlaceholder(value: string): void;\n  /** Enable/disable the field */\n  setDisabled(disabled: boolean): void;\n  /** Check if the field is focused */\n  isFocused(): boolean;\n  /** Cleanup */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_COLOR_HEX = '#000000';\n\n// Token button SVG icon (palette icon)\nconst TOKEN_BTN_ICON_SVG = `\n  <svg class=\"we-token-btn-icon\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n    <path d=\"M12 3a9 9 0 100 18h1a2 2 0 002-2v-1a2 2 0 012-2h1a3 3 0 003-3 10 10 0 00-9-10z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n    <circle cx=\"7.5\" cy=\"10.5\" r=\"1\" fill=\"currentColor\"/>\n    <circle cx=\"10.5\" cy=\"7.5\" r=\"1\" fill=\"currentColor\"/>\n    <circle cx=\"13.5\" cy=\"10.5\" r=\"1\" fill=\"currentColor\"/>\n  </svg>\n`;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Clamp a byte value to 0-255\n */\nfunction clampByte(n: number): number {\n  if (!Number.isFinite(n)) return 0;\n  return Math.max(0, Math.min(255, Math.round(n)));\n}\n\n/**\n * Convert a byte to 2-digit hex string\n */\nfunction toHexByte(n: number): string {\n  return clampByte(n).toString(16).padStart(2, '0');\n}\n\n/**\n * Convert rgb(r, g, b) or rgba(r, g, b, a) string to #RRGGBB hex\n */\nfunction rgbToHex(rgb: string): string | null {\n  const match = rgb.match(/rgba?\\(\\s*([0-9.]+)\\s*,\\s*([0-9.]+)\\s*,\\s*([0-9.]+)/i);\n  if (!match) return null;\n\n  const r = Number(match[1]);\n  const g = Number(match[2]);\n  const b = Number(match[3]);\n\n  if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {\n    return null;\n  }\n\n  return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;\n}\n\n/**\n * Normalize a hex color string to #RRGGBB format\n */\nfunction normalizeHex(raw: string): string | null {\n  const v = raw.trim().toLowerCase();\n  if (!v.startsWith('#')) return null;\n\n  // Already #RRGGBB\n  if (/^#[0-9a-f]{6}$/.test(v)) return v;\n\n  // #RGB -> #RRGGBB\n  if (/^#[0-9a-f]{3}$/.test(v)) {\n    const r = v[1]!;\n    const g = v[2]!;\n    const b = v[3]!;\n    return `#${r}${r}${g}${g}${b}${b}`;\n  }\n\n  // #RRGGBBAA -> #RRGGBB (ignore alpha)\n  if (/^#[0-9a-f]{8}$/.test(v)) return v.slice(0, 7);\n\n  // #RGBA -> #RRGGBB (ignore alpha)\n  if (/^#[0-9a-f]{4}$/.test(v)) {\n    const r = v[1]!;\n    const g = v[2]!;\n    const b = v[3]!;\n    return `#${r}${r}${g}${g}${b}${b}`;\n  }\n\n  return null;\n}\n\n/**\n * Get active element from shadow DOM context\n */\nfunction getActiveElement(root: HTMLElement): Element | null {\n  try {\n    const rootNode = root.getRootNode();\n    if (rootNode instanceof ShadowRoot) {\n      return rootNode.activeElement;\n    }\n  } catch {\n    // Best-effort focus detection\n  }\n  return document.activeElement;\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a color field component with optional token support.\n */\nexport function createColorField(options: ColorFieldOptions): ColorField {\n  const {\n    container,\n    ariaLabel,\n    onInput,\n    onCommit,\n    onCancel,\n    tokensService,\n    getTokenTarget,\n    tokenPickerMaxVisible,\n  } = options;\n\n  const disposer = new Disposer();\n\n  // ---------------------------------------------------------------------------\n  // State\n  // ---------------------------------------------------------------------------\n\n  let currentValue = '';\n  let currentPlaceholder = '';\n  let lastResolvedHex = DEFAULT_COLOR_HEX;\n  let isTokenMode = false;\n  let isDisabled = false;\n\n  // Token integration instances (created only when tokensService is provided)\n  let tokenPill: TokenPill | null = null;\n  let tokenBtn: HTMLButtonElement | null = null;\n  let tokenPicker: TokenPicker | null = null;\n\n  // ---------------------------------------------------------------------------\n  // DOM Structure\n  // ---------------------------------------------------------------------------\n\n  // Root container (relative positioning for token picker dropdown)\n  const root = document.createElement('div');\n  root.className = 'we-color-field';\n  root.style.position = 'relative';\n\n  // Swatch button\n  const swatchBtn = document.createElement('button');\n  swatchBtn.type = 'button';\n  swatchBtn.className = 'we-color-swatch';\n  swatchBtn.dataset.tooltip = 'Pick color';\n  swatchBtn.setAttribute('aria-label', `Pick ${ariaLabel}`);\n\n  // Native color input (overlays swatch for direct click interaction)\n  const nativeInput = document.createElement('input');\n  nativeInput.type = 'color';\n  nativeInput.className = 'we-color-native-input';\n  nativeInput.value = lastResolvedHex;\n  nativeInput.tabIndex = -1;\n\n  // Text input for manual entry\n  const textInput = document.createElement('input');\n  textInput.type = 'text';\n  textInput.className = 'we-input we-color-text';\n  textInput.autocomplete = 'off';\n  textInput.spellcheck = false;\n  textInput.setAttribute('aria-label', ariaLabel);\n\n  // Hidden probe element for color resolution\n  const probe = document.createElement('span');\n  probe.style.cssText =\n    'position:fixed;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;opacity:0';\n  probe.setAttribute('aria-hidden', 'true');\n\n  // Place native input inside swatch\n  swatchBtn.append(nativeInput);\n\n  // Token button (opens token picker) - created only when tokensService provided\n  if (tokensService) {\n    tokenBtn = document.createElement('button');\n    tokenBtn.type = 'button';\n    tokenBtn.className = 'we-token-btn';\n    tokenBtn.setAttribute('aria-label', 'Select design token');\n    tokenBtn.dataset.tooltip = 'Select design token';\n    tokenBtn.innerHTML = TOKEN_BTN_ICON_SVG;\n  }\n\n  // Assemble DOM structure\n  root.append(swatchBtn, textInput);\n  if (tokenBtn) {\n    root.append(tokenBtn);\n  }\n  root.append(probe);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ---------------------------------------------------------------------------\n  // Token Pill (created when tokensService provided)\n  // ---------------------------------------------------------------------------\n\n  if (tokensService) {\n    tokenPill = createTokenPill({\n      container: root,\n      ariaLabel: `${ariaLabel} token`,\n      tokenName: '',\n      disabled: false,\n      onClick: () => toggleTokenPicker(),\n      onClear: () => detachToken(),\n    });\n    tokenPill.root.hidden = true;\n    disposer.add(() => tokenPill?.dispose());\n  }\n\n  // ---------------------------------------------------------------------------\n  // Token Picker (created when tokensService provided)\n  // ---------------------------------------------------------------------------\n\n  if (tokensService) {\n    tokenPicker = createTokenPicker({\n      container: root,\n      tokensService,\n      tokenKind: 'color',\n      maxVisible: tokenPickerMaxVisible,\n      onSelect: handleTokenSelect,\n    });\n    disposer.add(() => tokenPicker?.dispose());\n\n    // Close picker when clicking outside this field\n    disposer.listen(document, 'click', (e: MouseEvent) => {\n      if (!tokenPicker?.isVisible()) return;\n      const target = e.target as Node;\n      if (!root.contains(target)) {\n        tokenPicker.hide();\n      }\n    });\n  }\n\n  // ---------------------------------------------------------------------------\n  // Color Resolution\n  // ---------------------------------------------------------------------------\n\n  /**\n   * Get the display value for color resolution.\n   * When value contains var(), use placeholder (computed value) for resolution.\n   */\n  function getDisplayValue(): string {\n    const value = currentValue.trim();\n    const placeholder = currentPlaceholder.trim();\n\n    if (value && /\\bvar\\s*\\(/i.test(value) && placeholder) {\n      return placeholder;\n    }\n\n    return value || placeholder;\n  }\n\n  /**\n   * Resolve a color string to swatch display and hex for native picker\n   */\n  function resolveDisplayColor(raw: string): { swatch: string | null; hex: string | null } {\n    const trimmed = raw.trim();\n    if (!trimmed) return { swatch: null, hex: null };\n\n    const hex = normalizeHex(trimmed);\n    if (hex) return { swatch: hex, hex };\n\n    try {\n      probe.style.backgroundColor = '';\n      probe.style.backgroundColor = trimmed;\n      if (!probe.style.backgroundColor) return { swatch: null, hex: null };\n\n      const computed = getComputedStyle(probe).backgroundColor;\n      const computedHex = rgbToHex(computed);\n      return { swatch: computed || null, hex: computedHex };\n    } catch {\n      return { swatch: null, hex: null };\n    }\n  }\n\n  /**\n   * Update the swatch button color\n   */\n  function updateSwatch(): void {\n    const display = getDisplayValue();\n    const resolved = resolveDisplayColor(display);\n\n    if (resolved.swatch) {\n      swatchBtn.style.backgroundColor = resolved.swatch;\n    } else {\n      swatchBtn.style.backgroundColor = '';\n    }\n\n    if (resolved.hex) {\n      lastResolvedHex = resolved.hex;\n      nativeInput.value = resolved.hex;\n    }\n  }\n\n  /**\n   * Open the native color picker\n   */\n  function openPicker(): void {\n    if (nativeInput.disabled) return;\n\n    const display = getDisplayValue();\n    const resolved = resolveDisplayColor(display);\n    if (resolved.hex) lastResolvedHex = resolved.hex;\n    nativeInput.value = lastResolvedHex;\n\n    const showPicker = (nativeInput as HTMLInputElement & { showPicker?: () => void }).showPicker;\n    if (typeof showPicker === 'function') {\n      try {\n        showPicker.call(nativeInput);\n        return;\n      } catch {\n        // showPicker may throw if not triggered by user gesture\n      }\n    }\n\n    try {\n      nativeInput.click();\n    } catch {\n      // Best-effort fallback\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Token Mode Management\n  // ---------------------------------------------------------------------------\n\n  /**\n   * Parse token name from current value using tokensService.parseCssVar\n   */\n  function parseTokenName(): CssVarName | null {\n    if (!tokensService) return null;\n    const ref = tokensService.parseCssVar(currentValue.trim());\n    return ref ? ref.name : null;\n  }\n\n  /**\n   * Switch between token pill mode and text input mode\n   */\n  function setTokenMode(next: boolean, tokenName?: CssVarName): void {\n    if (!tokensService || !tokenPill) return;\n    if (next === isTokenMode) {\n      // Already in correct mode, just update token name if provided\n      if (next && tokenName) {\n        tokenPill.setTokenName(tokenName);\n      }\n      return;\n    }\n\n    isTokenMode = next;\n\n    if (next) {\n      // Enter token pill mode\n      const name = tokenName ?? parseTokenName() ?? '';\n      tokenPill.setTokenName(name);\n      tokenPill.setLeadingElement(swatchBtn);\n      tokenPill.root.hidden = false;\n      textInput.hidden = true;\n      if (tokenBtn) tokenBtn.hidden = true;\n    } else {\n      // Exit token pill mode\n      tokenPill.root.hidden = true;\n      tokenPill.setLeadingElement(null);\n      textInput.hidden = false;\n      if (tokenBtn) tokenBtn.hidden = false;\n\n      // Ensure swatch is positioned correctly\n      if (swatchBtn.parentElement !== root) {\n        root.insertBefore(swatchBtn, textInput);\n      } else if (swatchBtn.nextSibling !== textInput) {\n        root.insertBefore(swatchBtn, textInput);\n      }\n    }\n  }\n\n  /**\n   * Sync token UI based on current value\n   */\n  function syncTokenUi(): void {\n    if (!tokensService || !tokenPill) return;\n    const name = parseTokenName();\n    setTokenMode(Boolean(name), name ?? undefined);\n  }\n\n  /**\n   * Toggle token picker visibility\n   */\n  function toggleTokenPicker(): void {\n    if (!tokenPicker || !tokensService) return;\n    if (isDisabled) return;\n\n    tokenPicker.setTarget(getTokenTarget?.() ?? null);\n    tokenPicker.toggle();\n  }\n\n  /**\n   * Handle token selection from picker\n   */\n  function handleTokenSelect(tokenName: CssVarName, cssValue: string): void {\n    // Clear stale placeholder\n    currentPlaceholder = '';\n    textInput.placeholder = '';\n\n    // Update value\n    currentValue = cssValue;\n    textInput.value = currentValue;\n    updateSwatch();\n\n    // Notify listeners\n    onInput?.(currentValue.trim());\n    onCommit?.();\n\n    // Switch to token mode\n    setTokenMode(true, tokenName);\n  }\n\n  /**\n   * Detach token (clear to literal color)\n   */\n  function detachToken(): void {\n    if (!tokensService || !tokenPill) return;\n    if (isDisabled) return;\n\n    tokenPicker?.hide();\n\n    // Replace with current resolved color as literal\n    const literal = lastResolvedHex || DEFAULT_COLOR_HEX;\n    currentPlaceholder = '';\n    textInput.placeholder = '';\n\n    currentValue = literal;\n    textInput.value = currentValue;\n    updateSwatch();\n\n    // Notify listeners\n    onInput?.(currentValue);\n    onCommit?.();\n\n    // Exit token mode\n    setTokenMode(false);\n  }\n\n  // ---------------------------------------------------------------------------\n  // Event Handlers\n  // ---------------------------------------------------------------------------\n\n  // Swatch button keyboard activation\n  disposer.listen(swatchBtn, 'keydown', (e: KeyboardEvent) => {\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault();\n      openPicker();\n    }\n  });\n\n  // Text input change\n  disposer.listen(textInput, 'input', () => {\n    currentValue = textInput.value;\n    updateSwatch();\n    onInput?.(currentValue.trim());\n  });\n\n  // Text input blur -> commit and sync token UI\n  disposer.listen(textInput, 'blur', () => {\n    onCommit?.();\n    syncTokenUi();\n  });\n\n  // Text input keyboard\n  disposer.listen(textInput, 'keydown', (e: KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      onCommit?.();\n      textInput.blur();\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      onCancel?.();\n    }\n  });\n\n  // Native picker input (live update)\n  disposer.listen(nativeInput, 'input', () => {\n    currentValue = nativeInput.value;\n    textInput.value = currentValue;\n    updateSwatch();\n    onInput?.(currentValue);\n  });\n\n  // Native picker change (commit)\n  disposer.listen(nativeInput, 'change', () => {\n    onCommit?.();\n    syncTokenUi();\n  });\n\n  // Token button click\n  if (tokenBtn) {\n    disposer.listen(tokenBtn, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      toggleTokenPicker();\n    });\n  }\n\n  // Initial updates\n  updateSwatch();\n  syncTokenUi();\n\n  // ---------------------------------------------------------------------------\n  // Public Interface\n  // ---------------------------------------------------------------------------\n\n  return {\n    setValue(value: string): void {\n      currentValue = String(value ?? '');\n      textInput.value = currentValue;\n      updateSwatch();\n      syncTokenUi();\n    },\n\n    setPlaceholder(value: string): void {\n      currentPlaceholder = String(value ?? '');\n      textInput.placeholder = currentPlaceholder;\n      updateSwatch();\n    },\n\n    setDisabled(disabled: boolean): void {\n      isDisabled = Boolean(disabled);\n      swatchBtn.disabled = isDisabled;\n      textInput.disabled = isDisabled;\n      nativeInput.disabled = isDisabled;\n      if (tokenBtn) tokenBtn.disabled = isDisabled;\n      tokenPill?.setDisabled(isDisabled);\n      if (isDisabled) tokenPicker?.hide();\n    },\n\n    isFocused(): boolean {\n      const active = getActiveElement(root);\n      return active instanceof HTMLElement ? root.contains(active) : false;\n    },\n\n    dispose(): void {\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/css-helpers.ts",
    "content": "/**\n * CSS Value Helpers\n *\n * Shared utilities for parsing and normalizing CSS values.\n * Used by control components for input-container suffix management.\n */\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** CSS keywords that should not display a unit suffix */\nconst LENGTH_KEYWORDS = new Set([\n  'auto',\n  'inherit',\n  'initial',\n  'unset',\n  'none',\n  'fit-content',\n  'min-content',\n  'max-content',\n  'revert',\n  'revert-layer',\n]);\n\n/** Regex to detect CSS function expressions */\nconst LENGTH_FUNCTION_REGEX = /\\b(?:calc|var|clamp|min|max|fit-content)\\s*\\(/i;\n\n/** Regex to match number with unit (e.g., \"20px\", \"50%\") */\nconst NUMBER_WITH_UNIT_REGEX = /^(-?(?:\\d+|\\d*\\.\\d+|\\.\\d+))\\s*([a-zA-Z%]+)$/;\n\n/** Regex to match pure numbers */\nconst PURE_NUMBER_REGEX = /^-?(?:\\d+|\\d*\\.\\d+|\\.\\d+)$/;\n\n/** Regex to match numbers with trailing dot (e.g., \"10.\") */\nconst TRAILING_DOT_NUMBER_REGEX = /^-?\\d+\\.$/;\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Result of formatting a length value for display */\nexport interface FormattedLength {\n  /** The numeric or keyword value to display in the input */\n  value: string;\n  /** The unit suffix to display, or null if no suffix should be shown */\n  suffix: string | null;\n}\n\n// =============================================================================\n// Functions\n// =============================================================================\n\n/**\n * Extract CSS unit suffix from a length value.\n * Supports px, %, rem, em, vh, vw, etc.\n * Falls back to 'px' for pure numbers or unknown patterns.\n *\n * @example\n * extractUnitSuffix('100px') // 'px'\n * extractUnitSuffix('50%') // '%'\n * extractUnitSuffix('2rem') // 'rem'\n * extractUnitSuffix('100') // 'px' (default)\n * extractUnitSuffix('auto') // 'px' (fallback)\n */\nexport function extractUnitSuffix(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return 'px';\n\n  // Handle shorthand values by taking first token\n  const token = trimmed.split(/\\s+/)[0] ?? '';\n\n  // Match number + unit (including %)\n  const match = token.match(/^-?(?:\\d+|\\d*\\.\\d+)([a-zA-Z%]+)$/);\n  if (match) return match[1]!;\n\n  // Pure number: default to px\n  if (/^-?(?:\\d+|\\d*\\.\\d+)$/.test(token)) return 'px';\n  if (/^-?\\d+\\.$/.test(token)) return 'px';\n\n  return 'px';\n}\n\n/**\n * Check if a value has an explicit CSS unit.\n * Returns false for unitless numbers (e.g., \"1.5\" for line-height).\n *\n * @example\n * hasExplicitUnit('100px') // true\n * hasExplicitUnit('1.5') // false\n * hasExplicitUnit('auto') // false\n */\nexport function hasExplicitUnit(raw: string): boolean {\n  const trimmed = raw.trim();\n  if (!trimmed) return false;\n  const token = trimmed.split(/\\s+/)[0] ?? '';\n  return /^-?(?:\\d+|\\d*\\.\\d+)([a-zA-Z%]+)$/.test(token);\n}\n\n/**\n * Normalize a length value.\n * - Pure numbers (e.g., \"100\", \"10.5\") get \"px\" suffix\n * - Values with units or keywords pass through unchanged\n * - Empty string clears the inline style\n *\n * @example\n * normalizeLength('100') // '100px'\n * normalizeLength('10.5') // '10.5px'\n * normalizeLength('50%') // '50%'\n * normalizeLength('auto') // 'auto'\n * normalizeLength('') // ''\n */\nexport function normalizeLength(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return '';\n\n  // Pure number patterns: \"10\", \"-10\", \"10.5\", \".5\", \"-.5\"\n  if (/^-?(?:\\d+|\\d*\\.\\d+)$/.test(trimmed)) {\n    return `${trimmed}px`;\n  }\n\n  // Trailing dot (e.g., \"10.\") -> treat as integer px\n  if (/^-?\\d+\\.$/.test(trimmed)) {\n    return `${trimmed.slice(0, -1)}px`;\n  }\n\n  // Keep units/keywords/expressions as-is\n  return trimmed;\n}\n\n/**\n * Format a CSS length value for display in an input + suffix UI.\n *\n * Separates the numeric value from its unit to avoid duplication\n * (e.g., displaying \"20px\" in input and \"px\" as suffix).\n *\n * @example\n * formatLengthForDisplay('20px')    // { value: '20', suffix: 'px' }\n * formatLengthForDisplay('50%')     // { value: '50', suffix: '%' }\n * formatLengthForDisplay('auto')    // { value: 'auto', suffix: null }\n * formatLengthForDisplay('calc(...)') // { value: 'calc(...)', suffix: null }\n * formatLengthForDisplay('20')      // { value: '20', suffix: 'px' }\n * formatLengthForDisplay('')        // { value: '', suffix: 'px' }\n */\nexport function formatLengthForDisplay(raw: string): FormattedLength {\n  const trimmed = raw.trim();\n\n  // Empty: show default \"px\" suffix for consistent affordance\n  if (!trimmed) {\n    return { value: '', suffix: 'px' };\n  }\n\n  const lower = trimmed.toLowerCase();\n\n  // Keywords should not show any unit suffix\n  if (LENGTH_KEYWORDS.has(lower)) {\n    return { value: trimmed, suffix: null };\n  }\n\n  // Function expressions (calc, var, etc.) should not show suffix\n  if (LENGTH_FUNCTION_REGEX.test(trimmed)) {\n    return { value: trimmed, suffix: null };\n  }\n\n  // Number with unit: separate value and suffix\n  const unitMatch = trimmed.match(NUMBER_WITH_UNIT_REGEX);\n  if (unitMatch) {\n    const value = unitMatch[1] ?? '';\n    const suffix = unitMatch[2] ?? '';\n    return { value, suffix: suffix || null };\n  }\n\n  // Pure number: default to \"px\" suffix\n  if (PURE_NUMBER_REGEX.test(trimmed)) {\n    return { value: trimmed, suffix: 'px' };\n  }\n\n  // Trailing dot number (e.g., \"10.\"): treat as integer with \"px\"\n  if (TRAILING_DOT_NUMBER_REGEX.test(trimmed)) {\n    return { value: trimmed.slice(0, -1), suffix: 'px' };\n  }\n\n  // Fallback: unknown value, don't show misleading suffix\n  return { value: trimmed, suffix: null };\n}\n\n/**\n * Combine an input value with a unit suffix to form a complete CSS value.\n *\n * This is the inverse of formatLengthForDisplay - it takes the separated\n * value and suffix and combines them for CSS writing.\n *\n * @param inputValue - The value from the input field\n * @param suffix - The current unit suffix (from getSuffixText)\n * @returns The complete CSS value ready for style.setProperty()\n *\n * @example\n * combineLengthValue('20', 'px')     // '20px'\n * combineLengthValue('50', '%')      // '50%'\n * combineLengthValue('auto', null)   // 'auto'\n * combineLengthValue('', 'px')       // ''\n * combineLengthValue('calc(...)', null) // 'calc(...)'\n */\nexport function combineLengthValue(inputValue: string, suffix: string | null): string {\n  const trimmed = inputValue.trim();\n\n  // Empty value clears the style\n  if (!trimmed) return '';\n\n  const lower = trimmed.toLowerCase();\n\n  // Keywords should not have suffix appended\n  if (LENGTH_KEYWORDS.has(lower)) return trimmed;\n\n  // Function expressions should not have suffix appended\n  if (LENGTH_FUNCTION_REGEX.test(trimmed)) return trimmed;\n\n  // If input already has a unit (user typed \"20px\"), use it as-is\n  if (NUMBER_WITH_UNIT_REGEX.test(trimmed)) return trimmed;\n\n  // Trailing dot number (e.g., \"10.\"): normalize and add suffix\n  if (TRAILING_DOT_NUMBER_REGEX.test(trimmed)) {\n    const normalized = trimmed.slice(0, -1);\n    return suffix ? `${normalized}${suffix}` : `${normalized}px`;\n  }\n\n  // Pure number: append suffix (or default to px)\n  if (PURE_NUMBER_REGEX.test(trimmed)) {\n    return suffix ? `${trimmed}${suffix}` : `${trimmed}px`;\n  }\n\n  // Fallback: return as-is (might be invalid, but let browser handle it)\n  return trimmed;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/effects-control.ts",
    "content": "/**\n * Effects Control\n *\n * Current scope:\n * - Inline `box-shadow` list editor (Drop Shadow / Inner Shadow)\n *\n * Features:\n * - Add/remove multiple shadow effects\n * - Toggle visibility (hide/show) per shadow\n * - Adjust panel for detailed editing (type, offset, blur, spread, color)\n *\n * Notes:\n * - Rendering reads inline styles only (no computed fallback)\n * - Hidden shadows are kept in memory for the current editor session\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignTokensService } from '../../../core/design-tokens';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { createColorField, type ColorField } from './color-field';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\nimport type { DesignControl } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst EFFECT_TYPES = [\n  { value: 'drop-shadow', label: 'Drop Shadow' },\n  { value: 'inner-shadow', label: 'Inner Shadow' },\n  { value: 'layer-blur', label: 'Layer Blur' },\n  { value: 'backdrop-blur', label: 'Backdrop Blur' },\n] as const;\n\ntype EffectType = (typeof EFFECT_TYPES)[number]['value'];\n\ntype EffectsProperty = 'box-shadow' | 'filter' | 'backdrop-filter';\n\n/**\n * Regex to match CSS length tokens (e.g., \"10px\", \"-5.5em\", \"0\")\n * Note: Does not match calc()/var() - those are treated as \"other\" tokens\n */\nconst LENGTH_TOKEN_REGEX = /^-?(?:\\d+\\.?\\d*|\\.\\d+)(?:[a-zA-Z%]+)?$/;\n\n/** Check if a token looks like a CSS function call (e.g., calc(), var()) */\nfunction isCssFunctionToken(token: string): boolean {\n  return /^[a-zA-Z_-]+\\s*\\(/.test(token);\n}\n\n// =============================================================================\n// Types\n// =============================================================================\n\ninterface ParsedBoxShadow {\n  inset: boolean;\n  offsetX: string;\n  offsetY: string;\n  blurRadius: string;\n  spreadRadius: string;\n  color: string;\n}\n\ninterface CssFunctionMatch {\n  start: number;\n  end: number;\n  args: string;\n}\n\n// =============================================================================\n// CSS Parsing Helpers\n// =============================================================================\n\n/**\n * Check if an element is focused within Shadow DOM context\n */\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Normalize a length value to include \"px\" unit if missing\n */\nfunction normalizeLength(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed || trimmed.toLowerCase() === 'none') return '';\n\n  // Pure number: add \"px\" unit\n  if (/^-?(?:\\d+|\\d*\\.\\d+)$/.test(trimmed)) return `${trimmed}px`;\n\n  // Trailing dot: \"10.\" -> \"10px\"\n  if (/^-?\\d+\\.$/.test(trimmed)) return `${trimmed.slice(0, -1)}px`;\n\n  return trimmed;\n}\n\n/**\n * Read inline style value from element\n */\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Read computed style value from element\n */\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Split a CSS value by a separator, respecting parentheses and quotes\n */\nfunction splitTopLevel(value: string, separator: string): string[] {\n  const results: string[] = [];\n  let depth = 0;\n  let quote: \"'\" | '\"' | null = null;\n  let escape = false;\n  let start = 0;\n\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]!;\n\n    if (escape) {\n      escape = false;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      escape = true;\n      continue;\n    }\n\n    if (quote) {\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '(') {\n      depth++;\n      continue;\n    }\n\n    if (ch === ')') {\n      depth = Math.max(0, depth - 1);\n      continue;\n    }\n\n    if (depth === 0 && ch === separator) {\n      results.push(value.slice(start, i));\n      start = i + 1;\n    }\n  }\n\n  results.push(value.slice(start));\n  return results;\n}\n\n/**\n * Tokenize a CSS value by whitespace, respecting parentheses and quotes\n */\nfunction tokenizeTopLevel(value: string): string[] {\n  const tokens: string[] = [];\n  let depth = 0;\n  let quote: \"'\" | '\"' | null = null;\n  let escape = false;\n  let buffer = '';\n\n  const flush = () => {\n    const t = buffer.trim();\n    if (t) tokens.push(t);\n    buffer = '';\n  };\n\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]!;\n\n    if (escape) {\n      buffer += ch;\n      escape = false;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      buffer += ch;\n      escape = true;\n      continue;\n    }\n\n    if (quote) {\n      buffer += ch;\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      buffer += ch;\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '(') {\n      depth++;\n      buffer += ch;\n      continue;\n    }\n\n    if (ch === ')') {\n      depth = Math.max(0, depth - 1);\n      buffer += ch;\n      continue;\n    }\n\n    if (depth === 0 && /\\s/.test(ch)) {\n      flush();\n      continue;\n    }\n\n    buffer += ch;\n  }\n\n  flush();\n  return tokens;\n}\n\n/**\n * Parse a single box-shadow value into components\n */\nfunction parseBoxShadow(raw: string): ParsedBoxShadow | null {\n  const trimmed = raw.trim();\n  if (!trimmed || trimmed.toLowerCase() === 'none') return null;\n\n  // Get the first shadow (before comma)\n  const first = splitTopLevel(trimmed, ',')[0]?.trim() ?? '';\n  if (!first || first.toLowerCase() === 'none') return null;\n\n  const tokens = tokenizeTopLevel(first);\n  if (tokens.length === 0) return null;\n\n  let inset = false;\n  const lengthTokens: string[] = [];\n  const otherTokens: string[] = [];\n\n  for (const token of tokens) {\n    if (/^inset$/i.test(token)) {\n      inset = true;\n      continue;\n    }\n\n    // Pure length values (numbers with optional units)\n    if (LENGTH_TOKEN_REGEX.test(token)) {\n      lengthTokens.push(token);\n    }\n    // CSS functions like calc(), var() - treat as length if in length position\n    else if (isCssFunctionToken(token) && lengthTokens.length < 4) {\n      lengthTokens.push(token);\n    } else {\n      otherTokens.push(token);\n    }\n  }\n\n  // Need at least 2 length values (offset-x, offset-y)\n  if (lengthTokens.length < 2) return null;\n\n  return {\n    inset,\n    offsetX: lengthTokens[0] ?? '',\n    offsetY: lengthTokens[1] ?? '',\n    blurRadius: lengthTokens[2] ?? '',\n    spreadRadius: lengthTokens[3] ?? '',\n    color: otherTokens.join(' ').trim(),\n  };\n}\n\n/**\n * Format box-shadow components into CSS value\n */\nfunction formatBoxShadow(input: {\n  inset: boolean;\n  offsetX: string;\n  offsetY: string;\n  blurRadius: string;\n  spreadRadius: string;\n  color: string;\n}): string {\n  const offsetX = normalizeLength(input.offsetX);\n  const offsetY = normalizeLength(input.offsetY);\n  const blurRadius = normalizeLength(input.blurRadius);\n  const spreadRadius = normalizeLength(input.spreadRadius);\n  const color = input.color.trim();\n\n  // Return empty if no meaningful values\n  if (!offsetX && !offsetY && !blurRadius && !spreadRadius && !color) return '';\n\n  const parts: string[] = [];\n  if (input.inset) parts.push('inset');\n\n  parts.push(offsetX || '0px', offsetY || '0px');\n\n  // Include blur if set or if spread is set\n  if (blurRadius || spreadRadius) parts.push(blurRadius || '0px');\n  if (spreadRadius) parts.push(spreadRadius);\n  if (color) parts.push(color);\n\n  return parts.join(' ');\n}\n\n/**\n * Update the first shadow in a comma-separated list, preserving others\n */\nfunction upsertFirstShadow(existing: string, first: string): string {\n  const base = existing.trim();\n  const firstTrimmed = first.trim();\n\n  const segments = base && base.toLowerCase() !== 'none' ? splitTopLevel(base, ',') : [];\n  const tail = segments\n    .slice(1)\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  if (!firstTrimmed) return tail.join(', ');\n  if (tail.length === 0) return firstTrimmed;\n  return `${firstTrimmed}, ${tail.join(', ')}`;\n}\n\n/**\n * Find a CSS function call (e.g., blur(...)) in a filter value\n * Handles word boundaries to avoid matching \"myblur\" when looking for \"blur\"\n */\nfunction findCssFunction(value: string, fnName: string): CssFunctionMatch | null {\n  const src = value;\n  const lower = src.toLowerCase();\n  const needle = fnName.toLowerCase();\n\n  let searchIndex = 0;\n\n  while (searchIndex < src.length) {\n    const found = lower.indexOf(needle, searchIndex);\n    if (found < 0) return null;\n\n    // Check word boundary: must not be preceded by a letter/digit/underscore/hyphen\n    if (found > 0) {\n      const prevChar = src[found - 1]!;\n      if (/[a-zA-Z0-9_-]/.test(prevChar)) {\n        searchIndex = found + needle.length;\n        continue;\n      }\n    }\n\n    // Find opening parenthesis (allow whitespace)\n    let i = found + needle.length;\n    while (i < src.length && /\\s/.test(src[i]!)) i++;\n    if (src[i] !== '(') {\n      searchIndex = found + needle.length;\n      continue;\n    }\n\n    const openIndex = i;\n    let depth = 0;\n    let quote: \"'\" | '\"' | null = null;\n    let escape = false;\n\n    for (let j = openIndex; j < src.length; j++) {\n      const ch = src[j]!;\n\n      if (escape) {\n        escape = false;\n        continue;\n      }\n\n      if (ch === '\\\\') {\n        escape = true;\n        continue;\n      }\n\n      if (quote) {\n        if (ch === quote) quote = null;\n        continue;\n      }\n\n      if (ch === '\"' || ch === \"'\") {\n        quote = ch;\n        continue;\n      }\n\n      if (ch === '(') {\n        depth++;\n        continue;\n      }\n\n      if (ch === ')') {\n        depth--;\n        if (depth === 0) {\n          return {\n            start: found,\n            end: j + 1,\n            args: src.slice(openIndex + 1, j),\n          };\n        }\n        continue;\n      }\n    }\n\n    return null;\n  }\n\n  return null;\n}\n\n/**\n * Extract blur radius from filter/backdrop-filter value\n */\nfunction parseBlurRadius(value: string): string {\n  const trimmed = value.trim();\n  if (!trimmed || trimmed.toLowerCase() === 'none') return '';\n\n  const match = findCssFunction(trimmed, 'blur');\n  return match ? match.args.trim() : '';\n}\n\n/**\n * Update blur() function in filter value, preserving other functions\n */\nfunction upsertBlurFunction(existing: string, radius: string): string {\n  const base = existing.trim().toLowerCase() === 'none' ? '' : existing.trim();\n  const match = base ? findCssFunction(base, 'blur') : null;\n\n  const normalizedRadius = normalizeLength(radius);\n\n  // Remove blur if radius is empty\n  if (!normalizedRadius) {\n    if (!match) return base;\n\n    const left = base.slice(0, match.start).trimEnd();\n    const right = base.slice(match.end).trimStart();\n    if (left && right) return `${left} ${right}`.trim();\n    return (left || right).trim();\n  }\n\n  const replacement = `blur(${normalizedRadius})`;\n\n  // Add blur if not present\n  if (!match) {\n    if (!base) return replacement;\n    return `${base} ${replacement}`.trim();\n  }\n\n  // Replace existing blur\n  const left = base.slice(0, match.start).trimEnd();\n  const right = base.slice(match.end).trimStart();\n  const parts: string[] = [];\n  if (left) parts.push(left);\n  parts.push(replacement);\n  if (right) parts.push(right);\n  return parts.join(' ');\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface EffectsControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n  /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */\n  tokensService?: DesignTokensService;\n  /** Optional: Container element for header actions (e.g., add button) */\n  headerActionsContainer?: HTMLElement;\n}\n\n/** @deprecated Use `createEffectsControl` (box-shadow list) instead. */\nexport function createLegacyEffectsControl(options: EffectsControlOptions): DesignControl {\n  const { container, transactionManager, tokensService } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n  let currentEffectType: EffectType = 'drop-shadow';\n  let shadowColorValue = '';\n\n  const handles: Record<EffectsProperty, StyleTransactionHandle | null> = {\n    'box-shadow': null,\n    filter: null,\n    'backdrop-filter': null,\n  };\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // -------------------------------------------------------------------------\n  // DOM Construction Helpers\n  // -------------------------------------------------------------------------\n\n  function createInputRow(\n    labelText: string,\n    ariaLabel: string,\n  ): { row: HTMLDivElement; input: HTMLInputElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'we-input';\n    input.autocomplete = 'off';\n    input.spellcheck = false;\n    input.inputMode = 'decimal';\n    input.setAttribute('aria-label', ariaLabel);\n\n    row.append(label, input);\n    return { row, input };\n  }\n\n  function createSelectRow(\n    labelText: string,\n    ariaLabel: string,\n    values: readonly { value: string; label: string }[],\n  ): { row: HTMLDivElement; select: HTMLSelectElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n\n    const select = document.createElement('select');\n    select.className = 'we-select';\n    select.setAttribute('aria-label', ariaLabel);\n\n    for (const v of values) {\n      const opt = document.createElement('option');\n      opt.value = v.value;\n      opt.textContent = v.label;\n      select.append(opt);\n    }\n\n    row.append(label, select);\n    return { row, select };\n  }\n\n  function createColorRow(labelText: string): {\n    row: HTMLDivElement;\n    colorFieldContainer: HTMLDivElement;\n  } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n\n    const colorFieldContainer = document.createElement('div');\n    colorFieldContainer.style.flex = '1';\n    colorFieldContainer.style.minWidth = '0';\n\n    row.append(label, colorFieldContainer);\n    return { row, colorFieldContainer };\n  }\n\n  // -------------------------------------------------------------------------\n  // Create UI Elements\n  // -------------------------------------------------------------------------\n\n  const { row: typeRow, select: effectTypeSelect } = createSelectRow(\n    'Type',\n    'Effect Type',\n    EFFECT_TYPES,\n  );\n\n  // Shadow-specific fields\n  const { row: offsetXRow, input: offsetXInput } = createInputRow('Offset X', 'Shadow Offset X');\n  const { row: offsetYRow, input: offsetYInput } = createInputRow('Offset Y', 'Shadow Offset Y');\n  const { row: shadowBlurRow, input: shadowBlurInput } = createInputRow(\n    'Blur',\n    'Shadow Blur Radius',\n  );\n  const { row: spreadRow, input: spreadInput } = createInputRow('Spread', 'Shadow Spread Radius');\n  const { row: colorRow, colorFieldContainer } = createColorRow('Color');\n\n  // Blur-specific fields\n  const { row: blurRadiusRow, input: blurRadiusInput } = createInputRow('Radius', 'Blur Radius');\n\n  root.append(typeRow, offsetXRow, offsetYRow, shadowBlurRow, spreadRow, colorRow, blurRadiusRow);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // Wire keyboard stepping for numeric inputs\n  wireNumberStepping(disposer, offsetXInput, { mode: 'css-length' });\n  wireNumberStepping(disposer, offsetYInput, { mode: 'css-length' });\n  wireNumberStepping(disposer, shadowBlurInput, {\n    mode: 'css-length',\n    min: 0,\n    step: 1,\n    shiftStep: 10,\n    altStep: 0.1,\n  });\n  wireNumberStepping(disposer, spreadInput, {\n    mode: 'css-length',\n    step: 1,\n    shiftStep: 10,\n    altStep: 0.1,\n  });\n  wireNumberStepping(disposer, blurRadiusInput, {\n    mode: 'css-length',\n    min: 0,\n    step: 1,\n    shiftStep: 10,\n    altStep: 0.1,\n  });\n\n  // Create color field\n  const shadowColorField: ColorField = createColorField({\n    container: colorFieldContainer,\n    ariaLabel: 'Shadow Color',\n    tokensService,\n    getTokenTarget: () => currentTarget,\n    onInput: (value) => {\n      shadowColorValue = value;\n      previewShadow();\n    },\n    onCommit: () => {\n      commitTransaction('box-shadow');\n      syncAllFields();\n    },\n    onCancel: () => {\n      rollbackTransaction('box-shadow');\n      syncAllFields(true);\n    },\n  });\n  disposer.add(() => shadowColorField.dispose());\n\n  // -------------------------------------------------------------------------\n  // Transaction Management\n  // -------------------------------------------------------------------------\n\n  function beginTransaction(property: EffectsProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const existing = handles[property];\n    if (existing) return existing;\n\n    const handle = transactionManager.beginStyle(target, property);\n    handles[property] = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: EffectsProperty): void {\n    const handle = handles[property];\n    handles[property] = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: EffectsProperty): void {\n    const handle = handles[property];\n    handles[property] = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    commitTransaction('box-shadow');\n    commitTransaction('filter');\n    commitTransaction('backdrop-filter');\n  }\n\n  // -------------------------------------------------------------------------\n  // Effect Type Helpers\n  // -------------------------------------------------------------------------\n\n  function isShadowType(type: EffectType): boolean {\n    return type === 'drop-shadow' || type === 'inner-shadow';\n  }\n\n  function getBlurProperty(type: EffectType): EffectsProperty {\n    return type === 'backdrop-blur' ? 'backdrop-filter' : 'filter';\n  }\n\n  function updateRowVisibility(): void {\n    const isShadow = isShadowType(currentEffectType);\n\n    offsetXRow.hidden = !isShadow;\n    offsetYRow.hidden = !isShadow;\n    shadowBlurRow.hidden = !isShadow;\n    spreadRow.hidden = !isShadow;\n    colorRow.hidden = !isShadow;\n    blurRadiusRow.hidden = isShadow;\n  }\n\n  function isShadowEditing(): boolean {\n    return (\n      handles['box-shadow'] !== null ||\n      isFieldFocused(offsetXInput) ||\n      isFieldFocused(offsetYInput) ||\n      isFieldFocused(shadowBlurInput) ||\n      isFieldFocused(spreadInput) ||\n      shadowColorField.isFocused()\n    );\n  }\n\n  function isBlurEditing(property: EffectsProperty): boolean {\n    return handles[property] !== null || isFieldFocused(blurRadiusInput);\n  }\n\n  // -------------------------------------------------------------------------\n  // Live Preview\n  // -------------------------------------------------------------------------\n\n  function previewShadow(): void {\n    if (disposer.isDisposed || !isShadowType(currentEffectType)) return;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    const handle = beginTransaction('box-shadow');\n    if (!handle) return;\n\n    const shadowValue = formatBoxShadow({\n      inset: currentEffectType === 'inner-shadow',\n      offsetX: offsetXInput.value,\n      offsetY: offsetYInput.value,\n      blurRadius: shadowBlurInput.value,\n      spreadRadius: spreadInput.value,\n      color: shadowColorValue,\n    });\n\n    const existingInline = readInlineValue(target, 'box-shadow');\n    handle.set(upsertFirstShadow(existingInline, shadowValue));\n  }\n\n  function previewBlur(): void {\n    if (disposer.isDisposed) return;\n    if (currentEffectType !== 'layer-blur' && currentEffectType !== 'backdrop-blur') return;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    const property = getBlurProperty(currentEffectType);\n    const handle = beginTransaction(property);\n    if (!handle) return;\n\n    const existingInline = readInlineValue(target, property);\n    handle.set(upsertBlurFunction(existingInline, blurRadiusInput.value));\n  }\n\n  // -------------------------------------------------------------------------\n  // Sync (Render from Element State)\n  // -------------------------------------------------------------------------\n\n  function setAllDisabled(disabled: boolean): void {\n    effectTypeSelect.disabled = disabled;\n    offsetXInput.disabled = disabled;\n    offsetYInput.disabled = disabled;\n    shadowBlurInput.disabled = disabled;\n    spreadInput.disabled = disabled;\n    blurRadiusInput.disabled = disabled;\n    shadowColorField.setDisabled(disabled);\n  }\n\n  function clearAllValues(): void {\n    offsetXInput.value = '';\n    offsetYInput.value = '';\n    shadowBlurInput.value = '';\n    spreadInput.value = '';\n    blurRadiusInput.value = '';\n    shadowColorValue = '';\n    shadowColorField.setValue('');\n    shadowColorField.setPlaceholder('');\n  }\n\n  function syncShadowFields(force = false): void {\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    if (isShadowEditing() && !force) return;\n\n    const inlineValue = readInlineValue(target, 'box-shadow');\n    const inlineParsed = inlineValue ? parseBoxShadow(inlineValue) : null;\n\n    // Only read computed value if inline is empty or contains CSS variables\n    const needsComputed = !inlineParsed || /\\bvar\\s*\\(/i.test(inlineValue);\n    const computedParsed = needsComputed\n      ? parseBoxShadow(readComputedValue(target, 'box-shadow'))\n      : null;\n\n    const parsed = inlineParsed ?? computedParsed;\n\n    if (!parsed) {\n      offsetXInput.value = '';\n      offsetYInput.value = '';\n      shadowBlurInput.value = '';\n      spreadInput.value = '';\n      shadowColorValue = '';\n      shadowColorField.setValue('');\n      shadowColorField.setPlaceholder('');\n      return;\n    }\n\n    offsetXInput.value = parsed.offsetX;\n    offsetYInput.value = parsed.offsetY;\n    shadowBlurInput.value = parsed.blurRadius;\n    spreadInput.value = parsed.spreadRadius;\n\n    if (inlineParsed) {\n      shadowColorValue = inlineParsed.color;\n      shadowColorField.setValue(inlineParsed.color);\n\n      // Pass computed value as placeholder for CSS variables\n      const needsPlaceholder = /\\bvar\\s*\\(/i.test(inlineParsed.color);\n      shadowColorField.setPlaceholder(needsPlaceholder ? (computedParsed?.color ?? '') : '');\n    } else {\n      shadowColorValue = parsed.color;\n      shadowColorField.setValue(parsed.color);\n      shadowColorField.setPlaceholder('');\n    }\n  }\n\n  function syncBlurFields(property: EffectsProperty, force = false): void {\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    if (isBlurEditing(property) && !force) return;\n\n    const inlineValue = readInlineValue(target, property);\n    // Only read computed if inline is empty\n    const display = inlineValue || readComputedValue(target, property);\n\n    blurRadiusInput.value = parseBlurRadius(display);\n  }\n\n  function syncAllFields(force = false): void {\n    updateRowVisibility();\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) {\n      setAllDisabled(true);\n      clearAllValues();\n      return;\n    }\n\n    setAllDisabled(false);\n\n    if (isShadowType(currentEffectType)) {\n      syncShadowFields(force);\n    } else {\n      syncBlurFields(getBlurProperty(currentEffectType), force);\n    }\n  }\n\n  /**\n   * Infer the initial effect type based on existing styles\n   */\n  function inferEffectType(target: Element): EffectType {\n    const shadowValue =\n      readInlineValue(target, 'box-shadow') || readComputedValue(target, 'box-shadow');\n    const parsedShadow = parseBoxShadow(shadowValue);\n    if (parsedShadow) return parsedShadow.inset ? 'inner-shadow' : 'drop-shadow';\n\n    const filterValue = readInlineValue(target, 'filter') || readComputedValue(target, 'filter');\n    if (parseBlurRadius(filterValue)) return 'layer-blur';\n\n    const backdropValue =\n      readInlineValue(target, 'backdrop-filter') || readComputedValue(target, 'backdrop-filter');\n    if (parseBlurRadius(backdropValue)) return 'backdrop-blur';\n\n    return 'drop-shadow';\n  }\n\n  // -------------------------------------------------------------------------\n  // Event Wiring\n  // -------------------------------------------------------------------------\n\n  function rollbackAllTransactions(): void {\n    rollbackTransaction('box-shadow');\n    rollbackTransaction('filter');\n    rollbackTransaction('backdrop-filter');\n  }\n\n  const onEffectTypeChange = () => {\n    const next = effectTypeSelect.value as EffectType;\n    if (next === currentEffectType) return;\n\n    // Rollback any in-progress edits when switching effect type\n    // This prevents accidentally committing half-edited values\n    rollbackAllTransactions();\n    currentEffectType = next;\n    updateRowVisibility();\n    syncAllFields(true);\n  };\n\n  disposer.listen(effectTypeSelect, 'input', onEffectTypeChange);\n  disposer.listen(effectTypeSelect, 'change', onEffectTypeChange);\n\n  function wireShadowInput(input: HTMLInputElement): void {\n    disposer.listen(input, 'input', previewShadow);\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction('box-shadow');\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction('box-shadow');\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction('box-shadow');\n        syncAllFields(true);\n      }\n    });\n  }\n\n  wireShadowInput(offsetXInput);\n  wireShadowInput(offsetYInput);\n  wireShadowInput(shadowBlurInput);\n  wireShadowInput(spreadInput);\n\n  disposer.listen(blurRadiusInput, 'input', previewBlur);\n\n  disposer.listen(blurRadiusInput, 'blur', () => {\n    if (currentEffectType !== 'layer-blur' && currentEffectType !== 'backdrop-blur') return;\n    commitTransaction(getBlurProperty(currentEffectType));\n    syncAllFields();\n  });\n\n  disposer.listen(blurRadiusInput, 'keydown', (e: KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      if (currentEffectType === 'layer-blur' || currentEffectType === 'backdrop-blur') {\n        commitTransaction(getBlurProperty(currentEffectType));\n        syncAllFields();\n      }\n      blurRadiusInput.blur();\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      if (currentEffectType === 'layer-blur' || currentEffectType === 'backdrop-blur') {\n        rollbackTransaction(getBlurProperty(currentEffectType));\n        syncAllFields(true);\n      }\n    }\n  });\n\n  // -------------------------------------------------------------------------\n  // DesignControl Interface\n  // -------------------------------------------------------------------------\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n\n    if (element && element.isConnected) {\n      currentEffectType = inferEffectType(element);\n      effectTypeSelect.value = currentEffectType;\n    }\n\n    syncAllFields(true);\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  // Initialize\n  effectTypeSelect.value = currentEffectType;\n  syncAllFields(true);\n\n  return { setTarget, refresh, dispose };\n}\n\n// =============================================================================\n// Box Shadow List (Effects v2)\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\nconst BOX_SHADOW_PROPERTY = 'box-shadow';\n\n// 效果类型定义\nconst EFFECT_TYPE_OPTIONS = [\n  { value: 'drop-shadow', label: 'Drop Shadow', category: 'shadow' },\n  { value: 'inner-shadow', label: 'Inner Shadow', category: 'shadow' },\n  { value: 'layer-blur', label: 'Layer Blur', category: 'blur' },\n  { value: 'backdrop-blur', label: 'Backdrop Blur', category: 'blur' },\n] as const;\n\ntype EffectTypeValue = (typeof EFFECT_TYPE_OPTIONS)[number]['value'];\ntype EffectCategory = 'shadow' | 'blur';\n\n// -----------------------------------------------------------------------------\n// ID Generation\n// -----------------------------------------------------------------------------\n\nlet shadowItemIdCounter = 0;\n\nfunction createShadowItemId(): string {\n  try {\n    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n      return crypto.randomUUID();\n    }\n  } catch {\n    // Fallback to counter\n  }\n  shadowItemIdCounter += 1;\n  return `shadow_${shadowItemIdCounter}_${Date.now()}`;\n}\n\n// -----------------------------------------------------------------------------\n// Effect Item Types\n// -----------------------------------------------------------------------------\n\ninterface EffectItemBase {\n  id: string;\n  enabled: boolean;\n}\n\n// Shadow 类型效果（Drop Shadow / Inner Shadow）\ninterface ShadowEffectItem extends EffectItemBase {\n  type: 'drop-shadow' | 'inner-shadow';\n  kind: 'parsed';\n  inset: boolean;\n  offsetX: string;\n  offsetY: string;\n  blurRadius: string;\n  spreadRadius: string;\n  color: string;\n}\n\n// Blur 类型效果（Layer Blur / Backdrop Blur）\ninterface BlurEffectItem extends EffectItemBase {\n  type: 'layer-blur' | 'backdrop-blur';\n  kind: 'parsed';\n  radius: string;\n}\n\n// 无法解析的原始效果\ninterface RawEffectItem extends EffectItemBase {\n  type: 'raw';\n  kind: 'raw';\n  property: 'box-shadow' | 'filter' | 'backdrop-filter';\n  rawText: string;\n}\n\ntype EffectItem = ShadowEffectItem | BlurEffectItem | RawEffectItem;\n\nfunction isShadowEffect(item: EffectItem): item is ShadowEffectItem {\n  return item.type === 'drop-shadow' || item.type === 'inner-shadow';\n}\n\nfunction isBlurEffect(item: EffectItem): item is BlurEffectItem {\n  return item.type === 'layer-blur' || item.type === 'backdrop-blur';\n}\n\n// -----------------------------------------------------------------------------\n// Effect Item Helpers\n// -----------------------------------------------------------------------------\n\nfunction createDefaultShadowEffect(): ShadowEffectItem {\n  return {\n    id: createShadowItemId(),\n    enabled: true,\n    type: 'drop-shadow',\n    kind: 'parsed',\n    inset: false,\n    offsetX: '0px',\n    offsetY: '4px',\n    blurRadius: '12px',\n    spreadRadius: '0px',\n    color: 'rgba(0, 0, 0, 0.15)',\n  };\n}\n\nfunction createDefaultBlurEffect(type: 'layer-blur' | 'backdrop-blur'): BlurEffectItem {\n  return {\n    id: createShadowItemId(),\n    enabled: true,\n    type,\n    kind: 'parsed',\n    radius: '8px',\n  };\n}\n\nfunction getEffectItemLabel(item: EffectItem): string {\n  const option = EFFECT_TYPE_OPTIONS.find((o) => o.value === item.type);\n  if (option) return option.label;\n  if (item.kind === 'raw') return 'Custom Effect';\n  return 'Unknown Effect';\n}\n\nfunction effectItemKey(item: EffectItem): string {\n  if (item.kind === 'raw') return `raw:${item.property}:${item.rawText.trim()}`;\n  if (isShadowEffect(item)) {\n    const css = formatBoxShadow({\n      inset: item.inset,\n      offsetX: item.offsetX,\n      offsetY: item.offsetY,\n      blurRadius: item.blurRadius,\n      spreadRadius: item.spreadRadius,\n      color: item.color,\n    });\n    return `shadow:${item.type}:${css.toLowerCase()}`;\n  }\n  // isBlurEffect(item) must be true at this point\n  return `blur:${item.type}:${item.radius}`;\n}\n\n// -----------------------------------------------------------------------------\n// Parsing & Formatting\n// -----------------------------------------------------------------------------\n\nfunction parseBoxShadowToEffects(raw: string): EffectItem[] {\n  const trimmed = raw.trim();\n  if (!trimmed || trimmed.toLowerCase() === 'none') return [];\n\n  const segments = splitTopLevel(trimmed, ',')\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  const out: EffectItem[] = [];\n\n  for (const seg of segments) {\n    const parsed = parseBoxShadow(seg);\n    if (parsed) {\n      out.push({\n        id: createShadowItemId(),\n        enabled: true,\n        type: parsed.inset ? 'inner-shadow' : 'drop-shadow',\n        kind: 'parsed',\n        inset: parsed.inset,\n        offsetX: parsed.offsetX,\n        offsetY: parsed.offsetY,\n        blurRadius: parsed.blurRadius,\n        spreadRadius: parsed.spreadRadius,\n        color: parsed.color,\n      });\n    } else {\n      out.push({\n        id: createShadowItemId(),\n        enabled: true,\n        type: 'raw',\n        kind: 'raw',\n        property: 'box-shadow',\n        rawText: seg,\n      });\n    }\n  }\n\n  return out;\n}\n\nfunction parseFilterBlurToEffect(\n  raw: string,\n  type: 'layer-blur' | 'backdrop-blur',\n): BlurEffectItem | null {\n  const radius = parseBlurRadius(raw);\n  if (!radius) return null;\n\n  return {\n    id: createShadowItemId(),\n    enabled: true,\n    type,\n    kind: 'parsed',\n    radius,\n  };\n}\n\nfunction formatEffectsToBoxShadow(items: EffectItem[]): string {\n  const parts = items\n    .filter(\n      (item) =>\n        item.enabled &&\n        (isShadowEffect(item) || (item.kind === 'raw' && item.property === 'box-shadow')),\n    )\n    .map((item) => {\n      if (item.kind === 'raw') return item.rawText.trim();\n      if (isShadowEffect(item)) {\n        return formatBoxShadow({\n          inset: item.inset,\n          offsetX: item.offsetX,\n          offsetY: item.offsetY,\n          blurRadius: item.blurRadius,\n          spreadRadius: item.spreadRadius,\n          color: item.color,\n        });\n      }\n      return '';\n    })\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  return parts.join(', ');\n}\n\nfunction getBlurEffectByType(\n  items: EffectItem[],\n  type: 'layer-blur' | 'backdrop-blur',\n): BlurEffectItem | null {\n  const item = items.find((i) => i.type === type && i.enabled);\n  return item && isBlurEffect(item) ? item : null;\n}\n\nfunction reconcileEffectItems(\n  prevItems: EffectItem[],\n  nextEnabledItems: EffectItem[],\n): EffectItem[] {\n  const usedIds = new Set<string>();\n  const pool = new Map<string, EffectItem[]>();\n\n  for (const item of prevItems) {\n    const key = effectItemKey(item);\n    const queue = pool.get(key) ?? [];\n    queue.push(item);\n    pool.set(key, queue);\n  }\n\n  const reconciledEnabled = nextEnabledItems.map((item) => {\n    const key = effectItemKey(item);\n    const queue = pool.get(key);\n    const match = queue?.shift();\n    if (match) {\n      usedIds.add(match.id);\n      return { ...item, id: match.id, enabled: true };\n    }\n    return item;\n  });\n\n  // Keep session-only hidden effects (enabled=false) that are not present in CSS\n  const remainingHidden = prevItems.filter((item) => !item.enabled && !usedIds.has(item.id));\n\n  return [...reconciledEnabled, ...remainingHidden];\n}\n\n// -----------------------------------------------------------------------------\n// DOM Helpers\n// -----------------------------------------------------------------------------\n\nfunction getActiveElementInSameRoot(root: HTMLElement): Element | null {\n  try {\n    const rootNode = root.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement;\n    return document.activeElement;\n  } catch {\n    return null;\n  }\n}\n\nfunction isFocusedWithin(root: HTMLElement): boolean {\n  const active = getActiveElementInSameRoot(root);\n  return active instanceof HTMLElement ? root.contains(active) : false;\n}\n\n// -----------------------------------------------------------------------------\n// SVG Icons\n// -----------------------------------------------------------------------------\n\nfunction createSvgIcon(pathD: string, viewBox = '0 0 24 24'): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', viewBox);\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('d', pathD);\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '2');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n  svg.append(path);\n\n  return svg;\n}\n\nfunction createPlusIcon(): SVGElement {\n  return createSvgIcon('M12 5v14M5 12h14');\n}\n\nfunction createTrashIcon(): SVGElement {\n  return createSvgIcon('M9 6h6M10 6l.5-1.5h3L14 6M7 6l1 14h8l1-14');\n}\n\nfunction createAdjustIcon(): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 20 20');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n\n  const lines = document.createElementNS(SVG_NS, 'path');\n  lines.setAttribute('d', 'M4 5H16 M4 10H16 M4 15H16');\n  lines.setAttribute('stroke', 'currentColor');\n  lines.setAttribute('stroke-width', '2');\n  lines.setAttribute('stroke-linecap', 'round');\n  lines.setAttribute('stroke-linejoin', 'round');\n  svg.append(lines);\n\n  const knobs: ReadonlyArray<readonly [number, number]> = [\n    [7, 5],\n    [13, 10],\n    [9, 15],\n  ];\n\n  for (const [cx, cy] of knobs) {\n    const circle = document.createElementNS(SVG_NS, 'circle');\n    circle.setAttribute('cx', String(cx));\n    circle.setAttribute('cy', String(cy));\n    circle.setAttribute('r', '1.6');\n    circle.setAttribute('fill', 'none');\n    circle.setAttribute('stroke', 'currentColor');\n    circle.setAttribute('stroke-width', '2');\n    svg.append(circle);\n  }\n\n  return svg;\n}\n\nfunction createEyeIcon(enabled: boolean): SVGElement {\n  if (enabled) {\n    const svg = document.createElementNS(SVG_NS, 'svg');\n    svg.setAttribute('viewBox', '0 0 24 24');\n    svg.setAttribute('fill', 'none');\n    svg.setAttribute('aria-hidden', 'true');\n\n    const outline = document.createElementNS(SVG_NS, 'path');\n    outline.setAttribute('d', 'M2.5 12s3.5-7 9.5-7 9.5 7 9.5 7-3.5 7-9.5 7-9.5-7-9.5-7z');\n    outline.setAttribute('stroke', 'currentColor');\n    outline.setAttribute('stroke-width', '2');\n    outline.setAttribute('stroke-linecap', 'round');\n    outline.setAttribute('stroke-linejoin', 'round');\n\n    const iris = document.createElementNS(SVG_NS, 'circle');\n    iris.setAttribute('cx', '12');\n    iris.setAttribute('cy', '12');\n    iris.setAttribute('r', '3');\n    iris.setAttribute('stroke', 'currentColor');\n    iris.setAttribute('stroke-width', '2');\n\n    svg.append(outline, iris);\n    return svg;\n  }\n\n  return createSvgIcon(\n    'M3 3l18 18M10.6 10.6A3 3 0 0012 15a3 3 0 002.4-4.4M9.5 5.8A10.7 10.7 0 0112 5c6 0 9.5 7 9.5 7a17.4 17.4 0 01-3.1 4.1M6.2 6.2A17.8 17.8 0 002.5 12s3.5 7 9.5 7c1 0 1.9-.2 2.8-.5',\n  );\n}\n\nfunction createIconButton(ariaLabel: string): HTMLButtonElement {\n  const btn = document.createElement('button');\n  btn.type = 'button';\n  btn.className = 'we-effects-icon-btn';\n  btn.setAttribute('aria-label', ariaLabel);\n  return btn;\n}\n\n// -----------------------------------------------------------------------------\n// Item View Types\n// -----------------------------------------------------------------------------\n\ninterface EffectItemViewBase {\n  id: string;\n  root: HTMLDivElement;\n  row: HTMLDivElement;\n  adjustBtn: HTMLButtonElement;\n  nameBtn: HTMLButtonElement;\n  eyeBtn: HTMLButtonElement;\n  deleteBtn: HTMLButtonElement;\n  popover: HTMLDivElement;\n  disposer: Disposer;\n  setOpen(open: boolean): void;\n  focusFirst(): void;\n  sync(item: EffectItem): void;\n  dispose(): void;\n}\n\ninterface ShadowEffectItemView extends EffectItemViewBase {\n  viewType: 'shadow';\n  typeSelect: HTMLSelectElement;\n  offsetX: InputContainer;\n  offsetY: InputContainer;\n  blur: InputContainer;\n  spread: InputContainer;\n  colorField: ColorField;\n}\n\ninterface BlurEffectItemView extends EffectItemViewBase {\n  viewType: 'blur';\n  typeSelect: HTMLSelectElement;\n  radiusInput: InputContainer;\n}\n\ninterface RawEffectItemView extends EffectItemViewBase {\n  viewType: 'raw';\n  rawInput: HTMLInputElement;\n}\n\ntype EffectItemView = ShadowEffectItemView | BlurEffectItemView | RawEffectItemView;\n\nfunction getViewTypeForItem(item: EffectItem): EffectItemView['viewType'] {\n  if (item.kind === 'raw') return 'raw';\n  if (isShadowEffect(item)) return 'shadow';\n  if (isBlurEffect(item)) return 'blur';\n  return 'raw';\n}\n\n// -----------------------------------------------------------------------------\n// Main Factory\n// -----------------------------------------------------------------------------\n\nexport function createEffectsControl(options: EffectsControlOptions): DesignControl {\n  const { container, transactionManager, tokensService, headerActionsContainer } = options;\n  const disposer = new Disposer();\n\n  // 每个元素的 effect items 缓存（仅限当前编辑会话）\n  // 使用 WeakMap 的原因：\n  // 1. 隐藏的 effect 不会写入 CSS（enabled=false），但需要在会话内记住以便恢复\n  // 2. WeakMap 保证元素被移除时自动释放内存，无需手动清理\n  // 3. 只读取 inline style（不读 computed），因此缓存仅用于保留用户的隐藏操作\n  const perTargetItems = new WeakMap<Element, EffectItem[]>();\n\n  let currentTarget: Element | null = null;\n  let currentItems: EffectItem[] = [];\n  let itemsById = new Map<string, EffectItem>();\n  let openItemId: string | null = null;\n  let activeHandle: StyleTransactionHandle | null = null;\n  let activeProperty: string | null = null;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-field-group we-effects';\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // Add button - placed in header if available, otherwise in toolbar\n  const addBtn = document.createElement('button');\n  addBtn.type = 'button';\n  addBtn.className = 'we-effects-icon-btn';\n  addBtn.setAttribute('aria-label', 'Add effect');\n  addBtn.append(createPlusIcon());\n\n  if (headerActionsContainer) {\n    // 将 + 按钮放在 group header 的右侧（chevron 左边）\n    headerActionsContainer.insertBefore(addBtn, headerActionsContainer.firstChild);\n    disposer.add(() => addBtn.remove());\n  } else {\n    // 回退：在内容区域显示 toolbar\n    const toolbar = document.createElement('div');\n    toolbar.className = 'we-effects-toolbar';\n    toolbar.append(addBtn);\n    root.append(toolbar);\n  }\n\n  // Effect list container\n  const list = document.createElement('div');\n  list.className = 'we-effects-list';\n\n  root.append(list);\n\n  // View registry\n  const views = new Map<string, EffectItemView>();\n\n  // -------------------------------------------------------------------------\n  // State Management\n  // -------------------------------------------------------------------------\n\n  function setCurrentItems(next: EffectItem[]): void {\n    currentItems = next;\n    itemsById = new Map(next.map((i) => [i.id, i]));\n    const target = currentTarget;\n    if (target) perTargetItems.set(target, next);\n  }\n\n  function getItem(id: string): EffectItem | null {\n    return itemsById.get(id) ?? null;\n  }\n\n  // -------------------------------------------------------------------------\n  // Transaction Management\n  // -------------------------------------------------------------------------\n\n  function beginTransaction(property: string): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n    if (activeHandle && activeProperty === property) return activeHandle;\n    // Commit previous if different property\n    if (activeHandle) activeHandle.commit({ merge: true });\n    activeHandle = transactionManager.beginStyle(target, property);\n    activeProperty = property;\n    return activeHandle;\n  }\n\n  function commitTransaction(): void {\n    const handle = activeHandle;\n    activeHandle = null;\n    activeProperty = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(): void {\n    const handle = activeHandle;\n    activeHandle = null;\n    activeProperty = null;\n    if (handle) handle.rollback();\n  }\n\n  function isEditing(): boolean {\n    // 只在有打开的 popover 或正在进行事务时阻止刷新\n    // 避免过于宽泛的 focus 检测导致外部样式变化无法同步\n    return activeHandle !== null || openItemId !== null;\n  }\n\n  // -------------------------------------------------------------------------\n  // Preview & Apply\n  // -------------------------------------------------------------------------\n\n  function previewCurrentItems(): void {\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    // Preview box-shadow\n    const shadowHandle = beginTransaction(BOX_SHADOW_PROPERTY);\n    if (shadowHandle) {\n      shadowHandle.set(formatEffectsToBoxShadow(currentItems));\n    }\n\n    // Preview filter blur\n    const layerBlur = getBlurEffectByType(currentItems, 'layer-blur');\n    if (layerBlur) {\n      const filterHandle = beginTransaction('filter');\n      if (filterHandle) {\n        const existing = readInlineValue(target, 'filter');\n        filterHandle.set(upsertBlurFunction(existing, layerBlur.radius));\n      }\n    }\n\n    // Preview backdrop-filter blur\n    const backdropBlur = getBlurEffectByType(currentItems, 'backdrop-blur');\n    if (backdropBlur) {\n      const backdropHandle = beginTransaction('backdrop-filter');\n      if (backdropHandle) {\n        const existing = readInlineValue(target, 'backdrop-filter');\n        backdropHandle.set(upsertBlurFunction(existing, backdropBlur.radius));\n      }\n    }\n  }\n\n  function applyCurrentItemsDiscrete(): void {\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n    commitTransaction();\n\n    // Apply box-shadow\n    transactionManager.applyStyle(\n      target,\n      BOX_SHADOW_PROPERTY,\n      formatEffectsToBoxShadow(currentItems),\n      {\n        merge: false,\n      },\n    );\n\n    // Apply filter blur\n    const layerBlur = getBlurEffectByType(currentItems, 'layer-blur');\n    const existingFilter = readInlineValue(target, 'filter');\n    transactionManager.applyStyle(\n      target,\n      'filter',\n      upsertBlurFunction(existingFilter, layerBlur?.radius ?? ''),\n      {\n        merge: false,\n      },\n    );\n\n    // Apply backdrop-filter blur\n    const backdropBlur = getBlurEffectByType(currentItems, 'backdrop-blur');\n    const existingBackdrop = readInlineValue(target, 'backdrop-filter');\n    transactionManager.applyStyle(\n      target,\n      'backdrop-filter',\n      upsertBlurFunction(existingBackdrop, backdropBlur?.radius ?? ''),\n      {\n        merge: false,\n      },\n    );\n  }\n\n  // -------------------------------------------------------------------------\n  // Popover Management\n  // -------------------------------------------------------------------------\n\n  function closePopover(opts?: { commit?: boolean; rollback?: boolean }): void {\n    const commit = opts?.commit ?? false;\n    const rollback = opts?.rollback ?? false;\n\n    if (rollback) rollbackTransaction();\n    else if (commit) commitTransaction();\n\n    const wasOpen = openItemId !== null;\n    openItemId = null;\n    for (const view of views.values()) view.setOpen(false);\n\n    // 关闭后同步一次，确保 currentItems 与真实 inline 一致\n    // 避免浏览器归一化/修正值后产生不一致\n    if (wasOpen && !rollback) {\n      syncFromTarget(true);\n    }\n  }\n\n  function setPopoverOpen(id: string | null): void {\n    if (id === openItemId) {\n      closePopover({ commit: true });\n      return;\n    }\n\n    closePopover({ commit: true });\n\n    if (!id) return;\n    const view = views.get(id);\n    if (!view) return;\n\n    openItemId = id;\n    for (const [vid, v] of views) v.setOpen(vid === id);\n    view.focusFirst();\n  }\n\n  // -------------------------------------------------------------------------\n  // Input Helpers\n  // -------------------------------------------------------------------------\n\n  function setLengthInput(containerRef: InputContainer, raw: string): void {\n    const formatted = formatLengthForDisplay(raw);\n    containerRef.input.value = formatted.value;\n    containerRef.setSuffix(formatted.suffix);\n  }\n\n  // -------------------------------------------------------------------------\n  // Effect Type Conversion\n  // -------------------------------------------------------------------------\n\n  /**\n   * Convert an effect item to a new type, preserving compatible fields.\n   */\n  function createEffectItemWithType(\n    prev: EffectItem,\n    nextType: EffectTypeValue,\n  ): EffectItem | null {\n    if (prev.kind === 'raw') return null;\n\n    // Convert to blur type\n    if (nextType === 'layer-blur' || nextType === 'backdrop-blur') {\n      const base = createDefaultBlurEffect(nextType);\n      // Map blur radius from previous effect\n      const mappedRadius = isBlurEffect(prev)\n        ? prev.radius\n        : isShadowEffect(prev)\n          ? prev.blurRadius\n          : base.radius;\n      return {\n        ...base,\n        id: prev.id,\n        enabled: prev.enabled,\n        radius: mappedRadius || base.radius,\n      };\n    }\n\n    // Convert to shadow type\n    const base = createDefaultShadowEffect();\n    const shadowPrev = isShadowEffect(prev) ? prev : null;\n    const blurPrev = isBlurEffect(prev) ? prev : null;\n    const mappedBlurRadius = shadowPrev?.blurRadius ?? blurPrev?.radius ?? base.blurRadius;\n\n    return {\n      ...base,\n      id: prev.id,\n      enabled: prev.enabled,\n      type: nextType,\n      inset: nextType === 'inner-shadow',\n      offsetX: shadowPrev?.offsetX ?? base.offsetX,\n      offsetY: shadowPrev?.offsetY ?? base.offsetY,\n      blurRadius: mappedBlurRadius || base.blurRadius,\n      spreadRadius: shadowPrev?.spreadRadius ?? base.spreadRadius,\n      color: shadowPrev?.color ?? base.color,\n    };\n  }\n\n  /**\n   * Update an effect item's type, potentially converting between shadow/blur.\n   */\n  function updateEffectItemType(id: string, nextType: EffectTypeValue): void {\n    const prev = getItem(id);\n    if (!prev || prev.kind === 'raw') return;\n    if (prev.type === nextType) return;\n\n    const nextItem = createEffectItemWithType(prev, nextType);\n    if (!nextItem) return;\n\n    let nextItems = currentItems.map((it) => (it.id === id ? nextItem : it));\n\n    // Only one blur effect per type (filter/backdrop-filter) is supported\n    if (nextItem.type === 'layer-blur' || nextItem.type === 'backdrop-blur') {\n      nextItems = nextItems.filter((it) => it.id === id || it.type !== nextItem.type);\n    }\n\n    setCurrentItems(nextItems);\n    renderList();\n    applyCurrentItemsDiscrete();\n\n    // The view might have been recreated (shadow <-> blur), restore focus\n    if (openItemId === id) {\n      views.get(id)?.focusFirst();\n    }\n  }\n\n  // -------------------------------------------------------------------------\n  // Item View Factory\n  // -------------------------------------------------------------------------\n\n  function createItemView(item: EffectItem): EffectItemView {\n    const itemDisposer = new Disposer();\n\n    const wrap = document.createElement('div');\n    wrap.className = 'we-effects-item-wrap';\n\n    const row = document.createElement('div');\n    row.className = 'we-effects-item';\n    row.dataset.enabled = item.enabled ? 'true' : 'false';\n    row.dataset.open = 'false';\n\n    const adjustBtn = createIconButton('Adjust effect');\n    adjustBtn.append(createAdjustIcon());\n\n    const nameBtn = document.createElement('button');\n    nameBtn.type = 'button';\n    nameBtn.className = 'we-effects-name';\n\n    const eyeBtn = createIconButton('Toggle visibility');\n\n    const deleteBtn = createIconButton('Remove effect');\n    deleteBtn.append(createTrashIcon());\n\n    row.append(adjustBtn, nameBtn, eyeBtn, deleteBtn);\n\n    const popover = document.createElement('div');\n    popover.className = 'we-effects-popover';\n    popover.hidden = true;\n\n    wrap.append(row, popover);\n\n    // Common event handlers\n    itemDisposer.listen(adjustBtn, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setPopoverOpen(item.id);\n    });\n\n    itemDisposer.listen(nameBtn, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setPopoverOpen(item.id);\n    });\n\n    itemDisposer.listen(eyeBtn, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      const it = getItem(item.id);\n      if (!it) return;\n      it.enabled = !it.enabled;\n      row.dataset.enabled = it.enabled ? 'true' : 'false';\n      eyeBtn.replaceChildren(createEyeIcon(it.enabled));\n      applyCurrentItemsDiscrete();\n    });\n\n    itemDisposer.listen(deleteBtn, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      if (openItemId === item.id) closePopover({ commit: true });\n      setCurrentItems(currentItems.filter((i) => i.id !== item.id));\n      views.get(item.id)?.dispose();\n      views.delete(item.id);\n      renderList();\n      applyCurrentItemsDiscrete();\n    });\n\n    // Raw shadow item view\n    if (item.kind === 'raw') {\n      const content = document.createElement('div');\n      content.className = 'we-effects-popover-content';\n\n      const field = document.createElement('div');\n      field.className = 'we-field';\n\n      const label = document.createElement('span');\n      label.className = 'we-field-label';\n      label.textContent = 'Value';\n\n      const input = document.createElement('input');\n      input.type = 'text';\n      input.className = 'we-input';\n      input.autocomplete = 'off';\n      input.spellcheck = false;\n      input.setAttribute('aria-label', 'Shadow value');\n\n      field.append(label, input);\n      content.append(field);\n      popover.append(content);\n\n      itemDisposer.listen(input, 'input', () => {\n        const it = getItem(item.id);\n        if (!it || it.kind !== 'raw') return;\n        it.rawText = input.value;\n        previewCurrentItems();\n      });\n\n      itemDisposer.listen(input, 'blur', () => {\n        commitTransaction();\n      });\n\n      itemDisposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n        if (e.key === 'Enter') {\n          e.preventDefault();\n          commitTransaction();\n          input.blur();\n        } else if (e.key === 'Escape') {\n          e.preventDefault();\n          closePopover({ rollback: true });\n          syncFromTarget(true);\n        }\n      });\n\n      const view: RawEffectItemView = {\n        id: item.id,\n        viewType: 'raw',\n        root: wrap,\n        row,\n        adjustBtn,\n        nameBtn,\n        eyeBtn,\n        deleteBtn,\n        popover,\n        rawInput: input,\n        disposer: itemDisposer,\n        setOpen(open: boolean): void {\n          row.dataset.open = open ? 'true' : 'false';\n          popover.hidden = !open;\n        },\n        focusFirst(): void {\n          input.focus();\n          input.select();\n        },\n        sync(next: EffectItem): void {\n          row.dataset.enabled = next.enabled ? 'true' : 'false';\n          nameBtn.textContent = getEffectItemLabel(next);\n          eyeBtn.replaceChildren(createEyeIcon(next.enabled));\n          if (next.kind === 'raw') input.value = next.rawText;\n        },\n        dispose(): void {\n          itemDisposer.dispose();\n          wrap.remove();\n        },\n      };\n\n      return view;\n    }\n\n    // Blur effect view (Layer Blur / Backdrop Blur)\n    if (isBlurEffect(item)) {\n      const content = document.createElement('div');\n      content.className = 'we-effects-popover-content';\n\n      // Type select (only blur types)\n      const typeField = document.createElement('div');\n      typeField.className = 'we-field';\n\n      const typeLabel = document.createElement('span');\n      typeLabel.className = 'we-field-label';\n      typeLabel.textContent = 'Type';\n\n      const typeSelect = document.createElement('select');\n      typeSelect.className = 'we-select';\n      typeSelect.setAttribute('aria-label', 'Effect type');\n\n      for (const v of EFFECT_TYPE_OPTIONS) {\n        const opt = document.createElement('option');\n        opt.value = v.value;\n        opt.textContent = v.label;\n        typeSelect.append(opt);\n      }\n\n      typeField.append(typeLabel, typeSelect);\n\n      // Radius input\n      const radiusField = document.createElement('div');\n      radiusField.className = 'we-field';\n\n      const radiusLabel = document.createElement('span');\n      radiusLabel.className = 'we-field-label';\n      radiusLabel.textContent = 'Blur';\n\n      const radiusInput = createInputContainer({\n        ariaLabel: 'Blur radius',\n        inputMode: 'decimal',\n        suffix: 'px',\n      });\n\n      radiusField.append(radiusLabel, radiusInput.root);\n      content.append(typeField, radiusField);\n      popover.append(content);\n\n      wireNumberStepping(itemDisposer, radiusInput.input, {\n        mode: 'css-length',\n        min: 0,\n        step: 1,\n        shiftStep: 10,\n        altStep: 0.1,\n      });\n\n      itemDisposer.listen(radiusInput.input, 'input', () => {\n        const it = getItem(item.id);\n        if (!it || !isBlurEffect(it)) return;\n        it.radius = combineLengthValue(radiusInput.input.value, radiusInput.getSuffixText());\n        previewCurrentItems();\n      });\n\n      itemDisposer.listen(radiusInput.input, 'blur', () => {\n        commitTransaction();\n        syncFromTarget(true);\n      });\n\n      itemDisposer.listen(radiusInput.input, 'keydown', (e: KeyboardEvent) => {\n        if (e.key === 'Enter') {\n          e.preventDefault();\n          commitTransaction();\n          syncFromTarget(true);\n          radiusInput.input.blur();\n        } else if (e.key === 'Escape') {\n          e.preventDefault();\n          closePopover({ rollback: true });\n          syncFromTarget(true);\n        }\n      });\n\n      const onTypeChange = () => {\n        updateEffectItemType(item.id, typeSelect.value as EffectTypeValue);\n      };\n\n      itemDisposer.listen(typeSelect, 'input', onTypeChange);\n      itemDisposer.listen(typeSelect, 'change', onTypeChange);\n\n      const view: BlurEffectItemView = {\n        id: item.id,\n        viewType: 'blur',\n        root: wrap,\n        row,\n        adjustBtn,\n        nameBtn,\n        eyeBtn,\n        deleteBtn,\n        popover,\n        disposer: itemDisposer,\n        typeSelect,\n        radiusInput,\n        setOpen(open: boolean): void {\n          row.dataset.open = open ? 'true' : 'false';\n          popover.hidden = !open;\n        },\n        focusFirst(): void {\n          radiusInput.input.focus();\n          radiusInput.input.select();\n        },\n        sync(next: EffectItem): void {\n          row.dataset.enabled = next.enabled ? 'true' : 'false';\n          nameBtn.textContent = getEffectItemLabel(next);\n          eyeBtn.replaceChildren(createEyeIcon(next.enabled));\n          if (!isBlurEffect(next)) return;\n          typeSelect.value = next.type;\n          setLengthInput(radiusInput, next.radius);\n        },\n        dispose(): void {\n          itemDisposer.dispose();\n          wrap.remove();\n        },\n      };\n\n      return view;\n    }\n\n    // Shadow effect view (Drop Shadow / Inner Shadow)\n    const content = document.createElement('div');\n    content.className = 'we-effects-popover-content';\n\n    // Type select (only shadow types)\n    const typeField = document.createElement('div');\n    typeField.className = 'we-field';\n\n    const typeLabel = document.createElement('span');\n    typeLabel.className = 'we-field-label';\n    typeLabel.textContent = 'Type';\n\n    const typeSelect = document.createElement('select');\n    typeSelect.className = 'we-select';\n    typeSelect.setAttribute('aria-label', 'Effect type');\n\n    for (const v of EFFECT_TYPE_OPTIONS) {\n      const opt = document.createElement('option');\n      opt.value = v.value;\n      opt.textContent = v.label;\n      typeSelect.append(opt);\n    }\n\n    typeField.append(typeLabel, typeSelect);\n\n    // X/Y row\n    const xyRow = document.createElement('div');\n    xyRow.className = 'we-field-row';\n\n    const x = createInputContainer({\n      ariaLabel: 'Shadow offset X',\n      inputMode: 'decimal',\n      prefix: 'X',\n      suffix: 'px',\n    });\n    const y = createInputContainer({\n      ariaLabel: 'Shadow offset Y',\n      inputMode: 'decimal',\n      prefix: 'Y',\n      suffix: 'px',\n    });\n    xyRow.append(x.root, y.root);\n\n    // Blur/Spread row\n    const blurRow = document.createElement('div');\n    blurRow.className = 'we-field-row';\n\n    const blur = createInputContainer({\n      ariaLabel: 'Shadow blur radius',\n      inputMode: 'decimal',\n      prefix: 'B',\n      suffix: 'px',\n    });\n    const spread = createInputContainer({\n      ariaLabel: 'Shadow spread radius',\n      inputMode: 'decimal',\n      prefix: 'S',\n      suffix: 'px',\n    });\n    blurRow.append(blur.root, spread.root);\n\n    // Color field\n    const colorFieldRow = document.createElement('div');\n    colorFieldRow.className = 'we-field';\n\n    const colorLabel = document.createElement('span');\n    colorLabel.className = 'we-field-label';\n    colorLabel.textContent = 'Color';\n\n    const colorMount = document.createElement('div');\n    colorMount.style.minWidth = '0';\n\n    colorFieldRow.append(colorLabel, colorMount);\n\n    content.append(typeField, xyRow, blurRow, colorFieldRow);\n    popover.append(content);\n\n    // Wire number stepping\n    wireNumberStepping(itemDisposer, x.input, { mode: 'css-length' });\n    wireNumberStepping(itemDisposer, y.input, { mode: 'css-length' });\n    wireNumberStepping(itemDisposer, blur.input, {\n      mode: 'css-length',\n      min: 0,\n      step: 1,\n      shiftStep: 10,\n      altStep: 0.1,\n    });\n    wireNumberStepping(itemDisposer, spread.input, {\n      mode: 'css-length',\n      step: 1,\n      shiftStep: 10,\n      altStep: 0.1,\n    });\n\n    // Create color field\n    const colorField = createColorField({\n      container: colorMount,\n      ariaLabel: 'Shadow color',\n      tokensService,\n      getTokenTarget: () => currentTarget,\n      onInput: (value) => {\n        const it = getItem(item.id);\n        if (!it || !isShadowEffect(it)) return;\n        it.color = value;\n        previewCurrentItems();\n      },\n      onCommit: () => {\n        commitTransaction();\n      },\n      onCancel: () => {\n        rollbackTransaction();\n        syncFromTarget(true);\n      },\n    });\n    itemDisposer.add(() => colorField.dispose());\n\n    // Wire length field handlers\n    const wireShadowLengthField = (\n      containerRef: InputContainer,\n      key: keyof Pick<ShadowEffectItem, 'offsetX' | 'offsetY' | 'blurRadius' | 'spreadRadius'>,\n    ) => {\n      itemDisposer.listen(containerRef.input, 'input', () => {\n        const it = getItem(item.id);\n        if (!it || !isShadowEffect(it)) return;\n        const next = combineLengthValue(containerRef.input.value, containerRef.getSuffixText());\n        it[key] = next;\n        previewCurrentItems();\n      });\n\n      itemDisposer.listen(containerRef.input, 'blur', () => {\n        commitTransaction();\n        syncFromTarget(true);\n      });\n\n      itemDisposer.listen(containerRef.input, 'keydown', (e: KeyboardEvent) => {\n        if (e.key === 'Enter') {\n          e.preventDefault();\n          commitTransaction();\n          syncFromTarget(true);\n          containerRef.input.blur();\n        } else if (e.key === 'Escape') {\n          e.preventDefault();\n          rollbackTransaction();\n          syncFromTarget(true);\n        }\n      });\n    };\n\n    wireShadowLengthField(x, 'offsetX');\n    wireShadowLengthField(y, 'offsetY');\n    wireShadowLengthField(blur, 'blurRadius');\n    wireShadowLengthField(spread, 'spreadRadius');\n\n    // Type change handler\n    const onTypeChange = () => {\n      updateEffectItemType(item.id, typeSelect.value as EffectTypeValue);\n    };\n\n    itemDisposer.listen(typeSelect, 'input', onTypeChange);\n    itemDisposer.listen(typeSelect, 'change', onTypeChange);\n\n    const view: ShadowEffectItemView = {\n      id: item.id,\n      viewType: 'shadow',\n      root: wrap,\n      row,\n      adjustBtn,\n      nameBtn,\n      eyeBtn,\n      deleteBtn,\n      popover,\n      disposer: itemDisposer,\n      typeSelect,\n      offsetX: x,\n      offsetY: y,\n      blur,\n      spread,\n      colorField,\n      setOpen(open: boolean): void {\n        row.dataset.open = open ? 'true' : 'false';\n        popover.hidden = !open;\n      },\n      focusFirst(): void {\n        typeSelect.focus();\n      },\n      sync(next: EffectItem): void {\n        row.dataset.enabled = next.enabled ? 'true' : 'false';\n        nameBtn.textContent = getEffectItemLabel(next);\n        eyeBtn.replaceChildren(createEyeIcon(next.enabled));\n        if (!isShadowEffect(next)) return;\n        typeSelect.value = next.type;\n        setLengthInput(x, next.offsetX);\n        setLengthInput(y, next.offsetY);\n        setLengthInput(blur, next.blurRadius);\n        setLengthInput(spread, next.spreadRadius);\n        colorField.setValue(next.color);\n      },\n      dispose(): void {\n        itemDisposer.dispose();\n        wrap.remove();\n      },\n    };\n\n    return view;\n  }\n\n  // -------------------------------------------------------------------------\n  // List Rendering\n  // -------------------------------------------------------------------------\n\n  function renderList(): void {\n    const ids = new Set(currentItems.map((i) => i.id));\n\n    // Remove stale views\n    for (const [id, view] of Array.from(views.entries())) {\n      if (!ids.has(id)) {\n        if (openItemId === id) openItemId = null;\n        view.dispose();\n        views.delete(id);\n      }\n    }\n\n    // Create/update views\n    for (const item of currentItems) {\n      const existing = views.get(item.id);\n      const expectedViewType = getViewTypeForItem(item);\n      if (!existing || existing.viewType !== expectedViewType) {\n        existing?.dispose();\n        views.set(item.id, createItemView(item));\n      }\n\n      const view = views.get(item.id)!;\n      view.sync(item);\n      view.setOpen(openItemId === item.id);\n      list.append(view.root);\n    }\n  }\n\n  // -------------------------------------------------------------------------\n  // Sync from Target\n  // -------------------------------------------------------------------------\n\n  function syncFromTarget(force = false): void {\n    const target = currentTarget;\n\n    if (!target || !target.isConnected) {\n      addBtn.disabled = true;\n      setCurrentItems([]);\n      closePopover({ commit: true });\n      renderList();\n      return;\n    }\n\n    addBtn.disabled = false;\n    if (!force && isEditing()) return;\n\n    // Parse box-shadow effects\n    const boxShadowInline = readInlineValue(target, BOX_SHADOW_PROPERTY);\n    const shadowEffects = parseBoxShadowToEffects(boxShadowInline);\n\n    // Parse filter blur\n    const filterInline = readInlineValue(target, 'filter');\n    const layerBlur = parseFilterBlurToEffect(filterInline, 'layer-blur');\n\n    // Parse backdrop-filter blur\n    const backdropInline = readInlineValue(target, 'backdrop-filter');\n    const backdropBlur = parseFilterBlurToEffect(backdropInline, 'backdrop-blur');\n\n    // Combine all enabled effects\n    const nextEnabled: EffectItem[] = [\n      ...shadowEffects,\n      ...(layerBlur ? [layerBlur] : []),\n      ...(backdropBlur ? [backdropBlur] : []),\n    ];\n\n    const prev = perTargetItems.get(target) ?? [];\n    setCurrentItems(reconcileEffectItems(prev, nextEnabled));\n    renderList();\n  }\n\n  // -------------------------------------------------------------------------\n  // Event Handlers\n  // -------------------------------------------------------------------------\n\n  disposer.listen(addBtn, 'click', (e: MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    closePopover({ commit: true });\n    const newEffect = createDefaultShadowEffect();\n    const next = [...currentItems, newEffect];\n    setCurrentItems(next);\n    renderList();\n    applyCurrentItemsDiscrete();\n    setPopoverOpen(newEffect.id);\n  });\n\n  // Close popover when clicking outside the open item\n  // 使用 document 的捕获阶段监听，确保点击 Effects 控件外也能关闭\n  const handleClickOutside = (e: MouseEvent) => {\n    const openId = openItemId;\n    if (!openId) return;\n    const view = views.get(openId);\n    if (!view) return;\n\n    // Use composedPath to handle Shadow DOM event retargeting\n    const path = typeof e.composedPath === 'function' ? e.composedPath() : [];\n    const clickedInside =\n      path.length > 0\n        ? path.includes(view.root)\n        : (() => {\n            const node = e.target as Node | null;\n            return !!(node && view.root.contains(node));\n          })();\n\n    if (clickedInside) return;\n    closePopover({ commit: true });\n  };\n\n  // 在 document 上监听捕获阶段的点击事件\n  const doc = root.ownerDocument;\n  doc.addEventListener('click', handleClickOutside, true);\n  disposer.add(() => doc.removeEventListener('click', handleClickOutside, true));\n\n  // Escape closes the popover and rolls back the current preview transaction\n  // 在 root 上监听捕获阶段的键盘事件\n  const handleEscape = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape') return;\n    if (!openItemId) return;\n    e.preventDefault();\n    e.stopPropagation();\n    closePopover({ rollback: true });\n    syncFromTarget(true);\n  };\n\n  root.addEventListener('keydown', handleEscape, true);\n  disposer.add(() => root.removeEventListener('keydown', handleEscape, true));\n\n  // -------------------------------------------------------------------------\n  // DesignControl Interface\n  // -------------------------------------------------------------------------\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    if (element !== currentTarget) {\n      commitTransaction();\n      closePopover({ commit: true });\n    }\n\n    currentTarget = element;\n\n    if (element && element.isConnected) {\n      setCurrentItems(perTargetItems.get(element) ?? []);\n    } else {\n      setCurrentItems([]);\n    }\n\n    syncFromTarget(true);\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncFromTarget(false);\n  }\n\n  function dispose(): void {\n    commitTransaction();\n    currentTarget = null;\n    for (const view of views.values()) view.dispose();\n    views.clear();\n    disposer.dispose();\n  }\n\n  // Initialize\n  syncFromTarget(true);\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/gradient-control.ts",
    "content": "/**\n * Gradient Control\n *\n * Edits inline `background-image` gradients:\n * - linear-gradient(<angle>deg, <stop1>, <stop2>, ...)\n * - radial-gradient([<shape>] [at <x>% <y>%], <stop1>, <stop2>, ...)\n *\n * Supports:\n * - Multiple color stops (2+)\n * - Numeric angles (deg) and percent positions\n *\n * Current UI Limitation:\n * - UI currently edits only the first 2 stops (parser supports N stops)\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignTokensService } from '../../../core/design-tokens';\nimport { createColorField, type ColorField } from './color-field';\nimport { wireNumberStepping } from './number-stepping';\nimport type { DesignControl } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst GRADIENT_TYPES = [\n  { value: 'none', label: 'None' },\n  { value: 'linear', label: 'Linear' },\n  { value: 'radial', label: 'Radial' },\n] as const;\n\ntype GradientType = (typeof GRADIENT_TYPES)[number]['value'];\n\nconst RADIAL_SHAPES = [\n  { value: 'ellipse', label: 'Ellipse' },\n  { value: 'circle', label: 'Circle' },\n] as const;\n\ntype RadialShape = (typeof RADIAL_SHAPES)[number]['value'];\n\nconst DEFAULT_LINEAR_ANGLE = 180;\nconst DEFAULT_POSITION = 50;\n\nconst DEFAULT_STOP_1: GradientStop = { color: '#000000', position: 0 };\nconst DEFAULT_STOP_2: GradientStop = { color: '#ffffff', position: 100 };\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Unique identifier for a gradient stop (stable across reorder/edit) */\ntype StopId = string;\n\n/** Model for a gradient stop with stable identity */\ninterface StopModel {\n  id: StopId;\n  color: string;\n  position: number;\n  /** Resolved/computed color for display when color contains var() */\n  placeholderColor?: string;\n}\n\n/**\n * Drag session state for thumb dragging.\n * Tracks the active drag operation with all data needed for\n * real-time preview and rollback on cancel.\n */\ninterface ThumbDragSession {\n  /** ID of the stop being dragged */\n  stopId: StopId;\n  /** Pointer identifier for the drag gesture (used to filter multi-touch) */\n  pointerId: number;\n  /** Position snapshot before drag started (for rollback on Escape) */\n  initialPositions: Map<StopId, number>;\n  /** The thumb element being dragged (for pointer capture) */\n  thumbElement: HTMLElement;\n}\n\n/**\n * Keyboard session state for thumb stepping (Arrow keys).\n * Maintains a snapshot for Escape rollback and keeps thumbs stable during stepping.\n */\ninterface ThumbKeyboardSession {\n  /** ID of the stop being adjusted */\n  stopId: StopId;\n  /** Position snapshot before stepping started (for rollback on Escape) */\n  initialPositions: Map<StopId, number>;\n  /** The thumb element being adjusted (focus anchor) */\n  thumbElement: HTMLElement;\n}\n\n/** Basic gradient stop (used in parsing and UI state) */\ninterface GradientStop {\n  color: string;\n  position: number;\n  /**\n   * Resolved/computed color when `color` contains var().\n   * Populated during sync when inline value contains CSS variables.\n   */\n  placeholderColor?: string;\n}\n\ninterface ParsedLinearGradient {\n  type: 'linear';\n  angle: number;\n  stops: GradientStop[];\n}\n\ninterface ParsedRadialGradient {\n  type: 'radial';\n  shape: RadialShape;\n  position: { x: number; y: number } | null;\n  stops: GradientStop[];\n}\n\ntype ParsedGradient = ParsedLinearGradient | ParsedRadialGradient;\n\ninterface ParsedStop {\n  color: string;\n  position: number | null;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nlet stopIdCounter = 0;\n\n/**\n * Generate a unique stop ID using crypto.randomUUID when available,\n * falling back to a counter-based ID.\n */\nfunction createStopId(): StopId {\n  try {\n    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n      return crypto.randomUUID();\n    }\n  } catch {\n    // Fallback to counter\n  }\n  stopIdCounter += 1;\n  return `stop_${stopIdCounter}_${Date.now()}`;\n}\n\n/** Create default stop models with unique IDs */\nfunction createDefaultStopModels(): StopModel[] {\n  return [\n    { id: createStopId(), color: DEFAULT_STOP_1.color, position: DEFAULT_STOP_1.position },\n    { id: createStopId(), color: DEFAULT_STOP_2.color, position: DEFAULT_STOP_2.position },\n  ];\n}\n\n/** Convert GradientStop[] to StopModel[] (assigns new IDs) */\nfunction toStopModels(stops: GradientStop[]): StopModel[] {\n  return stops.map((s) => ({\n    id: createStopId(),\n    color: s.color,\n    position: s.position,\n    placeholderColor: s.placeholderColor,\n  }));\n}\n\n/**\n * Reconcile new stops with existing models to preserve stable IDs.\n * Uses index-based matching when stop count is the same, otherwise creates new models.\n */\nfunction reconcileStopModels(prevModels: StopModel[], newStops: GradientStop[]): StopModel[] {\n  // If count matches, preserve IDs by index\n  if (prevModels.length === newStops.length) {\n    return newStops.map((stop, i) => ({\n      id: prevModels[i]?.id ?? createStopId(),\n      color: stop.color,\n      position: stop.position,\n      placeholderColor: stop.placeholderColor,\n    }));\n  }\n\n  // Count mismatch: create fresh models\n  return toStopModels(newStops);\n}\n\n/** Get the preview color for a stop (resolved color if contains var(), otherwise raw color) */\nfunction getStopPreviewColor(stop: Pick<StopModel, 'color' | 'placeholderColor'>): string {\n  if (needsColorPlaceholder(stop.color)) {\n    const c = stop.placeholderColor?.trim();\n    return c ? c : 'transparent';\n  }\n  return stop.color;\n}\n\n/** Convert StopModel[] to GradientStop[] for preview rendering */\nfunction toPreviewStops(stops: StopModel[]): GradientStop[] {\n  return stops.map((s) => ({\n    color: getStopPreviewColor(s),\n    position: s.position,\n  }));\n}\n\n// =============================================================================\n// Color Interpolation (Phase 6)\n// =============================================================================\n\n/** RGBA color representation for interpolation */\ninterface RgbaColor {\n  r: number;\n  g: number;\n  b: number;\n  a: number;\n}\n\n/** Clamp a value to byte range [0, 255] */\nfunction clampByte(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return Math.max(0, Math.min(255, Math.round(value)));\n}\n\n/** Convert a byte value to 2-digit hex string */\nfunction toHexByte(value: number): string {\n  return clampByte(value).toString(16).padStart(2, '0');\n}\n\n/** Convert RGBA to CSS color string (hex or rgba) */\nfunction rgbaToCss(color: RgbaColor): string {\n  const a = clampNumber(color.a, 0, 1);\n  if (a >= 1) {\n    return `#${toHexByte(color.r)}${toHexByte(color.g)}${toHexByte(color.b)}`;\n  }\n  const alpha = Math.round(a * 1000) / 1000;\n  return `rgba(${clampByte(color.r)}, ${clampByte(color.g)}, ${clampByte(color.b)}, ${alpha})`;\n}\n\n/** Parse hex color (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) to RGBA */\nfunction parseHexColorToRgba(raw: string): RgbaColor | null {\n  const v = raw.trim().toLowerCase();\n  if (!v.startsWith('#')) return null;\n\n  // #RGB\n  if (/^#[0-9a-f]{3}$/.test(v)) {\n    const r = Number.parseInt(v[1]! + v[1]!, 16);\n    const g = Number.parseInt(v[2]! + v[2]!, 16);\n    const b = Number.parseInt(v[3]! + v[3]!, 16);\n    return { r, g, b, a: 1 };\n  }\n\n  // #RGBA\n  if (/^#[0-9a-f]{4}$/.test(v)) {\n    const r = Number.parseInt(v[1]! + v[1]!, 16);\n    const g = Number.parseInt(v[2]! + v[2]!, 16);\n    const b = Number.parseInt(v[3]! + v[3]!, 16);\n    const a = Number.parseInt(v[4]! + v[4]!, 16) / 255;\n    return { r, g, b, a };\n  }\n\n  // #RRGGBB\n  if (/^#[0-9a-f]{6}$/.test(v)) {\n    const r = Number.parseInt(v.slice(1, 3), 16);\n    const g = Number.parseInt(v.slice(3, 5), 16);\n    const b = Number.parseInt(v.slice(5, 7), 16);\n    return { r, g, b, a: 1 };\n  }\n\n  // #RRGGBBAA\n  if (/^#[0-9a-f]{8}$/.test(v)) {\n    const r = Number.parseInt(v.slice(1, 3), 16);\n    const g = Number.parseInt(v.slice(3, 5), 16);\n    const b = Number.parseInt(v.slice(5, 7), 16);\n    const a = Number.parseInt(v.slice(7, 9), 16) / 255;\n    return { r, g, b, a };\n  }\n\n  return null;\n}\n\n/** Parse RGB channel value (number or percentage) */\nfunction parseRgbChannel(token: string): number | null {\n  const t = token.trim();\n  if (!t) return null;\n\n  if (t.endsWith('%')) {\n    const n = Number(t.slice(0, -1));\n    if (!Number.isFinite(n)) return null;\n    return clampByte((n / 100) * 255);\n  }\n\n  const n = Number(t);\n  if (!Number.isFinite(n)) return null;\n  return clampByte(n);\n}\n\n/** Parse alpha channel value (number or percentage) */\nfunction parseAlphaChannel(token: string): number | null {\n  const t = token.trim();\n  if (!t) return null;\n\n  if (t.endsWith('%')) {\n    const n = Number(t.slice(0, -1));\n    if (!Number.isFinite(n)) return null;\n    return clampNumber(n / 100, 0, 1);\n  }\n\n  const n = Number(t);\n  if (!Number.isFinite(n)) return null;\n  return clampNumber(n, 0, 1);\n}\n\n/** Parse rgb()/rgba() color to RGBA (supports legacy and modern syntax) */\nfunction parseRgbColorToRgba(raw: string): RgbaColor | null {\n  const trimmed = raw.trim();\n  if (!/^rgba?\\(/i.test(trimmed)) return null;\n\n  const openIndex = trimmed.indexOf('(');\n  const closeIndex = trimmed.lastIndexOf(')');\n  if (openIndex < 0 || closeIndex < openIndex) return null;\n\n  const inner = trimmed.slice(openIndex + 1, closeIndex).trim();\n  if (!inner) return null;\n\n  let channelsPart = inner;\n  let alphaPart: string | null = null;\n\n  // Modern syntax: rgb(0 0 0 / 0.5)\n  const slashIndex = inner.indexOf('/');\n  if (slashIndex !== -1) {\n    channelsPart = inner.slice(0, slashIndex).trim();\n    alphaPart = inner.slice(slashIndex + 1).trim();\n  }\n\n  // Split by comma (legacy) or whitespace (modern)\n  const channelTokens = channelsPart.includes(',')\n    ? channelsPart\n        .split(',')\n        .map((t) => t.trim())\n        .filter(Boolean)\n    : channelsPart\n        .split(/\\s+/)\n        .map((t) => t.trim())\n        .filter(Boolean);\n\n  if (channelTokens.length < 3) return null;\n\n  const r = parseRgbChannel(channelTokens[0]!);\n  const g = parseRgbChannel(channelTokens[1]!);\n  const b = parseRgbChannel(channelTokens[2]!);\n  if (r === null || g === null || b === null) return null;\n\n  let a = 1;\n\n  // Legacy rgba(r,g,b,a) comma syntax\n  if (!alphaPart && channelTokens.length >= 4) {\n    alphaPart = channelTokens[3]!;\n  }\n\n  if (alphaPart) {\n    const parsedA = parseAlphaChannel(alphaPart);\n    if (parsedA !== null) a = parsedA;\n  }\n\n  return { r, g, b, a };\n}\n\n/** Linear interpolation between two numbers */\nfunction lerpNumber(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\n/** Interpolate between two RGBA colors */\nfunction interpolateRgba(a: RgbaColor, b: RgbaColor, t: number): RgbaColor {\n  const clampedT = clampNumber(t, 0, 1);\n  return {\n    r: lerpNumber(a.r, b.r, clampedT),\n    g: lerpNumber(a.g, b.g, clampedT),\n    b: lerpNumber(a.b, b.b, clampedT),\n    a: lerpNumber(a.a, b.a, clampedT),\n  };\n}\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction isNoneValue(value: string): boolean {\n  const trimmed = value.trim();\n  return !trimmed || trimmed.toLowerCase() === 'none';\n}\n\nfunction clampNumber(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min;\n  return Math.max(min, Math.min(max, value));\n}\n\nfunction parseNumber(raw: string): number | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n  const n = Number(trimmed);\n  return Number.isFinite(n) ? n : null;\n}\n\nfunction parseAngleToken(raw: string): number | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n  const match = trimmed.match(/^(-?(?:\\d+\\.?\\d*|\\.\\d+))\\s*deg$/i);\n  if (!match) return null;\n  const n = Number(match[1]);\n  return Number.isFinite(n) ? n : null;\n}\n\nfunction parsePercentToken(raw: string): number | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n  const match = trimmed.match(/^(-?(?:\\d+\\.?\\d*|\\.\\d+))\\s*%$/);\n  if (!match) return null;\n  const n = Number(match[1]);\n  return Number.isFinite(n) ? n : null;\n}\n\n/**\n * Parse X position keyword (left/center/right or %)\n */\nfunction parsePositionX(raw: string): number | null {\n  const trimmed = raw.trim().toLowerCase();\n  if (!trimmed) return null;\n\n  const pct = parsePercentToken(trimmed);\n  if (pct !== null) return pct;\n\n  if (trimmed === 'center') return 50;\n  if (trimmed === 'left') return 0;\n  if (trimmed === 'right') return 100;\n\n  return null;\n}\n\n/**\n * Parse Y position keyword (top/center/bottom or %)\n */\nfunction parsePositionY(raw: string): number | null {\n  const trimmed = raw.trim().toLowerCase();\n  if (!trimmed) return null;\n\n  const pct = parsePercentToken(trimmed);\n  if (pct !== null) return pct;\n\n  if (trimmed === 'center') return 50;\n  if (trimmed === 'top') return 0;\n  if (trimmed === 'bottom') return 100;\n\n  return null;\n}\n\n/**\n * Check if a token is an X-axis keyword\n */\nfunction isXKeyword(raw: string): boolean {\n  const lower = raw.trim().toLowerCase();\n  return lower === 'left' || lower === 'right';\n}\n\n/**\n * Check if a token is a Y-axis keyword\n */\nfunction isYKeyword(raw: string): boolean {\n  const lower = raw.trim().toLowerCase();\n  return lower === 'top' || lower === 'bottom';\n}\n\nfunction clampAngle(value: number): number {\n  return clampNumber(value, 0, 360);\n}\n\nfunction clampPercent(value: number): number {\n  return clampNumber(value, 0, 100);\n}\n\n/**\n * Split a CSS value by a separator, respecting parentheses and quotes\n */\nfunction splitTopLevel(value: string, separator: string): string[] {\n  const results: string[] = [];\n  let depth = 0;\n  let quote: \"'\" | '\"' | null = null;\n  let escape = false;\n  let start = 0;\n\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]!;\n\n    if (escape) {\n      escape = false;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      escape = true;\n      continue;\n    }\n\n    if (quote) {\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '(') {\n      depth++;\n      continue;\n    }\n\n    if (ch === ')') {\n      depth = Math.max(0, depth - 1);\n      continue;\n    }\n\n    if (depth === 0 && ch === separator) {\n      results.push(value.slice(start, i));\n      start = i + 1;\n    }\n  }\n\n  results.push(value.slice(start));\n  return results;\n}\n\n/**\n * Tokenize a CSS value by whitespace, respecting parentheses and quotes\n */\nfunction tokenizeTopLevel(value: string): string[] {\n  const tokens: string[] = [];\n  let depth = 0;\n  let quote: \"'\" | '\"' | null = null;\n  let escape = false;\n  let buffer = '';\n\n  const flush = () => {\n    const t = buffer.trim();\n    if (t) tokens.push(t);\n    buffer = '';\n  };\n\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]!;\n\n    if (escape) {\n      buffer += ch;\n      escape = false;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      buffer += ch;\n      escape = true;\n      continue;\n    }\n\n    if (quote) {\n      buffer += ch;\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      buffer += ch;\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '(') {\n      depth++;\n      buffer += ch;\n      continue;\n    }\n\n    if (ch === ')') {\n      depth = Math.max(0, depth - 1);\n      buffer += ch;\n      continue;\n    }\n\n    if (depth === 0 && /\\s/.test(ch)) {\n      flush();\n      continue;\n    }\n\n    buffer += ch;\n  }\n\n  flush();\n  return tokens;\n}\n\nfunction parseColorStop(raw: string): ParsedStop | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n\n  const tokens = tokenizeTopLevel(trimmed);\n  if (tokens.length === 0) return null;\n\n  const color = tokens[0] ?? '';\n  if (!color) return null;\n\n  let position: number | null = null;\n  for (let i = 1; i < tokens.length; i++) {\n    const p = parsePercentToken(tokens[i] ?? '');\n    if (p !== null) {\n      position = p;\n      break;\n    }\n  }\n\n  return { color, position };\n}\n\n/**\n * Normalize stop positions following CSS gradient specification:\n * - First stop defaults to 0%, last stop defaults to 100%\n * - Enforces monotonically non-decreasing positions (CSS spec)\n * - Missing positions are distributed evenly between defined positions\n * - All positions are clamped to 0..100\n *\n * @param stops - Parsed stops with optional positions\n * @returns Normalized stops with all positions defined\n */\nfunction normalizeStopPositions(stops: ParsedStop[]): GradientStop[] {\n  if (stops.length === 0) return [];\n  if (stops.length === 1) {\n    return [\n      {\n        color: stops[0]!.color.trim() || DEFAULT_STOP_1.color,\n        position: clampPercent(stops[0]!.position ?? 0),\n      },\n    ];\n  }\n\n  // Extract colors and initial positions\n  const colors = stops.map((s) => s.color.trim() || DEFAULT_STOP_1.color);\n  const positions: Array<number | null> = stops.map((s) =>\n    s.position === null ? null : clampPercent(s.position),\n  );\n\n  // Default first position to 0 if not defined\n  if (positions[0] === null) {\n    positions[0] = 0;\n  }\n\n  // Default last position to 100 if not defined\n  const lastIndex = positions.length - 1;\n  if (positions[lastIndex] === null) {\n    positions[lastIndex] = 100;\n  }\n\n  // CSS spec: Enforce monotonically non-decreasing positions\n  // If a later explicit position is less than an earlier one, bump it up\n  let maxSoFar = positions[0] ?? 0;\n  for (let i = 1; i < positions.length; i++) {\n    const pos = positions[i];\n    if (pos !== null) {\n      if (pos < maxSoFar) {\n        positions[i] = maxSoFar;\n      } else {\n        maxSoFar = pos;\n      }\n    }\n  }\n\n  // Fill in missing positions by linear interpolation\n  // Find runs of null positions and distribute them evenly\n  let runStart: number | null = null;\n\n  for (let i = 0; i < positions.length; i++) {\n    if (positions[i] === null) {\n      if (runStart === null) {\n        runStart = i;\n      }\n    } else {\n      if (runStart !== null) {\n        // Fill the run from runStart to i-1\n        const prevPos = positions[runStart - 1] ?? 0;\n        const nextPos = positions[i] ?? 100;\n        const runLength = i - runStart + 1;\n\n        for (let j = runStart; j < i; j++) {\n          const t = (j - runStart + 1) / runLength;\n          positions[j] = prevPos + (nextPos - prevPos) * t;\n        }\n        runStart = null;\n      }\n    }\n  }\n\n  return stops.map((_, i) => ({\n    color: colors[i]!,\n    position: clampPercent(positions[i] ?? 0),\n  }));\n}\n\n/**\n * Legacy normalize function for backward compatibility\n * @deprecated Use normalizeStopPositions for N stops\n */\nfunction normalizeStops(stops: [ParsedStop, ParsedStop]): [GradientStop, GradientStop] {\n  const normalized = normalizeStopPositions(stops);\n  return [normalized[0] ?? { ...DEFAULT_STOP_1 }, normalized[1] ?? { ...DEFAULT_STOP_2 }];\n}\n\nfunction parseGradientFunctionCall(\n  value: string,\n): { kind: 'linear' | 'radial'; args: string } | null {\n  const trimmed = value.trim();\n  const lower = trimmed.toLowerCase();\n\n  let kind: 'linear' | 'radial' | null = null;\n  let fnName = '';\n\n  if (lower.startsWith('linear-gradient')) {\n    kind = 'linear';\n    fnName = 'linear-gradient';\n  } else if (lower.startsWith('radial-gradient')) {\n    kind = 'radial';\n    fnName = 'radial-gradient';\n  } else {\n    return null;\n  }\n\n  let i = fnName.length;\n  while (i < trimmed.length && /\\s/.test(trimmed[i]!)) i++;\n  if (trimmed[i] !== '(') return null;\n\n  const openIndex = i;\n  let depth = 0;\n  let quote: \"'\" | '\"' | null = null;\n  let escape = false;\n\n  for (let j = openIndex; j < trimmed.length; j++) {\n    const ch = trimmed[j]!;\n\n    if (escape) {\n      escape = false;\n      continue;\n    }\n\n    if (ch === '\\\\') {\n      escape = true;\n      continue;\n    }\n\n    if (quote) {\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '(') {\n      depth++;\n      continue;\n    }\n\n    if (ch === ')') {\n      depth--;\n      if (depth === 0) {\n        // Check no trailing content\n        const trailing = trimmed.slice(j + 1).trim();\n        if (trailing) return null;\n\n        const args = trimmed.slice(openIndex + 1, j);\n        return { kind, args };\n      }\n    }\n  }\n\n  return null;\n}\n\nfunction parseLinearGradient(args: string): ParsedLinearGradient | null {\n  const parts = splitTopLevel(args, ',')\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  // Need at least 2 color stops\n  if (parts.length < 2) return null;\n\n  const firstPart = parts[0] ?? '';\n  const firstLower = firstPart.toLowerCase();\n\n  // Reject unsupported direction keywords: \"to left\", \"to right\", \"to top\", etc.\n  // These are valid CSS but we only support angle-based linear gradients\n  if (firstLower.startsWith('to ')) {\n    return null;\n  }\n\n  // Check if first part is an angle\n  const maybeAngle = parseAngleToken(firstPart);\n\n  let angle = DEFAULT_LINEAR_ANGLE;\n  let stopStartIndex = 0;\n\n  if (maybeAngle !== null) {\n    // Format: linear-gradient(angle, stop1, stop2, ...)\n    if (parts.length < 3) return null;\n    angle = maybeAngle;\n    stopStartIndex = 1;\n  }\n\n  // Parse all color stops\n  const stopParts = parts.slice(stopStartIndex);\n  const parsedStops: ParsedStop[] = [];\n\n  for (const raw of stopParts) {\n    const stop = parseColorStop(raw);\n    if (!stop) return null;\n    parsedStops.push(stop);\n  }\n\n  // Must have at least 2 stops\n  if (parsedStops.length < 2) return null;\n\n  return {\n    type: 'linear',\n    angle: clampAngle(angle),\n    stops: normalizeStopPositions(parsedStops),\n  };\n}\n\n/** Size keywords we don't support - return null to show as \"none\" */\nconst UNSUPPORTED_RADIAL_SIZE_KEYWORDS = new Set([\n  'closest-side',\n  'farthest-side',\n  'closest-corner',\n  'farthest-corner',\n]);\n\nfunction parseRadialGradient(args: string): ParsedRadialGradient | null {\n  const parts = splitTopLevel(args, ',')\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  if (parts.length < 2) return null;\n\n  let shape: RadialShape = 'ellipse';\n  let position: { x: number; y: number } | null = null;\n  let stopStartIndex = 0;\n\n  const first = parts[0] ?? '';\n  const tokens = tokenizeTopLevel(first);\n  const lowerTokens = tokens.map((t) => t.toLowerCase());\n\n  // Reject unsupported size keywords - valid CSS but we only support basic shapes\n  for (const token of lowerTokens) {\n    if (UNSUPPORTED_RADIAL_SIZE_KEYWORDS.has(token)) {\n      return null;\n    }\n  }\n\n  const atIndex = lowerTokens.indexOf('at');\n  const hasAt = atIndex >= 0;\n\n  const hasCircle = lowerTokens.includes('circle');\n  const hasEllipse = lowerTokens.includes('ellipse');\n  const hasShape = hasCircle || hasEllipse;\n\n  if (hasShape || hasAt) {\n    stopStartIndex = 1;\n\n    if (hasCircle) shape = 'circle';\n    else if (hasEllipse) shape = 'ellipse';\n\n    if (hasAt) {\n      const token1 = tokens[atIndex + 1] ?? '';\n      const token2 = tokens[atIndex + 2] ?? '';\n\n      // Handle position parsing with axis awareness\n      // CSS allows \"at top right\" (Y then X) or \"at right top\" (X then Y)\n      let x: number | null = null;\n      let y: number | null = null;\n\n      // Check if first token is a Y keyword (top/bottom)\n      if (isYKeyword(token1)) {\n        // \"at top\" or \"at top right\" - first is Y\n        y = parsePositionY(token1);\n        x = token2 ? parsePositionX(token2) : null;\n      } else if (isXKeyword(token1)) {\n        // \"at left\" or \"at left top\" - first is X\n        x = parsePositionX(token1);\n        y = token2 ? parsePositionY(token2) : null;\n      } else {\n        // Default: treat as \"X Y\" order (most common for percentages)\n        x = parsePositionX(token1);\n        y = token2 ? parsePositionY(token2) : null;\n      }\n\n      position = {\n        x: clampPercent(x ?? DEFAULT_POSITION),\n        y: clampPercent(y ?? DEFAULT_POSITION),\n      };\n    }\n  }\n\n  // Parse all color stops\n  const stopParts = parts.slice(stopStartIndex);\n  const parsedStops: ParsedStop[] = [];\n\n  for (const raw of stopParts) {\n    const stop = parseColorStop(raw);\n    if (!stop) return null;\n    parsedStops.push(stop);\n  }\n\n  // Must have at least 2 stops\n  if (parsedStops.length < 2) return null;\n\n  return {\n    type: 'radial',\n    shape,\n    position,\n    stops: normalizeStopPositions(parsedStops),\n  };\n}\n\nfunction parseGradient(value: string): ParsedGradient | null {\n  const fn = parseGradientFunctionCall(value);\n  if (!fn) return null;\n  return fn.kind === 'linear' ? parseLinearGradient(fn.args) : parseRadialGradient(fn.args);\n}\n\nfunction needsColorPlaceholder(value: string): boolean {\n  return /\\bvar\\s*\\(/i.test(value);\n}\n\n/**\n * Build placeholder color mapping from inline stops to computed stops.\n * Uses nearest-neighbor matching by stop position (0..100).\n * This handles cases where normalization may produce slightly different positions.\n *\n * @param inlineStops - Stops parsed from inline CSS (may contain var())\n * @param computedStops - Stops parsed from computed CSS (resolved colors)\n * @returns Array of placeholder colors aligned to inlineStops indices\n */\nfunction buildPlaceholderMapping(\n  inlineStops: GradientStop[],\n  computedStops: GradientStop[],\n): string[] {\n  if (inlineStops.length === 0 || computedStops.length === 0) {\n    return [];\n  }\n\n  return inlineStops.map((inlineStop) => {\n    let nearestStop = computedStops[0]!;\n    let minDistance = Math.abs(nearestStop.position - inlineStop.position);\n\n    for (let i = 1; i < computedStops.length; i++) {\n      const candidate = computedStops[i]!;\n      const distance = Math.abs(candidate.position - inlineStop.position);\n      if (distance < minDistance) {\n        nearestStop = candidate;\n        minDistance = distance;\n      }\n    }\n\n    return nearestStop.color;\n  });\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface GradientControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n  /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */\n  tokensService?: DesignTokensService;\n  /**\n   * CSS property to write the gradient value to.\n   * Defaults to 'background-image'.\n   * Use 'border-image-source' for border gradient support.\n   */\n  property?: string;\n  /**\n   * Whether to show the 'None' option in the gradient type selector.\n   * Defaults to true.\n   * Set to false for text gradient mode where 'none' would make text invisible.\n   */\n  allowNone?: boolean;\n}\n\nexport function createGradientControl(options: GradientControlOptions): DesignControl {\n  const {\n    container,\n    transactionManager,\n    tokensService,\n    property: cssProperty = 'background-image',\n    allowNone = true,\n  } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n  // Default type is 'linear' when allowNone is false, otherwise 'none'\n  let currentType: GradientType = allowNone ? 'none' : 'linear';\n\n  // Current stops array - supports N stops with stable identity\n  let currentStops: StopModel[] = createDefaultStopModels();\n  let selectedStopId: StopId | null = currentStops[0]?.id ?? null;\n\n  // Active thumb drag session (null when not dragging)\n  let thumbDrag: ThumbDragSession | null = null;\n\n  // Active thumb keyboard session (null when not stepping via arrow keys)\n  let thumbKeyboard: ThumbKeyboardSession | null = null;\n\n  let backgroundHandle: StyleTransactionHandle | null = null;\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // -------------------------------------------------------------------------\n  // DOM Construction Helpers\n  // -------------------------------------------------------------------------\n\n  function createInputRow(\n    labelText: string,\n    ariaLabel: string,\n  ): { row: HTMLDivElement; input: HTMLInputElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.className = 'we-input';\n    input.autocomplete = 'off';\n    input.spellcheck = false;\n    input.inputMode = 'decimal';\n    input.setAttribute('aria-label', ariaLabel);\n\n    row.append(label, input);\n    return { row, input };\n  }\n\n  function createSelectRow(\n    labelText: string,\n    ariaLabel: string,\n    values: readonly { value: string; label: string }[],\n  ): { row: HTMLDivElement; select: HTMLSelectElement } {\n    const row = document.createElement('div');\n    row.className = 'we-field';\n\n    const label = document.createElement('span');\n    label.className = 'we-field-label';\n    label.textContent = labelText;\n\n    const select = document.createElement('select');\n    select.className = 'we-select';\n    select.setAttribute('aria-label', ariaLabel);\n\n    for (const v of values) {\n      const opt = document.createElement('option');\n      opt.value = v.value;\n      opt.textContent = v.label;\n      select.append(opt);\n    }\n\n    row.append(label, select);\n    return { row, select };\n  }\n\n  // -------------------------------------------------------------------------\n  // Create UI Elements\n  // -------------------------------------------------------------------------\n\n  // Build gradient type options based on allowNone parameter\n  const gradientTypeOptions = allowNone\n    ? GRADIENT_TYPES\n    : GRADIENT_TYPES.filter((t) => t.value !== 'none');\n\n  const { row: typeRow, select: typeSelect } = createSelectRow(\n    'Type',\n    'Gradient Type',\n    gradientTypeOptions,\n  );\n\n  // Gradient preview bar\n  const gradientBarRow = document.createElement('div');\n  gradientBarRow.className = 'we-gradient-bar-row';\n\n  const gradientBar = document.createElement('div');\n  gradientBar.className = 'we-gradient-bar';\n  gradientBar.setAttribute('aria-label', 'Gradient preview');\n\n  // Thumb container layer (Phase 4C) - positioned over gradient\n  const gradientThumbs = document.createElement('div');\n  gradientThumbs.className = 'we-gradient-bar-thumbs';\n  gradientBar.append(gradientThumbs);\n\n  gradientBarRow.append(gradientBar);\n\n  const { row: angleRow, input: angleInput } = createInputRow('Angle', 'Gradient Angle (deg)');\n  angleInput.placeholder = String(DEFAULT_LINEAR_ANGLE);\n\n  const { row: shapeRow, select: shapeSelect } = createSelectRow(\n    'Shape',\n    'Radial Gradient Shape',\n    RADIAL_SHAPES,\n  );\n\n  const { row: posXRow, input: posXInput } = createInputRow('Position X', 'Radial Position X (%)');\n  const { row: posYRow, input: posYInput } = createInputRow('Position Y', 'Radial Position Y (%)');\n\n  // Stops list header + list (Phase 4D) - read-only + selection sync\n  const stopsHeaderRow = document.createElement('div');\n  stopsHeaderRow.className = 'we-gradient-stops-header';\n\n  const stopsHeaderLabel = document.createElement('span');\n  stopsHeaderLabel.className = 'we-gradient-stops-title';\n  stopsHeaderLabel.textContent = 'Stops';\n\n  const stopsAddBtn = document.createElement('button');\n  stopsAddBtn.type = 'button';\n  stopsAddBtn.className = 'we-icon-btn we-gradient-stops-add';\n  stopsAddBtn.setAttribute('aria-label', 'Add stop');\n  stopsAddBtn.disabled = false;\n  stopsAddBtn.textContent = '+';\n\n  stopsHeaderRow.append(stopsHeaderLabel, stopsAddBtn);\n\n  const stopsList = document.createElement('div');\n  stopsList.className = 'we-gradient-stops-list';\n  stopsList.setAttribute('role', 'list');\n\n  root.append(\n    typeRow,\n    gradientBarRow,\n    angleRow,\n    shapeRow,\n    posXRow,\n    posYRow,\n    stopsHeaderRow,\n    stopsList,\n  );\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // Wire keyboard stepping for numeric inputs\n  wireNumberStepping(disposer, angleInput, {\n    mode: 'number',\n    min: 0,\n    max: 360,\n    step: 1,\n    shiftStep: 15,\n    altStep: 0.1,\n  });\n  wireNumberStepping(disposer, posXInput, {\n    mode: 'number',\n    min: 0,\n    max: 100,\n    step: 1,\n    shiftStep: 10,\n    altStep: 0.1,\n  });\n  wireNumberStepping(disposer, posYInput, {\n    mode: 'number',\n    min: 0,\n    max: 100,\n    step: 1,\n    shiftStep: 10,\n    altStep: 0.1,\n  });\n\n  // ---------------------------------------------------------------------------\n  // Single Position Input bound to selectedStopId (Phase 7)\n  // Host is re-parented into the selected row's position editor slot.\n  // ---------------------------------------------------------------------------\n  const selectedStopPosHost = document.createElement('div');\n\n  const selectedStopPosInput = document.createElement('input');\n  selectedStopPosInput.type = 'text';\n  selectedStopPosInput.className = 'we-gradient-stop-pos-input';\n  selectedStopPosInput.autocomplete = 'off';\n  selectedStopPosInput.spellcheck = false;\n  selectedStopPosInput.inputMode = 'decimal';\n  selectedStopPosInput.placeholder = '0';\n  selectedStopPosInput.setAttribute('aria-label', 'Selected Stop Position (%)');\n  selectedStopPosHost.append(selectedStopPosInput);\n\n  // Enable keyboard stepping (↑/↓ to increment/decrement)\n  wireNumberStepping(disposer, selectedStopPosInput, {\n    mode: 'number',\n    min: 0,\n    max: 100,\n    step: 1,\n    shiftStep: 10,\n  });\n\n  /**\n   * Commit the position edit: sort stops and finalize the transaction.\n   * Called on blur or Enter key.\n   */\n  function commitSelectedStopPosition(): void {\n    // Commit-time sort ensures CSS output is monotonically ordered\n    sortCurrentStopsByPosition();\n\n    // Only commit if we have an active transaction\n    if (backgroundHandle) {\n      previewGradient();\n      commitTransaction();\n    }\n    syncAllFields();\n  }\n\n  /**\n   * Cancel the position edit and rollback to the original value.\n   * Called on Escape key.\n   */\n  function cancelSelectedStopPosition(): void {\n    rollbackTransaction();\n    syncAllFields(true);\n  }\n\n  // Handle input changes - update model and preview in real-time\n  disposer.listen(selectedStopPosInput, 'input', () => {\n    const id = selectedStopId;\n    if (!id) return;\n\n    const parsed = parseNumber(selectedStopPosInput.value);\n    if (parsed === null) return;\n\n    // Update model and preview in real-time\n    setStopPositionById(id, parsed);\n    previewGradient();\n  });\n\n  // Commit on blur\n  disposer.listen(selectedStopPosInput, 'blur', commitSelectedStopPosition);\n\n  // Handle Enter/Escape keys\n  disposer.listen(selectedStopPosInput, 'keydown', (event: KeyboardEvent) => {\n    if (event.key === 'Enter') {\n      event.preventDefault();\n      commitSelectedStopPosition();\n      selectedStopPosInput.blur();\n    } else if (event.key === 'Escape') {\n      event.preventDefault();\n      cancelSelectedStopPosition();\n    }\n  });\n\n  // Single ColorField bound to selectedStopId (Phase 4E)\n  // Host is re-parented into the selected row's editor slot.\n  const selectedStopColorHost = document.createElement('div');\n\n  const selectedStopColorField: ColorField = createColorField({\n    container: selectedStopColorHost,\n    ariaLabel: 'Selected Stop Color',\n    tokensService,\n    getTokenTarget: () => currentTarget,\n    onInput: (value) => {\n      const id = selectedStopId;\n      if (!id) return;\n\n      const index = currentStops.findIndex((s) => s.id === id);\n      if (index < 0) return;\n\n      // Update model\n      currentStops[index]!.color = value;\n\n      // Update placeholder when switching away from var()\n      selectedStopColorField.setPlaceholder(\n        needsColorPlaceholder(value) ? (currentStops[index]!.placeholderColor ?? '') : '',\n      );\n\n      previewGradient();\n    },\n    onCommit: () => {\n      commitTransaction();\n      syncAllFields();\n    },\n    onCancel: () => {\n      rollbackTransaction();\n      syncAllFields(true);\n    },\n  });\n  disposer.add(() => selectedStopColorField.dispose());\n\n  // -------------------------------------------------------------------------\n  // Transaction Management\n  // -------------------------------------------------------------------------\n\n  function beginTransaction(): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    if (backgroundHandle) return backgroundHandle;\n\n    backgroundHandle = transactionManager.beginStyle(target, cssProperty);\n    return backgroundHandle;\n  }\n\n  function commitTransaction(): void {\n    const handle = backgroundHandle;\n    backgroundHandle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(): void {\n    const handle = backgroundHandle;\n    backgroundHandle = null;\n    if (handle) handle.rollback();\n  }\n\n  // -------------------------------------------------------------------------\n  // UI State Helpers\n  // -------------------------------------------------------------------------\n\n  // -------------------------------------------------------------------------\n  // Thumb Drag Helpers (Phase 5)\n  // -------------------------------------------------------------------------\n\n  /**\n   * Update a stop's position by its ID.\n   * Used during drag to update the model in real-time.\n   */\n  function setStopPositionById(stopId: StopId, position: number): void {\n    const index = currentStops.findIndex((s) => s.id === stopId);\n    if (index < 0) return;\n\n    const clamped = clampPercent(position);\n    currentStops[index]!.position = clamped;\n  }\n\n  /**\n   * Restore all stop positions from a snapshot map.\n   * Used when canceling a drag operation (Escape key).\n   */\n  function restoreStopPositions(snapshot: Map<StopId, number>): void {\n    for (const stop of currentStops) {\n      const savedPos = snapshot.get(stop.id);\n      if (savedPos !== undefined) {\n        stop.position = savedPos;\n      }\n    }\n  }\n\n  /**\n   * End the current thumb drag session and clean up.\n   * Commits or rolls back the transaction based on the outcome.\n   *\n   * @param commit - If true, commit changes; if false, rollback to initial state\n   */\n  function endThumbDrag(commit: boolean): void {\n    const session = thumbDrag;\n    if (!session) return;\n\n    thumbDrag = null;\n\n    // Remove dragging visual state\n    gradientBar.classList.remove('we-gradient-bar--dragging');\n    session.thumbElement.classList.remove('we-gradient-thumb--dragging');\n\n    // Best-effort: release capture (e.g., Escape cancel while pointer is still down)\n    try {\n      session.thumbElement.releasePointerCapture(session.pointerId);\n    } catch {\n      // Pointer capture may already be released or never set\n    }\n\n    if (commit) {\n      // Commit-time sort ensures CSS output is monotonically ordered\n      sortCurrentStopsByPosition();\n\n      // Update preview with sorted positions before committing\n      previewGradient();\n      commitTransaction();\n      syncAllFields();\n    } else {\n      // Restore positions before rolling back\n      restoreStopPositions(session.initialPositions);\n      rollbackTransaction();\n      syncAllFields(true);\n    }\n  }\n\n  /**\n   * Calculate the position percentage from a pointer event relative to the gradient bar.\n   * Returns a value clamped to 0-100.\n   */\n  function calculatePositionFromPointer(clientX: number): number {\n    const rect = gradientBar.getBoundingClientRect();\n    if (rect.width <= 0) return 0;\n\n    const relativeX = clientX - rect.left;\n    const rawPercent = (relativeX / rect.width) * 100;\n    return clampPercent(rawPercent);\n  }\n\n  // -------------------------------------------------------------------------\n  // Thumb Keyboard Stepping (Phase 9)\n  // -------------------------------------------------------------------------\n\n  /**\n   * Start a keyboard stepping session for a thumb.\n   * Similar to drag session but triggered by arrow keys.\n   */\n  function startThumbKeyboardSession(stopId: StopId, thumbElement: HTMLElement): void {\n    if (thumbDrag) return;\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    // If session already exists for this stop, don't restart\n    if (thumbKeyboard?.stopId === stopId) return;\n\n    // If switching stops, commit previous session first\n    if (thumbKeyboard) {\n      endThumbKeyboard(true);\n    }\n\n    // Snapshot all positions for potential rollback\n    const initialPositions = new Map<StopId, number>();\n    for (const stop of currentStops) {\n      initialPositions.set(stop.id, stop.position);\n    }\n\n    thumbKeyboard = { stopId, initialPositions, thumbElement };\n    beginTransaction();\n  }\n\n  /**\n   * End the keyboard stepping session.\n   * @param commit - If true, commit changes; if false, rollback to initial state\n   */\n  function endThumbKeyboard(commit: boolean): void {\n    const session = thumbKeyboard;\n    if (!session) return;\n    thumbKeyboard = null;\n\n    if (commit) {\n      // Commit-time sort keeps CSS output monotonic\n      sortCurrentStopsByPosition();\n      previewGradient();\n      commitTransaction();\n      syncAllFields();\n    } else {\n      restoreStopPositions(session.initialPositions);\n      rollbackTransaction();\n      syncAllFields(true);\n    }\n  }\n\n  /**\n   * Handle focus on a thumb - select the corresponding stop.\n   */\n  function handleThumbFocus(event: FocusEvent): void {\n    if (thumbDrag) return;\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    const thumb = event.currentTarget as HTMLElement;\n    const stopId = thumb.dataset.stopId;\n    if (!stopId) return;\n\n    if (selectedStopId !== stopId) {\n      selectedStopId = stopId;\n      // Preserve thumbs to avoid focus loss during selection sync\n      updateGradientBar({ preserveThumbs: true });\n    }\n  }\n\n  /**\n   * Handle blur on a thumb - commit any active keyboard session.\n   */\n  function handleThumbBlur(event: FocusEvent): void {\n    const session = thumbKeyboard;\n    if (!session) return;\n\n    // Only commit if blur is from the session's thumb\n    const thumb = event.currentTarget as HTMLElement;\n    if (thumb !== session.thumbElement) return;\n\n    // Commit on blur (similar to input field behavior)\n    endThumbKeyboard(true);\n  }\n\n  /**\n   * Handle keydown on a thumb - arrow keys for stepping, Escape for cancel.\n   */\n  function handleThumbKeyDown(event: KeyboardEvent): void {\n    // Preserve navigation shortcuts (Cmd/Ctrl + Arrow for cursor movement)\n    if (event.metaKey || event.ctrlKey) return;\n    if (thumbDrag) return;\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    const thumb = event.currentTarget as HTMLElement;\n    const stopId = thumb.dataset.stopId;\n    if (!stopId) return;\n\n    // Escape cancels the keyboard session\n    if (event.key === 'Escape') {\n      const session = thumbKeyboard;\n      if (!session || session.stopId !== stopId) return;\n      event.preventDefault();\n      event.stopPropagation();\n      endThumbKeyboard(false);\n      return;\n    }\n\n    // Handle arrow keys for position adjustment\n    const isArrow =\n      event.key === 'ArrowLeft' ||\n      event.key === 'ArrowRight' ||\n      event.key === 'ArrowUp' ||\n      event.key === 'ArrowDown';\n    if (!isArrow) return;\n\n    event.preventDefault();\n    event.stopPropagation();\n\n    // ArrowLeft/ArrowDown: decrease, ArrowRight/ArrowUp: increase\n    // Shift modifier: step by 10 instead of 1\n    const sign = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -1 : 1;\n    const step = event.shiftKey ? 10 : 1;\n    const delta = sign * step;\n\n    // Ensure stop is selected and session is active\n    selectedStopId = stopId;\n    startThumbKeyboardSession(stopId, thumb);\n\n    const idx = currentStops.findIndex((s) => s.id === stopId);\n    if (idx < 0) return;\n\n    setStopPositionById(stopId, currentStops[idx]!.position + delta);\n    previewGradient();\n  }\n\n  /**\n   * Sync slider ARIA attributes on a thumb element.\n   * Provides accessible name and value for screen readers.\n   */\n  function syncThumbSliderAria(thumb: HTMLElement, position: number): void {\n    const clamped = clampPercent(position);\n    const rounded = Math.round(clamped * 100) / 100;\n    const value = Object.is(rounded, -0) ? 0 : rounded;\n\n    thumb.setAttribute('role', 'slider');\n    thumb.setAttribute('aria-label', 'Gradient stop position');\n    thumb.setAttribute('aria-valuemin', '0');\n    thumb.setAttribute('aria-valuemax', '100');\n    thumb.setAttribute('aria-valuenow', String(value));\n    thumb.setAttribute('aria-valuetext', `${value}%`);\n    thumb.setAttribute('aria-orientation', 'horizontal');\n  }\n\n  // -------------------------------------------------------------------------\n  // Stop Add/Delete (Phase 6)\n  // -------------------------------------------------------------------------\n\n  // Hidden probe element used to resolve CSS colors for interpolation\n  const stopColorProbe = document.createElement('div');\n  stopColorProbe.style.cssText =\n    'position:fixed;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;opacity:0';\n  root.append(stopColorProbe);\n  disposer.add(() => stopColorProbe.remove());\n\n  /**\n   * Resolve any CSS color string to RGBA using browser color parsing.\n   * Handles hex, rgb(), rgba(), named colors, currentColor, etc.\n   */\n  function resolveCssColorToRgba(raw: string): RgbaColor | null {\n    const trimmed = raw.trim();\n    if (!trimmed) return null;\n\n    const lower = trimmed.toLowerCase();\n    if (lower === 'transparent') {\n      return { r: 0, g: 0, b: 0, a: 0 };\n    }\n\n    // Try direct parsing first (faster for common formats)\n    const fromHex = parseHexColorToRgba(trimmed);\n    if (fromHex) return fromHex;\n\n    const fromRgb = parseRgbColorToRgba(trimmed);\n    if (fromRgb) return fromRgb;\n\n    // Fall back to browser color parsing via computed style\n    try {\n      stopColorProbe.style.color = '';\n      stopColorProbe.style.color = trimmed;\n      if (!stopColorProbe.style.color) return null;\n      const computed = getComputedStyle(stopColorProbe).color;\n      return parseRgbColorToRgba(computed);\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Keep stop order monotonic by position for correct CSS output.\n   * CSS gradients do not reorder stops; out-of-order positions get clamped.\n   * Tie-breaks by original insertion order (array index) for stability.\n   */\n  function sortCurrentStopsByPosition(): void {\n    if (currentStops.length <= 1) return;\n    const indexed = currentStops.map((stop, index) => ({ stop, index }));\n    indexed.sort((a, b) => a.stop.position - b.stop.position || a.index - b.index);\n    currentStops = indexed.map((entry) => entry.stop);\n  }\n\n  /**\n   * Interpolate a new stop's color based on its position.\n   * Finds the left and right bounding stops and linearly interpolates.\n   */\n  function interpolateNewStopColor(position: number): string {\n    const clamped = clampPercent(position);\n    const models = currentStops.length >= 2 ? currentStops : createDefaultStopModels();\n    if (models.length === 0) return DEFAULT_STOP_1.color;\n\n    // Sort by position to find bounding stops\n    const sorted = models.slice().sort((a, b) => a.position - b.position);\n    let left = sorted[0]!;\n    let right = sorted[sorted.length - 1]!;\n\n    for (const stop of sorted) {\n      if (stop.position <= clamped) left = stop;\n      if (stop.position >= clamped) {\n        right = stop;\n        break;\n      }\n    }\n\n    // Resolve preview colors (handles var() references)\n    const leftRgba = resolveCssColorToRgba(getStopPreviewColor(left));\n    const rightRgba = resolveCssColorToRgba(getStopPreviewColor(right));\n\n    if (!leftRgba && !rightRgba) {\n      return left.color.trim() || DEFAULT_STOP_1.color;\n    }\n    if (!leftRgba) return rgbaToCss(rightRgba!);\n    if (!rightRgba) return rgbaToCss(leftRgba);\n\n    const span = right.position - left.position;\n    if (!Number.isFinite(span) || span <= 0) {\n      return rgbaToCss(leftRgba);\n    }\n\n    const t = clampNumber((clamped - left.position) / span, 0, 1);\n    return rgbaToCss(interpolateRgba(leftRgba, rightRgba, t));\n  }\n\n  /**\n   * Get a suggested position for adding a new stop.\n   * Returns the midpoint between the selected stop and its next neighbor.\n   */\n  function getSuggestedAddStopPosition(): number {\n    const selectedId = selectedStopId;\n    if (!selectedId) return DEFAULT_POSITION;\n\n    const models = currentStops.length >= 2 ? currentStops : createDefaultStopModels();\n    const sorted = models.slice().sort((a, b) => a.position - b.position);\n    const index = sorted.findIndex((s) => s.id === selectedId);\n    if (index < 0) return DEFAULT_POSITION;\n\n    const current = sorted[index]!;\n    const next = sorted[index + 1];\n    const prev = sorted[index - 1];\n\n    // Prefer midpoint toward the right (next), then toward the left (prev)\n    if (next) return clampPercent((current.position + next.position) / 2);\n    if (prev) return clampPercent((prev.position + current.position) / 2);\n    return DEFAULT_POSITION;\n  }\n\n  /**\n   * Find the stop ID closest to a given position.\n   * Used to select a neighbor after deletion.\n   * Tie-breaks toward the right (higher position).\n   */\n  function pickClosestStopId(position: number): StopId | null {\n    if (currentStops.length === 0) return null;\n\n    let best = currentStops[0]!;\n    let bestDistance = Math.abs(best.position - position);\n\n    for (let i = 1; i < currentStops.length; i++) {\n      const candidate = currentStops[i]!;\n      const distance = Math.abs(candidate.position - position);\n\n      if (distance < bestDistance) {\n        best = candidate;\n        bestDistance = distance;\n        continue;\n      }\n\n      // Tie-break: prefer stop on the right side\n      if (distance === bestDistance) {\n        const candidateOnRight = candidate.position >= position;\n        const bestOnRight = best.position >= position;\n        if (candidateOnRight && !bestOnRight) {\n          best = candidate;\n        }\n      }\n    }\n\n    return best.id;\n  }\n\n  /**\n   * Add a new stop at the specified position with interpolated color.\n   * Auto-selects the new stop after adding.\n   */\n  function addStopAtPosition(position: number, opts: { focusColor?: boolean } = {}): void {\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    const clamped = clampPercent(position);\n    const newStop: StopModel = {\n      id: createStopId(),\n      position: clamped,\n      color: interpolateNewStopColor(clamped),\n    };\n\n    currentStops.push(newStop);\n    selectedStopId = newStop.id;\n    sortCurrentStopsByPosition();\n\n    previewGradient();\n    commitTransaction();\n\n    if (opts.focusColor) {\n      queueMicrotask(() => {\n        const input = selectedStopColorHost.querySelector<HTMLInputElement>('input.we-color-text');\n        input?.focus();\n      });\n    }\n  }\n\n  /**\n   * Remove a stop by its ID.\n   * Enforces minimum 2 stops constraint.\n   * Auto-selects the closest neighbor after deletion.\n   */\n  function removeStopById(stopId: StopId): void {\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    // Enforce minimum 2 stops constraint\n    if (currentStops.length <= 2) return;\n\n    const index = currentStops.findIndex((s) => s.id === stopId);\n    if (index < 0) return;\n\n    const removed = currentStops[index]!;\n    currentStops.splice(index, 1);\n\n    // Auto-select closest neighbor if we deleted the selected stop\n    if (selectedStopId === stopId) {\n      selectedStopId = pickClosestStopId(removed.position);\n      if (!selectedStopId) {\n        selectedStopId = currentStops[0]?.id ?? null;\n      }\n    }\n\n    sortCurrentStopsByPosition();\n    previewGradient();\n    commitTransaction();\n  }\n\n  /**\n   * Check if an event target is a text input-like element.\n   * Used to avoid capturing Delete/Backspace when user is editing text.\n   */\n  function isTextInputLike(target: EventTarget | null): boolean {\n    return (\n      target instanceof HTMLInputElement ||\n      target instanceof HTMLTextAreaElement ||\n      (target instanceof HTMLElement && target.isContentEditable)\n    );\n  }\n\n  /**\n   * Update the gradient preview bar background and thumb elements.\n   * Uses buildPreviewBarCss() to render a horizontal (90deg) gradient.\n   * Reads from current UI state (inputs) to ensure real-time sync during editing.\n   *\n   * @param options.refreshStopsList - Set to false to skip stops list refresh (avoid re-mounting color field during editing)\n   * @param options.preserveThumbs - Set to true to only update thumb positions without recreating elements (used during drag)\n   */\n  function updateGradientBar(\n    options: { refreshStopsList?: boolean; preserveThumbs?: boolean } = {},\n  ): void {\n    const refreshStopsList = options.refreshStopsList ?? true;\n    const preserveThumbs = options.preserveThumbs ?? false;\n\n    if (currentType === 'none') {\n      gradientBar.style.backgroundImage = 'none';\n      gradientThumbs.textContent = '';\n      if (refreshStopsList) updateStopsList([], [], []);\n      return;\n    }\n\n    // Use collectCurrentStops() to get stops based on current UI input values.\n    // This ensures the preview bar updates in real-time while editing stop1/stop2.\n    const stops = collectCurrentStops();\n    if (stops.length === 0) {\n      gradientBar.style.backgroundImage = 'none';\n      gradientThumbs.textContent = '';\n      if (refreshStopsList) updateStopsList([], [], []);\n      return;\n    }\n\n    // Resolve placeholder colors for var() values from currentStops model\n    const previewStops: GradientStop[] = stops.map((stop, i) => {\n      const model = currentStops[i];\n      const previewColor = needsColorPlaceholder(stop.color)\n        ? model?.placeholderColor?.trim() || 'transparent'\n        : stop.color;\n      return { color: previewColor, position: stop.position };\n    });\n\n    gradientBar.style.backgroundImage = buildPreviewBarCss(previewStops);\n\n    // -------------------------------------------------------------------------\n    // Thumbs (Phase 4C + Phase 5 drag support)\n    // -------------------------------------------------------------------------\n\n    const models = currentStops.length >= 2 ? currentStops : createDefaultStopModels();\n\n    // Ensure selectedStopId points to a valid model\n    if (!selectedStopId || !models.some((s) => s.id === selectedStopId)) {\n      selectedStopId = models[0]?.id ?? null;\n    }\n\n    // When preserveThumbs is true (during drag), update existing thumbs in place\n    // to maintain pointer capture. Otherwise, rebuild all thumbs.\n    if (preserveThumbs) {\n      // Update existing thumb positions and colors without recreating elements\n      const existingThumbs = gradientThumbs.querySelectorAll<HTMLElement>('.we-gradient-thumb');\n      for (const thumb of existingThumbs) {\n        const stopId = thumb.dataset.stopId;\n        if (!stopId) continue;\n\n        const modelIndex = models.findIndex((m) => m.id === stopId);\n        if (modelIndex < 0) continue;\n\n        const stop = stops[modelIndex];\n        const preview = previewStops[modelIndex];\n        if (!stop || !preview) continue;\n\n        // Update position and color\n        thumb.style.left = `${clampPercent(stop.position)}%`;\n        thumb.style.backgroundColor = preview.color;\n        syncThumbSliderAria(thumb, stop.position);\n\n        // Update active state\n        const isActive = stopId === selectedStopId;\n        thumb.classList.toggle('we-gradient-thumb--active', isActive);\n      }\n    } else {\n      // Full rebuild: clear and recreate all thumbs\n      gradientThumbs.textContent = '';\n\n      for (let i = 0; i < stops.length; i++) {\n        const model = models[i];\n        const stop = stops[i];\n        const preview = previewStops[i];\n        if (!model || !stop || !preview) continue;\n\n        const thumb = document.createElement('button');\n        thumb.type = 'button';\n        thumb.className =\n          model.id === selectedStopId\n            ? 'we-gradient-thumb we-gradient-thumb--active'\n            : 'we-gradient-thumb';\n        thumb.dataset.stopId = model.id;\n        thumb.style.left = `${clampPercent(stop.position)}%`;\n        thumb.style.backgroundColor = preview.color;\n        syncThumbSliderAria(thumb, stop.position);\n\n        // Pointer event handlers for drag (Phase 5)\n        thumb.addEventListener('pointerdown', handleThumbPointerDown);\n\n        // Keyboard and focus handlers (Phase 9)\n        thumb.addEventListener('keydown', handleThumbKeyDown);\n        thumb.addEventListener('focus', handleThumbFocus);\n        thumb.addEventListener('blur', handleThumbBlur);\n\n        gradientThumbs.append(thumb);\n      }\n    }\n\n    // Stops list (Phase 4D) - skip during drag to avoid UI thrashing\n    if (refreshStopsList && !preserveThumbs) {\n      updateStopsList(models, stops, previewStops);\n    }\n  }\n\n  // -------------------------------------------------------------------------\n  // Thumb Drag Event Handlers (Phase 5)\n  // -------------------------------------------------------------------------\n\n  /**\n   * Handle pointerdown on a thumb to start drag.\n   * Sets up pointer capture and initializes the drag session.\n   */\n  function handleThumbPointerDown(event: PointerEvent): void {\n    // Prevent re-entry if drag is already in progress\n    if (thumbDrag) return;\n\n    // Defensive: don't allow drag when disabled or none type\n    if (currentType === 'none') return;\n    if (typeSelect.disabled) return;\n\n    // Only respond to primary button (left click) and primary pointer\n    if (event.button !== 0) return;\n    if (!event.isPrimary) return;\n\n    const thumb = event.currentTarget as HTMLElement;\n    const stopId = thumb.dataset.stopId;\n    if (!stopId) return;\n\n    // If a keyboard stepping session is active, transition to drag\n    // (share the same transaction handle)\n    if (thumbKeyboard) {\n      thumbKeyboard = null;\n    }\n\n    // Prevent default to avoid text selection, button activation, etc.\n    event.preventDefault();\n    event.stopPropagation();\n\n    // Select this stop\n    selectedStopId = stopId;\n\n    // Snapshot all positions for potential rollback\n    const initialPositions = new Map<StopId, number>();\n    for (const stop of currentStops) {\n      initialPositions.set(stop.id, stop.position);\n    }\n\n    // Start the drag session\n    thumbDrag = {\n      stopId,\n      pointerId: event.pointerId,\n      initialPositions,\n      thumbElement: thumb,\n    };\n\n    // Add visual feedback - dragging thumb raised above others\n    gradientBar.classList.add('we-gradient-bar--dragging');\n    thumb.classList.add('we-gradient-thumb--dragging');\n\n    // Capture pointer for reliable tracking outside element bounds\n    try {\n      thumb.setPointerCapture(event.pointerId);\n    } catch {\n      // Pointer capture may fail on some elements/browsers\n    }\n\n    // Begin transaction for live preview\n    beginTransaction();\n\n    // Update UI to show selected state\n    updateGradientBar({ preserveThumbs: true, refreshStopsList: false });\n  }\n\n  /**\n   * Handle pointermove during drag to update stop position.\n   * Called on window (capture phase) to ensure we capture all movement.\n   */\n  function handleThumbPointerMove(event: PointerEvent): void {\n    const session = thumbDrag;\n    if (!session) return;\n    if (event.pointerId !== session.pointerId) return;\n\n    // Calculate new position from pointer location\n    const newPosition = calculatePositionFromPointer(event.clientX);\n\n    // Update model\n    setStopPositionById(session.stopId, newPosition);\n\n    // Live preview to element (updateGradientBar is called inside previewGradient)\n    previewGradient();\n  }\n\n  /**\n   * Handle pointerup to end drag and commit changes.\n   */\n  function handleThumbPointerUp(event: PointerEvent): void {\n    const session = thumbDrag;\n    if (!session) return;\n    if (event.pointerId !== session.pointerId) return;\n\n    // Commit the drag\n    endThumbDrag(true);\n  }\n\n  /**\n   * Handle pointercancel (e.g., touch interrupted) to cancel drag.\n   */\n  function handleThumbPointerCancel(event: PointerEvent): void {\n    const session = thumbDrag;\n    if (!session) return;\n    if (event.pointerId !== session.pointerId) return;\n\n    // Rollback the drag\n    endThumbDrag(false);\n  }\n\n  /**\n   * Handle keydown during drag to support Escape cancellation.\n   */\n  function handleDragKeyDown(event: KeyboardEvent): void {\n    if (!thumbDrag) return;\n\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      event.stopImmediatePropagation();\n      event.stopPropagation();\n      endThumbDrag(false);\n    }\n  }\n\n  // Wire up window-level capture listeners for drag handling.\n  // UI events are stopped at the ShadowHost root, so these must be capture-phase.\n  const DRAG_LISTENER_OPTIONS: AddEventListenerOptions = { capture: true, passive: false };\n  disposer.listen(window, 'pointermove', handleThumbPointerMove, DRAG_LISTENER_OPTIONS);\n  disposer.listen(window, 'pointerup', handleThumbPointerUp, DRAG_LISTENER_OPTIONS);\n  disposer.listen(window, 'pointercancel', handleThumbPointerCancel, DRAG_LISTENER_OPTIONS);\n  disposer.listen(window, 'keydown', handleDragKeyDown, DRAG_LISTENER_OPTIONS);\n\n  /**\n   * Render stops list and sync selection with selectedStopId.\n   * Clicking a row selects the stop and refreshes thumbs via updateGradientBar().\n   */\n  function updateStopsList(\n    models: StopModel[],\n    stops: GradientStop[],\n    previewStops: GradientStop[],\n  ): void {\n    stopsList.textContent = '';\n    if (currentType === 'none') return;\n    if (models.length === 0 || stops.length === 0) return;\n\n    /**\n     * Format position value for display (e.g., \"50%\")\n     */\n    const formatPercentValue = (value: number): number => {\n      const clamped = clampPercent(value);\n      const rounded = Math.round(clamped * 100) / 100;\n      return Object.is(rounded, -0) ? 0 : rounded;\n    };\n\n    const formatPercentLabel = (value: number): string => `${formatPercentValue(value)}%`;\n\n    // Build rows with original index for stable ordering\n    const rows = stops\n      .map((stop, index) => ({\n        index,\n        stop,\n        model: models[index],\n        preview: previewStops[index],\n      }))\n      .filter((r) => Boolean(r.model && r.preview))\n      .sort((a, b) => a.stop.position - b.stop.position || a.index - b.index);\n\n    for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n      const r = rows[rowIndex]!;\n      const model = r.model!;\n      const stop = r.stop;\n      const preview = r.preview!;\n      const isActive = model.id === selectedStopId;\n\n      const row = document.createElement('div');\n      row.className = isActive\n        ? 'we-gradient-stop-row we-gradient-stop-row--active'\n        : 'we-gradient-stop-row';\n      row.dataset.stopId = model.id;\n      row.setAttribute('role', 'button');\n      row.tabIndex = 0;\n      row.setAttribute('aria-label', `Select stop at ${formatPercentLabel(stop.position)}`);\n\n      // Position column (Phase 7: static + editor dual-mode)\n      const pos = document.createElement('div');\n      pos.className = 'we-gradient-stop-pos';\n\n      // Static display (shown when not selected)\n      const posStatic = document.createElement('span');\n      posStatic.className = 'we-gradient-stop-pos-static';\n      posStatic.textContent = formatPercentLabel(stop.position);\n\n      // Position editor slot (shown when selected)\n      const posEditor = document.createElement('div');\n      posEditor.className = 'we-gradient-stop-pos-editor';\n\n      if (isActive) {\n        posEditor.append(selectedStopPosHost);\n        // Avoid resetting while user is typing\n        if (!isPositionInputFocused()) {\n          selectedStopPosInput.value = String(formatPercentValue(stop.position));\n        }\n      }\n\n      pos.append(posStatic, posEditor);\n\n      // Color column\n      const color = document.createElement('div');\n      color.className = 'we-gradient-stop-color';\n\n      // Static color display (shown when not selected)\n      const colorStatic = document.createElement('button');\n      colorStatic.type = 'button';\n      colorStatic.className = 'we-gradient-stop-color-static';\n      colorStatic.tabIndex = -1;\n      colorStatic.setAttribute('aria-label', 'Select stop');\n\n      const swatch = document.createElement('span');\n      swatch.className = 'we-gradient-stop-swatch';\n      swatch.style.backgroundColor = preview.color;\n\n      const text = document.createElement('span');\n      text.className = 'we-gradient-stop-color-text';\n      text.textContent = stop.color.trim() || DEFAULT_STOP_1.color;\n\n      colorStatic.append(swatch, text);\n\n      // Color editor slot (shown when selected)\n      const colorEditor = document.createElement('div');\n      colorEditor.className = 'we-gradient-stop-color-editor';\n\n      if (isActive) {\n        colorEditor.append(selectedStopColorHost);\n        // Avoid resetting while user is typing\n        if (!selectedStopColorField.isFocused()) {\n          selectedStopColorField.setValue(stop.color);\n          selectedStopColorField.setPlaceholder(\n            needsColorPlaceholder(stop.color) ? (model.placeholderColor ?? '') : '',\n          );\n        }\n      }\n\n      color.append(colorStatic, colorEditor);\n\n      // Remove button (Phase 6)\n      const removeBtn = document.createElement('button');\n      removeBtn.type = 'button';\n      removeBtn.className = 'we-icon-btn we-gradient-stop-remove';\n      removeBtn.setAttribute('aria-label', 'Remove stop');\n      // Disable if we can't remove (only 2 stops remaining or control is disabled)\n      const canRemove = !typeSelect.disabled && models.length > 2;\n      removeBtn.disabled = !canRemove;\n      removeBtn.textContent = '–';\n\n      removeBtn.addEventListener('click', (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        removeStopById(model.id);\n      });\n\n      // Focus helpers for position and color inputs\n      const focusSelectedPosInput = () => {\n        queueMicrotask(() => {\n          selectedStopPosInput.focus();\n          selectedStopPosInput.select();\n        });\n      };\n\n      const focusSelectedColorField = () => {\n        queueMicrotask(() => {\n          const input =\n            selectedStopColorHost.querySelector<HTMLInputElement>('input.we-color-text');\n          input?.focus();\n        });\n      };\n\n      // Click to select (with optional focus target)\n      const selectThisRow = (opts?: { focusColor?: boolean; focusPosition?: boolean }) => {\n        selectedStopId = model.id;\n        updateGradientBar();\n        if (opts?.focusColor) focusSelectedColorField();\n        if (opts?.focusPosition) focusSelectedPosInput();\n      };\n\n      row.addEventListener('click', (event) => {\n        if (model.id === selectedStopId) return;\n        event.preventDefault();\n        selectThisRow();\n      });\n\n      row.addEventListener('keydown', (event: KeyboardEvent) => {\n        // Don't hijack keys while user is editing text inputs inside the row\n        if (isTextInputLike(event.target)) return;\n\n        // Arrow key navigation between rows (Phase 9)\n        if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {\n          event.preventDefault();\n          event.stopPropagation();\n\n          const nextIndex =\n            event.key === 'ArrowUp'\n              ? Math.max(0, rowIndex - 1)\n              : Math.min(rows.length - 1, rowIndex + 1);\n          if (nextIndex === rowIndex) return;\n\n          const nextModel = rows[nextIndex]?.model;\n          if (!nextModel) return;\n\n          selectedStopId = nextModel.id;\n          updateGradientBar();\n\n          // Focus the next row after DOM update\n          queueMicrotask(() => {\n            const nextRow = stopsList.querySelector<HTMLElement>(\n              `.we-gradient-stop-row[data-stop-id=\"${nextModel.id}\"]`,\n            );\n            nextRow?.focus();\n          });\n          return;\n        }\n\n        // Enter/Space to select (only if not already selected)\n        if (model.id !== selectedStopId && (event.key === 'Enter' || event.key === ' ')) {\n          event.preventDefault();\n          selectThisRow();\n        }\n      });\n\n      // Clicking the position area selects and focuses the position editor\n      posStatic.addEventListener('click', (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        if (model.id === selectedStopId) {\n          focusSelectedPosInput();\n          return;\n        }\n        selectThisRow({ focusPosition: true });\n      });\n\n      // Clicking the color static area selects and focuses the color editor\n      colorStatic.addEventListener('click', (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        if (model.id === selectedStopId) {\n          focusSelectedColorField();\n          return;\n        }\n        selectThisRow({ focusColor: true });\n      });\n\n      row.append(pos, color, removeBtn);\n      stopsList.append(row);\n    }\n  }\n\n  function updateRowVisibility(): void {\n    gradientBarRow.hidden = currentType === 'none';\n    angleRow.hidden = currentType !== 'linear';\n    shapeRow.hidden = currentType !== 'radial';\n    posXRow.hidden = currentType !== 'radial';\n    posYRow.hidden = currentType !== 'radial';\n    stopsHeaderRow.hidden = currentType === 'none';\n    stopsList.hidden = currentType === 'none';\n    stopsAddBtn.disabled = typeSelect.disabled || currentType === 'none';\n  }\n\n  function setAllDisabled(disabled: boolean): void {\n    typeSelect.disabled = disabled;\n    angleInput.disabled = disabled;\n    shapeSelect.disabled = disabled;\n    posXInput.disabled = disabled;\n    posYInput.disabled = disabled;\n    stopsAddBtn.disabled = disabled;\n    selectedStopPosInput.disabled = disabled || currentType === 'none';\n    selectedStopColorField.setDisabled(disabled || currentType === 'none');\n  }\n\n  function resetDefaults(options: { skipPreview?: boolean } = {}): void {\n    angleInput.value = String(DEFAULT_LINEAR_ANGLE);\n    shapeSelect.value = 'ellipse';\n    posXInput.value = '';\n    posYInput.value = '';\n\n    // Reset stops array with new models (fresh IDs)\n    currentStops = createDefaultStopModels();\n    selectedStopId = currentStops[0]?.id ?? null;\n\n    if (!options.skipPreview) {\n      updateGradientBar();\n    }\n  }\n\n  /**\n   * Check if the position input is currently focused.\n   * Used to prevent list re-rendering while editing.\n   */\n  function isPositionInputFocused(): boolean {\n    return isFieldFocused(selectedStopPosInput);\n  }\n\n  function isEditing(): boolean {\n    return (\n      backgroundHandle !== null ||\n      isFieldFocused(typeSelect) ||\n      isFieldFocused(angleInput) ||\n      isFieldFocused(shapeSelect) ||\n      isFieldFocused(posXInput) ||\n      isFieldFocused(posYInput) ||\n      isPositionInputFocused() ||\n      selectedStopColorField.isFocused()\n    );\n  }\n\n  // -------------------------------------------------------------------------\n  // Formatting / Live Preview\n  // -------------------------------------------------------------------------\n\n  /**\n   * Format stops array as CSS color-stop list\n   */\n  function formatStopList(stops: GradientStop[]): string {\n    return stops\n      .map((s) => {\n        const color = s.color.trim() || DEFAULT_STOP_1.color;\n        const pos = clampPercent(s.position);\n        return `${color} ${pos}%`;\n      })\n      .join(', ');\n  }\n\n  /**\n   * Build CSS gradient string for writing back to element (background-image).\n   * Uses current UI input values for angle (linear) or shape/position (radial).\n   *\n   * @param stops - The gradient stops to include\n   * @returns CSS gradient string (e.g., \"linear-gradient(45deg, #fff 0%, #000 100%)\")\n   */\n  function buildElementGradientCss(stops: GradientStop[]): string {\n    if (currentType === 'none' || stops.length === 0) {\n      return 'none';\n    }\n\n    const stopsText = formatStopList(stops);\n\n    if (currentType === 'linear') {\n      const angle = clampAngle(parseNumber(angleInput.value) ?? DEFAULT_LINEAR_ANGLE);\n      return `linear-gradient(${angle}deg, ${stopsText})`;\n    }\n\n    // Radial gradient\n    const shape = (shapeSelect.value as RadialShape) || 'ellipse';\n    const rawX = posXInput.value.trim();\n    const rawY = posYInput.value.trim();\n    const hasPosition = Boolean(rawX || rawY);\n\n    if (!hasPosition) {\n      return `radial-gradient(${shape}, ${stopsText})`;\n    }\n\n    const x = clampPercent(parseNumber(rawX) ?? DEFAULT_POSITION);\n    const y = clampPercent(parseNumber(rawY) ?? DEFAULT_POSITION);\n    return `radial-gradient(${shape} at ${x}% ${y}%, ${stopsText})`;\n  }\n\n  /**\n   * Build CSS for the preview bar UI.\n   * Always outputs a horizontal 90deg linear-gradient regardless of actual gradient type.\n   * This provides a consistent left-to-right preview of stop positions and colors.\n   *\n   * @param stops - The gradient stops to preview\n   * @returns CSS linear-gradient string with 90deg angle\n   */\n  function buildPreviewBarCss(stops: GradientStop[]): string {\n    if (stops.length === 0) {\n      return 'linear-gradient(90deg, transparent, transparent)';\n    }\n    const stopsText = formatStopList(stops);\n    return `linear-gradient(90deg, ${stopsText})`;\n  }\n\n  /**\n   * Collect current stops from UI state, merging UI values for edited stops\n   * with preserved values for additional stops.\n   * Returns GradientStop[] for CSS generation (strips id field).\n   */\n  function collectCurrentStops(): GradientStop[] {\n    const baseStops = currentStops.length >= 2 ? currentStops : createDefaultStopModels();\n    return baseStops.map((s) => ({\n      color: s.color.trim() || DEFAULT_STOP_1.color,\n      position: clampPercent(s.position),\n    }));\n  }\n\n  /**\n   * Build the current gradient value for writing to element\n   */\n  function buildGradientValue(): string {\n    if (currentType === 'none') return 'none';\n    const stops = collectCurrentStops();\n    return buildElementGradientCss(stops);\n  }\n\n  function previewGradient(): void {\n    if (disposer.isDisposed) return;\n\n    // Avoid re-rendering stops list while dragging, keyboard stepping, or editing stop editors,\n    // otherwise thumbs may lose pointer capture/focus and inputs can lose focus/caret.\n    const isDragging = thumbDrag !== null;\n    const isKeyboardStepping = thumbKeyboard !== null;\n    const isEditingStopFields = selectedStopColorField.isFocused() || isPositionInputFocused();\n    updateGradientBar({\n      preserveThumbs: isDragging || isKeyboardStepping,\n      refreshStopsList: isDragging || isKeyboardStepping ? false : !isEditingStopFields,\n    });\n\n    const target = currentTarget;\n    if (!target || !target.isConnected) return;\n\n    const handle = beginTransaction();\n    if (!handle) return;\n\n    handle.set(buildGradientValue());\n  }\n\n  // -------------------------------------------------------------------------\n  // Sync (Render from Element State)\n  // -------------------------------------------------------------------------\n\n  function syncAllFields(force = false): void {\n    const target = currentTarget;\n\n    if (!target || !target.isConnected) {\n      setAllDisabled(true);\n      // Use 'linear' as default when 'none' is not allowed\n      const defaultType = allowNone ? 'none' : 'linear';\n      currentType = defaultType;\n      typeSelect.value = defaultType;\n      resetDefaults();\n      updateRowVisibility();\n      updateGradientBar();\n      return;\n    }\n\n    setAllDisabled(false);\n\n    if (isEditing() && !force) return;\n\n    const inlineValue = readInlineValue(target, cssProperty);\n    const needsComputed = !inlineValue || /\\bvar\\s*\\(/i.test(inlineValue);\n    const computedValue = needsComputed ? readComputedValue(target, cssProperty) : '';\n\n    const inlineParsed = !isNoneValue(inlineValue) ? parseGradient(inlineValue) : null;\n    const computedParsed = !isNoneValue(computedValue) ? parseGradient(computedValue) : null;\n\n    let parsed: ParsedGradient | null = null;\n    let source: 'inline' | 'computed' | 'none' = 'none';\n\n    if (inlineValue.trim()) {\n      if (isNoneValue(inlineValue)) {\n        parsed = null;\n        source = 'none';\n      } else if (inlineParsed) {\n        parsed = inlineParsed;\n        source = 'inline';\n      } else {\n        // Has value but couldn't parse - treat as none for our UI\n        parsed = null;\n        source = 'none';\n      }\n    } else {\n      if (isNoneValue(computedValue)) {\n        parsed = null;\n        source = 'none';\n      } else if (computedParsed) {\n        parsed = computedParsed;\n        source = 'computed';\n      } else {\n        parsed = null;\n        source = 'none';\n      }\n    }\n\n    resetDefaults({ skipPreview: true });\n\n    if (!parsed) {\n      // Use 'linear' as default when 'none' is not allowed\n      const defaultType = allowNone ? 'none' : 'linear';\n      currentType = defaultType;\n      typeSelect.value = defaultType;\n      updateRowVisibility();\n      updateGradientBar();\n      return;\n    }\n\n    // Convert parsed stops to StopModel[] with stable IDs\n    const rawStops: GradientStop[] =\n      parsed.stops.length >= 2\n        ? parsed.stops.slice()\n        : [{ ...DEFAULT_STOP_1 }, { ...DEFAULT_STOP_2 }];\n\n    // Apply placeholder mapping for var() values using nearest-neighbor matching\n    const hasVarInInline = source === 'inline' && needsColorPlaceholder(inlineValue);\n    if (hasVarInInline && computedParsed) {\n      const placeholderColors = buildPlaceholderMapping(rawStops, computedParsed.stops);\n      for (let i = 0; i < rawStops.length; i++) {\n        rawStops[i]!.placeholderColor = placeholderColors[i] ?? '';\n      }\n    }\n\n    // Reconcile with existing models to preserve stable IDs\n    currentStops = reconcileStopModels(currentStops, rawStops);\n\n    // Select first stop by default if nothing selected or selection is invalid\n    if (!selectedStopId || !currentStops.some((s) => s.id === selectedStopId)) {\n      selectedStopId = currentStops[0]?.id ?? null;\n    }\n\n    if (parsed.type === 'linear') {\n      currentType = 'linear';\n      typeSelect.value = 'linear';\n      angleInput.value = String(parsed.angle);\n    } else {\n      currentType = 'radial';\n      typeSelect.value = 'radial';\n      shapeSelect.value = parsed.shape;\n      if (parsed.position) {\n        posXInput.value = String(parsed.position.x);\n        posYInput.value = String(parsed.position.y);\n      } else {\n        posXInput.value = '';\n        posYInput.value = '';\n      }\n    }\n\n    updateRowVisibility();\n    updateGradientBar();\n  }\n\n  // -------------------------------------------------------------------------\n  // Event Wiring\n  // -------------------------------------------------------------------------\n\n  function wireTextInput(input: HTMLInputElement): void {\n    disposer.listen(input, 'input', previewGradient);\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction();\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction();\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction();\n        syncAllFields(true);\n      }\n    });\n  }\n\n  function wireSelect(select: HTMLSelectElement, onPreview?: () => void): void {\n    const preview = () => {\n      onPreview?.();\n      previewGradient();\n    };\n\n    disposer.listen(select, 'input', preview);\n    disposer.listen(select, 'change', preview);\n\n    disposer.listen(select, 'blur', () => {\n      commitTransaction();\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction();\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction();\n        syncAllFields(true);\n      }\n    });\n  }\n\n  wireSelect(typeSelect, () => {\n    currentType = typeSelect.value as GradientType;\n    updateRowVisibility();\n  });\n\n  wireSelect(shapeSelect);\n\n  wireTextInput(angleInput);\n  wireTextInput(posXInput);\n  wireTextInput(posYInput);\n\n  // -------------------------------------------------------------------------\n  // Stop Add/Delete Interactions (Phase 6)\n  // -------------------------------------------------------------------------\n\n  // Add stop via header button\n  disposer.listen(stopsAddBtn, 'click', (event: MouseEvent) => {\n    event.preventDefault();\n    if (stopsAddBtn.disabled) return;\n    addStopAtPosition(getSuggestedAddStopPosition(), { focusColor: true });\n  });\n\n  // Add stop via double-click on gradient bar\n  disposer.listen(gradientBar, 'dblclick', (event: MouseEvent) => {\n    // Don't add if dragging or if control is disabled\n    if (thumbDrag) return;\n    if (currentType === 'none' || typeSelect.disabled) return;\n\n    // Only add on \"empty bar\" double-click (ignore thumbs)\n    const path = event.composedPath();\n    if (\n      path.some((el) => el instanceof HTMLElement && el.classList.contains('we-gradient-thumb'))\n    ) {\n      return;\n    }\n\n    event.preventDefault();\n    addStopAtPosition(calculatePositionFromPointer(event.clientX), { focusColor: true });\n  });\n\n  // Delete stop via Delete/Backspace key\n  disposer.listen(root, 'keydown', (event: KeyboardEvent) => {\n    if (event.key !== 'Delete' && event.key !== 'Backspace') return;\n    if (thumbDrag) return;\n    if (currentType === 'none' || typeSelect.disabled) return;\n\n    const id = selectedStopId;\n    if (!id) return;\n\n    // Don't capture when user is editing text\n    if (isTextInputLike(event.target)) return;\n\n    // Only treat Delete/Backspace as stop deletion when the key event originates\n    // from the stops UI (bar or list), to avoid surprising deletions elsewhere\n    const path = event.composedPath();\n    if (!path.includes(stopsList) && !path.includes(gradientBar)) return;\n\n    event.preventDefault();\n    event.stopPropagation();\n    removeStopById(id);\n  });\n\n  // -------------------------------------------------------------------------\n  // DesignControl Interface\n  // -------------------------------------------------------------------------\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitTransaction();\n    currentTarget = element;\n    syncAllFields(true);\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitTransaction();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  // Initialize\n  typeSelect.value = currentType;\n  resetDefaults();\n  updateRowVisibility();\n  syncAllFields(true);\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/index.ts",
    "content": "/**\n * Design Controls Index\n *\n * Exports all design control factories.\n */\n\nexport { createSizeControl, type SizeControlOptions } from './size-control';\nexport { createSpacingControl, type SpacingControlOptions } from './spacing-control';\nexport { createPositionControl, type PositionControlOptions } from './position-control';\nexport { createLayoutControl, type LayoutControlOptions } from './layout-control';\nexport { createTypographyControl, type TypographyControlOptions } from './typography-control';\nexport { createAppearanceControl, type AppearanceControlOptions } from './appearance-control';\nexport { createEffectsControl, type EffectsControlOptions } from './effects-control';\nexport { createGradientControl, type GradientControlOptions } from './gradient-control';\nexport { createTokenPicker, type TokenPicker, type TokenPickerOptions } from './token-picker';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts",
    "content": "/**\n * Layout Control (Phase 3.4 + 4.1/4.2 - Refactored)\n *\n * Edits inline layout styles:\n * - display (icon button group): block/inline/inline-block/flex/grid/none\n * - flex-direction (icon button group, shown when display=flex)\n * - justify-content + align-items (content bars, shown when display=flex)\n * - grid-template-columns/rows (dimensions picker, shown when display=grid)\n * - flex-wrap (select, shown when display=flex)\n * - gap (input, shown when display=flex/grid)\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type {\n  MultiStyleTransactionHandle,\n  StyleTransactionHandle,\n  TransactionManager,\n} from '../../../core/transaction-manager';\nimport type { DesignControl } from '../types';\nimport { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\nconst DISPLAY_VALUES = ['block', 'inline', 'inline-block', 'flex', 'grid', 'none'] as const;\nconst FLEX_DIRECTION_VALUES = ['row', 'column', 'row-reverse', 'column-reverse'] as const;\nconst FLEX_WRAP_VALUES = ['nowrap', 'wrap', 'wrap-reverse'] as const;\nconst ALIGNMENT_AXIS_VALUES = ['flex-start', 'center', 'flex-end'] as const;\nconst GRID_DIMENSION_MAX = 12;\n\ntype DisplayValue = (typeof DISPLAY_VALUES)[number];\ntype FlexDirectionValue = (typeof FLEX_DIRECTION_VALUES)[number];\ntype AlignmentAxisValue = (typeof ALIGNMENT_AXIS_VALUES)[number];\n\n/** Single-property field keys */\ntype LayoutProperty = 'display' | 'flex-direction' | 'flex-wrap' | 'row-gap' | 'column-gap';\n\n/** All field keys including composite fields */\ntype FieldKey = LayoutProperty | 'alignment' | 'grid-dimensions';\n\n// =============================================================================\n// Field State Types\n// =============================================================================\n\ninterface DisplayFieldState {\n  kind: 'display-group';\n  property: 'display';\n  group: IconButtonGroup<DisplayValue>;\n  handle: StyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ninterface FlexDirectionFieldState {\n  kind: 'flex-direction-group';\n  property: 'flex-direction';\n  group: IconButtonGroup<FlexDirectionValue>;\n  handle: StyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ninterface SelectFieldState {\n  kind: 'select';\n  property: 'flex-wrap';\n  element: HTMLSelectElement;\n  handle: StyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ninterface InputFieldState {\n  kind: 'input';\n  property: 'row-gap' | 'column-gap';\n  element: HTMLInputElement;\n  container: InputContainer;\n  handle: StyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ninterface FlexAlignmentFieldState {\n  kind: 'flex-alignment';\n  properties: readonly ['justify-content', 'align-items'];\n  justifyGroup: IconButtonGroup<AlignmentAxisValue>;\n  alignGroup: IconButtonGroup<AlignmentAxisValue>;\n  handle: MultiStyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ninterface GridDimensionsFieldState {\n  kind: 'grid-dimensions';\n  properties: readonly ['grid-template-columns', 'grid-template-rows'];\n  previewButton: HTMLButtonElement;\n  previewColsValue: HTMLSpanElement;\n  previewRowsValue: HTMLSpanElement;\n  popover: HTMLDivElement;\n  colsContainer: InputContainer;\n  rowsContainer: InputContainer;\n  matrix: HTMLDivElement;\n  tooltip: HTMLDivElement;\n  cells: HTMLButtonElement[];\n  handle: MultiStyleTransactionHandle | null;\n  row: HTMLElement;\n}\n\ntype FieldState =\n  | DisplayFieldState\n  | FlexDirectionFieldState\n  | SelectFieldState\n  | InputFieldState\n  | FlexAlignmentFieldState\n  | GridDimensionsFieldState;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction isDisplayValue(value: string): value is DisplayValue {\n  return (DISPLAY_VALUES as readonly string[]).includes(value);\n}\n\nfunction isFlexDirectionValue(value: string): value is FlexDirectionValue {\n  return (FLEX_DIRECTION_VALUES as readonly string[]).includes(value);\n}\n\nfunction isAlignmentAxisValue(value: string): value is AlignmentAxisValue {\n  return (ALIGNMENT_AXIS_VALUES as readonly string[]).includes(value);\n}\n\n/**\n * Map computed display values to the closest option value.\n */\nfunction normalizeDisplayValue(computed: string): string {\n  const trimmed = computed.trim();\n  if (trimmed === 'inline-flex') return 'flex';\n  if (trimmed === 'inline-grid') return 'grid';\n  return trimmed;\n}\n\nfunction clampInt(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min;\n  return Math.min(max, Math.max(min, Math.trunc(value)));\n}\n\n/**\n * Split CSS value into top-level tokens (respects parentheses depth).\n */\nfunction splitTopLevelTokens(value: string): string[] {\n  const tokens: string[] = [];\n  let depth = 0;\n  let current = '';\n\n  for (let i = 0; i < value.length; i++) {\n    const ch = value[i]!;\n    if (ch === '(') depth++;\n    if (ch === ')' && depth > 0) depth--;\n\n    if (depth === 0 && /\\s/.test(ch)) {\n      const t = current.trim();\n      if (t) tokens.push(t);\n      current = '';\n      continue;\n    }\n    current += ch;\n  }\n\n  const tail = current.trim();\n  if (tail) tokens.push(tail);\n  return tokens;\n}\n\nfunction parseRepeatCount(token: string): number | null {\n  const match = token.match(/^repeat\\(\\s*(\\d+)\\s*,/i);\n  if (!match) return null;\n  const n = parseInt(match[1]!, 10);\n  return Number.isFinite(n) && n > 0 ? n : null;\n}\n\n/**\n * Count grid tracks from grid-template-columns/rows value.\n */\nfunction countGridTracks(raw: string): number | null {\n  const trimmed = raw.trim();\n  if (!trimmed || trimmed === 'none') return null;\n\n  const tokens = splitTopLevelTokens(trimmed);\n  let count = 0;\n\n  for (const t of tokens) {\n    // Ignore line-name tokens like [col-start]\n    if (/^\\[.*\\]$/.test(t)) continue;\n    count += parseRepeatCount(t) ?? 1;\n  }\n\n  return count > 0 ? count : null;\n}\n\nfunction formatGridTemplate(count: number): string {\n  const n = clampInt(count, 1, GRID_DIMENSION_MAX);\n  return n === 1 ? '1fr' : `repeat(${n}, 1fr)`;\n}\n\n// =============================================================================\n// SVG Icon Helpers\n// =============================================================================\n\nfunction createBaseIconSvg(): SVGSVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n  return svg;\n}\n\nfunction applyStroke(el: SVGElement, strokeWidth = '1.2'): void {\n  el.setAttribute('stroke', 'currentColor');\n  el.setAttribute('stroke-width', strokeWidth);\n  el.setAttribute('stroke-linecap', 'round');\n  el.setAttribute('stroke-linejoin', 'round');\n}\n\nfunction createDisplayIcon(value: DisplayValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 容器边框（虚线矩形表示容器）\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n\n  const addBlock = (x: number, y: number, w: number, h: number) => {\n    const rect = document.createElementNS(SVG_NS, 'rect');\n    rect.setAttribute('x', String(x));\n    rect.setAttribute('y', String(y));\n    rect.setAttribute('width', String(w));\n    rect.setAttribute('height', String(h));\n    rect.setAttribute('rx', '0.5');\n    rect.setAttribute('fill', 'currentColor');\n    svg.append(rect);\n  };\n\n  const addLine = (x: number, y: number, w: number) => {\n    const line = document.createElementNS(SVG_NS, 'rect');\n    line.setAttribute('x', String(x));\n    line.setAttribute('y', String(y));\n    line.setAttribute('width', String(w));\n    line.setAttribute('height', '1');\n    line.setAttribute('rx', '0.5');\n    line.setAttribute('fill', 'currentColor');\n    svg.append(line);\n  };\n\n  switch (value) {\n    case 'block':\n      // 两个全宽的块级元素，垂直堆叠\n      addBlock(3.5, 3.5, 8, 3);\n      addBlock(3.5, 8.5, 8, 3);\n      break;\n    case 'inline':\n      // 三行文本表示内联流\n      addLine(3.5, 4.5, 8);\n      addLine(3.5, 7.5, 5);\n      addLine(3.5, 10.5, 6.5);\n      break;\n    case 'inline-block':\n      // 左边一个块，右边两行文本\n      addBlock(3.5, 4.5, 3.5, 6);\n      addLine(8, 5.5, 4);\n      addLine(8, 8.5, 3);\n      break;\n    case 'flex':\n      // 三个水平排列的弹性子项\n      addBlock(3.5, 4.5, 2.5, 6);\n      addBlock(6.5, 4.5, 2.5, 6);\n      addBlock(9.5, 4.5, 2.5, 6);\n      break;\n    case 'grid':\n      // 2x2 网格布局\n      addBlock(3.5, 3.5, 3.5, 3.5);\n      addBlock(8, 3.5, 3.5, 3.5);\n      addBlock(3.5, 8, 3.5, 3.5);\n      addBlock(8, 8, 3.5, 3.5);\n      break;\n    case 'none': {\n      // 禁用符号：斜线\n      const slash = document.createElementNS(SVG_NS, 'path');\n      slash.setAttribute('d', 'M4 11L11 4');\n      slash.setAttribute('stroke', 'currentColor');\n      slash.setAttribute('stroke-width', '1.5');\n      slash.setAttribute('stroke-linecap', 'round');\n      svg.append(slash);\n      break;\n    }\n  }\n\n  svg.prepend(container);\n  return svg;\n}\n\nfunction createFlowIcon(direction: FlexDirectionValue): SVGElement {\n  const svg = createBaseIconSvg();\n  const path = document.createElementNS(SVG_NS, 'path');\n  applyStroke(path, '1.5');\n\n  const DIRECTION_PATHS: Record<FlexDirectionValue, string> = {\n    row: 'M2 7.5H13M10 4.5L13 7.5L10 10.5',\n    'row-reverse': 'M13 7.5H2M5 4.5L2 7.5L5 10.5',\n    column: 'M7.5 2V13M4.5 10L7.5 13L10.5 10',\n    'column-reverse': 'M7.5 13V2M4.5 5L7.5 2L10.5 5',\n  };\n\n  path.setAttribute('d', DIRECTION_PATHS[direction]);\n  svg.append(path);\n  return svg;\n}\n\nfunction createHorizontalAlignIcon(value: AlignmentAxisValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 容器边框（虚线矩形表示容器）\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n\n  // 内容块的 X 坐标根据对齐方式不同\n  const blockX: Record<AlignmentAxisValue, number> = {\n    'flex-start': 3.5, // 左对齐\n    center: 5.5, // 居中对齐\n    'flex-end': 7.5, // 右对齐\n  };\n\n  // 两个小方块表示子元素（水平方向排列变为垂直方向排列）\n  const block1 = document.createElementNS(SVG_NS, 'rect');\n  block1.setAttribute('x', String(blockX[value]));\n  block1.setAttribute('y', '4');\n  block1.setAttribute('width', '4');\n  block1.setAttribute('height', '3');\n  block1.setAttribute('rx', '0.5');\n  block1.setAttribute('fill', 'currentColor');\n\n  const block2 = document.createElementNS(SVG_NS, 'rect');\n  block2.setAttribute('x', String(blockX[value]));\n  block2.setAttribute('y', '8');\n  block2.setAttribute('width', '4');\n  block2.setAttribute('height', '3');\n  block2.setAttribute('rx', '0.5');\n  block2.setAttribute('fill', 'currentColor');\n\n  svg.append(container, block1, block2);\n  return svg;\n}\n\nfunction createVerticalAlignIcon(value: AlignmentAxisValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 容器边框（虚线矩形表示容器）\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n\n  // 内容块的 Y 坐标根据对齐方式不同\n  const blockY: Record<AlignmentAxisValue, number> = {\n    'flex-start': 3.5, // 顶部对齐\n    center: 5.5, // 居中对齐\n    'flex-end': 7.5, // 底部对齐\n  };\n\n  // 两个小方块表示子元素\n  const block1 = document.createElementNS(SVG_NS, 'rect');\n  block1.setAttribute('x', '4');\n  block1.setAttribute('y', String(blockY[value]));\n  block1.setAttribute('width', '3');\n  block1.setAttribute('height', '4');\n  block1.setAttribute('rx', '0.5');\n  block1.setAttribute('fill', 'currentColor');\n\n  const block2 = document.createElementNS(SVG_NS, 'rect');\n  block2.setAttribute('x', '8');\n  block2.setAttribute('y', String(blockY[value]));\n  block2.setAttribute('width', '3');\n  block2.setAttribute('height', '4');\n  block2.setAttribute('rx', '0.5');\n  block2.setAttribute('fill', 'currentColor');\n\n  svg.append(container, block1, block2);\n  return svg;\n}\n\nfunction createGapIcon(): SVGElement {\n  const svg = createBaseIconSvg();\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('d', 'M1.5 4.5H13.5M1.5 10.5H13.5');\n  svg.append(path);\n  return svg;\n}\n\nfunction createGridColumnsIcon(): SVGElement {\n  const svg = createBaseIconSvg();\n\n  const r1 = document.createElementNS(SVG_NS, 'rect');\n  r1.setAttribute('x', '3');\n  r1.setAttribute('y', '4');\n  r1.setAttribute('width', '3.5');\n  r1.setAttribute('height', '7');\n  r1.setAttribute('rx', '1');\n  applyStroke(r1);\n\n  const r2 = document.createElementNS(SVG_NS, 'rect');\n  r2.setAttribute('x', '8.5');\n  r2.setAttribute('y', '4');\n  r2.setAttribute('width', '3.5');\n  r2.setAttribute('height', '7');\n  r2.setAttribute('rx', '1');\n  applyStroke(r2);\n\n  svg.append(r1, r2);\n  return svg;\n}\n\nfunction createGridRowsIcon(): SVGElement {\n  const svg = createBaseIconSvg();\n\n  const r1 = document.createElementNS(SVG_NS, 'rect');\n  r1.setAttribute('x', '4');\n  r1.setAttribute('y', '3');\n  r1.setAttribute('width', '7');\n  r1.setAttribute('height', '3.5');\n  r1.setAttribute('rx', '1');\n  applyStroke(r1);\n\n  const r2 = document.createElementNS(SVG_NS, 'rect');\n  r2.setAttribute('x', '4');\n  r2.setAttribute('y', '8.5');\n  r2.setAttribute('width', '7');\n  r2.setAttribute('height', '3.5');\n  r2.setAttribute('rx', '1');\n  applyStroke(r2);\n\n  svg.append(r1, r2);\n  return svg;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface LayoutControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n}\n\nexport function createLayoutControl(options: LayoutControlOptions): DesignControl {\n  const { container, transactionManager } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ---------------------------------------------------------------------------\n  // Display row (icon button group)\n  // ---------------------------------------------------------------------------\n  const displayRow = document.createElement('div');\n  displayRow.className = 'we-field';\n\n  const displayLabel = document.createElement('span');\n  displayLabel.className = 'we-field-label';\n  displayLabel.textContent = 'Display';\n\n  const displayMount = document.createElement('div');\n  displayMount.className = 'we-field-content';\n\n  displayRow.append(displayLabel, displayMount);\n\n  const displayGroup = createIconButtonGroup<DisplayValue>({\n    container: displayMount,\n    ariaLabel: 'Display',\n    columns: 6,\n    items: DISPLAY_VALUES.map((v) => ({\n      value: v,\n      ariaLabel: v,\n      title: v,\n      icon: createDisplayIcon(v),\n    })),\n    onChange: (value) => {\n      const handle = beginTransaction('display');\n      if (handle) handle.set(value);\n      commitTransaction('display');\n      syncAllFields();\n    },\n  });\n  disposer.add(() => displayGroup.dispose());\n\n  // ---------------------------------------------------------------------------\n  // Flex direction row (icon button group)\n  // ---------------------------------------------------------------------------\n  const directionRow = document.createElement('div');\n  directionRow.className = 'we-field';\n\n  const directionLabel = document.createElement('span');\n  directionLabel.className = 'we-field-label';\n  directionLabel.textContent = 'Flow';\n\n  const directionMount = document.createElement('div');\n  directionMount.className = 'we-field-content';\n\n  directionRow.append(directionLabel, directionMount);\n\n  const directionGroup = createIconButtonGroup<FlexDirectionValue>({\n    container: directionMount,\n    ariaLabel: 'Flex direction',\n    columns: 4,\n    items: FLEX_DIRECTION_VALUES.map((dir) => ({\n      value: dir,\n      ariaLabel: dir.replace('-', ' '),\n      title: dir.replace('-', ' '),\n      icon: createFlowIcon(dir),\n    })),\n    onChange: (value) => {\n      const handle = beginTransaction('flex-direction');\n      if (handle) handle.set(value);\n      commitTransaction('flex-direction');\n      syncAllFields();\n    },\n  });\n  disposer.add(() => directionGroup.dispose());\n  directionGroup.setValue(null);\n\n  // ---------------------------------------------------------------------------\n  // Flex wrap row (select)\n  // ---------------------------------------------------------------------------\n  const wrapRow = document.createElement('div');\n  wrapRow.className = 'we-field';\n  const wrapLabel = document.createElement('span');\n  wrapLabel.className = 'we-field-label';\n  wrapLabel.textContent = 'Wrap';\n  const wrapSelect = document.createElement('select');\n  wrapSelect.className = 'we-select';\n  wrapSelect.setAttribute('aria-label', 'flex-wrap');\n  for (const v of FLEX_WRAP_VALUES) {\n    const opt = document.createElement('option');\n    opt.value = v;\n    opt.textContent = v;\n    wrapSelect.append(opt);\n  }\n  wrapRow.append(wrapLabel, wrapSelect);\n\n  // ---------------------------------------------------------------------------\n  // Alignment row (content bars for justify-content + align-items)\n  // ---------------------------------------------------------------------------\n  const alignmentRow = document.createElement('div');\n  alignmentRow.className = 'we-field';\n\n  const alignmentLabel = document.createElement('span');\n  alignmentLabel.className = 'we-field-label';\n  alignmentLabel.textContent = 'Align';\n\n  const alignmentMount = document.createElement('div');\n  alignmentMount.className = 'we-field-content';\n  alignmentMount.style.display = 'flex';\n  alignmentMount.style.gap = '4px';\n\n  alignmentRow.append(alignmentLabel, alignmentMount);\n\n  // Justify group with H label\n  const justifyWrapper = document.createElement('div');\n  justifyWrapper.style.flex = '1';\n  justifyWrapper.style.minWidth = '0';\n  justifyWrapper.style.display = 'flex';\n  justifyWrapper.style.flexDirection = 'column';\n  justifyWrapper.style.gap = '2px';\n\n  const justifyHint = document.createElement('span');\n  justifyHint.className = 'we-field-hint';\n  justifyHint.textContent = 'H';\n\n  const justifyMount = document.createElement('div');\n\n  justifyWrapper.append(justifyHint, justifyMount);\n\n  // Align group with V label\n  const alignWrapper = document.createElement('div');\n  alignWrapper.style.flex = '1';\n  alignWrapper.style.minWidth = '0';\n  alignWrapper.style.display = 'flex';\n  alignWrapper.style.flexDirection = 'column';\n  alignWrapper.style.gap = '2px';\n\n  const alignHint = document.createElement('span');\n  alignHint.className = 'we-field-hint';\n  alignHint.textContent = 'V';\n\n  const alignMount = document.createElement('div');\n\n  alignWrapper.append(alignHint, alignMount);\n\n  alignmentMount.append(justifyWrapper, alignWrapper);\n\n  const justifyGroup = createIconButtonGroup<AlignmentAxisValue>({\n    container: justifyMount,\n    ariaLabel: 'Justify content',\n    columns: 3,\n    items: ALIGNMENT_AXIS_VALUES.map((v) => ({\n      value: v,\n      ariaLabel: `justify-content: ${v}`,\n      title: v,\n      icon: createHorizontalAlignIcon(v),\n    })),\n    onChange: (justifyContent) => {\n      const handle = beginAlignmentTransaction();\n      if (!handle) return;\n      const alignItems = alignGroup.getValue() ?? 'center';\n      handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });\n      commitAlignmentTransaction();\n      syncAllFields();\n    },\n  });\n\n  const alignGroup = createIconButtonGroup<AlignmentAxisValue>({\n    container: alignMount,\n    ariaLabel: 'Align items',\n    columns: 3,\n    items: ALIGNMENT_AXIS_VALUES.map((v) => ({\n      value: v,\n      ariaLabel: `align-items: ${v}`,\n      title: v,\n      icon: createVerticalAlignIcon(v),\n    })),\n    onChange: (alignItems) => {\n      const handle = beginAlignmentTransaction();\n      if (!handle) return;\n      const justifyContent = justifyGroup.getValue() ?? 'center';\n      handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });\n      commitAlignmentTransaction();\n      syncAllFields();\n    },\n  });\n\n  disposer.add(() => justifyGroup.dispose());\n  disposer.add(() => alignGroup.dispose());\n  justifyGroup.setValue(null);\n  alignGroup.setValue(null);\n\n  // ---------------------------------------------------------------------------\n  // Grid dimensions row (grid-template-columns/rows)\n  // ---------------------------------------------------------------------------\n  const gridRow = document.createElement('div');\n  gridRow.className = 'we-field';\n\n  const gridLabel = document.createElement('span');\n  gridLabel.className = 'we-field-label';\n  gridLabel.textContent = 'Grid';\n\n  const gridMount = document.createElement('div');\n  gridMount.className = 'we-field-content';\n  gridMount.style.position = 'relative';\n\n  gridRow.append(gridLabel, gridMount);\n\n  const gridPreviewButton = document.createElement('button');\n  gridPreviewButton.type = 'button';\n  gridPreviewButton.className = 'we-grid-dimensions-preview';\n  gridPreviewButton.setAttribute('aria-label', 'Grid dimensions');\n  gridPreviewButton.setAttribute('aria-expanded', 'false');\n  gridPreviewButton.setAttribute('aria-haspopup', 'dialog');\n\n  // Single line preview: cols × rows\n  const gridPreviewColsValue = document.createElement('span');\n  gridPreviewColsValue.textContent = '1';\n  const gridPreviewTimes = document.createElement('span');\n  gridPreviewTimes.textContent = ' × ';\n  const gridPreviewRowsValue = document.createElement('span');\n  gridPreviewRowsValue.textContent = '1';\n\n  gridPreviewButton.append(gridPreviewColsValue, gridPreviewTimes, gridPreviewRowsValue);\n\n  const gridPopover = document.createElement('div');\n  gridPopover.className = 'we-grid-dimensions-popover';\n  gridPopover.hidden = true;\n\n  const gridInputs = document.createElement('div');\n  gridInputs.className = 'we-grid-dimensions-inputs';\n\n  const colsContainer = createInputContainer({\n    ariaLabel: 'Grid columns',\n    inputMode: 'numeric',\n    prefix: createGridColumnsIcon(),\n    suffix: null,\n  });\n  colsContainer.root.style.width = '72px';\n  colsContainer.root.style.flex = '0 0 auto';\n\n  const times = document.createElement('span');\n  times.className = 'we-grid-dimensions-times';\n  times.textContent = '×';\n\n  const rowsContainer = createInputContainer({\n    ariaLabel: 'Grid rows',\n    inputMode: 'numeric',\n    prefix: createGridRowsIcon(),\n    suffix: null,\n  });\n  rowsContainer.root.style.width = '72px';\n  rowsContainer.root.style.flex = '0 0 auto';\n\n  gridInputs.append(colsContainer.root, times, rowsContainer.root);\n\n  const matrix = document.createElement('div');\n  matrix.className = 'we-grid-dimensions-matrix';\n  matrix.setAttribute('role', 'grid');\n\n  const cells: HTMLButtonElement[] = [];\n  for (let r = 1; r <= GRID_DIMENSION_MAX; r++) {\n    for (let c = 1; c <= GRID_DIMENSION_MAX; c++) {\n      const cell = document.createElement('button');\n      cell.type = 'button';\n      cell.className = 'we-grid-dimensions-cell';\n      cell.dataset.row = String(r);\n      cell.dataset.col = String(c);\n      cell.setAttribute('role', 'gridcell');\n      cell.setAttribute('aria-label', `${c} × ${r}`);\n      cells.push(cell);\n      matrix.append(cell);\n    }\n  }\n\n  const tooltip = document.createElement('div');\n  tooltip.className = 'we-grid-dimensions-tooltip';\n  tooltip.hidden = true;\n\n  gridPopover.append(gridInputs, matrix, tooltip);\n  gridMount.append(gridPreviewButton, gridPopover);\n\n  wireNumberStepping(disposer, colsContainer.input, {\n    mode: 'number',\n    integer: true,\n    min: 1,\n    max: GRID_DIMENSION_MAX,\n  });\n  wireNumberStepping(disposer, rowsContainer.input, {\n    mode: 'number',\n    integer: true,\n    min: 1,\n    max: GRID_DIMENSION_MAX,\n  });\n\n  // ---------------------------------------------------------------------------\n  // Gap row (row-gap and column-gap inputs) - vertical layout\n  // ---------------------------------------------------------------------------\n  const gapRow = document.createElement('div');\n  gapRow.className = 'we-field';\n  const gapLabel = document.createElement('span');\n  gapLabel.className = 'we-field-label';\n  gapLabel.textContent = 'Gap';\n\n  const gapMount = document.createElement('div');\n  gapMount.className = 'we-field-content';\n\n  const gapInputs = document.createElement('div');\n  gapInputs.className = 'we-grid-gap-inputs';\n\n  const rowGapContainer = createInputContainer({\n    ariaLabel: 'Row gap',\n    inputMode: 'decimal',\n    prefix: createGridRowsIcon(),\n    suffix: 'px',\n  });\n\n  const columnGapContainer = createInputContainer({\n    ariaLabel: 'Column gap',\n    inputMode: 'decimal',\n    prefix: createGridColumnsIcon(),\n    suffix: 'px',\n  });\n\n  gapInputs.append(rowGapContainer.root, columnGapContainer.root);\n  gapMount.append(gapInputs);\n  gapRow.append(gapLabel, gapMount);\n\n  wireNumberStepping(disposer, rowGapContainer.input, { mode: 'css-length' });\n  wireNumberStepping(disposer, columnGapContainer.input, { mode: 'css-length' });\n\n  // ---------------------------------------------------------------------------\n  // Grid + Gap combined row (two columns when display=grid)\n  // ---------------------------------------------------------------------------\n  const gridGapRow = document.createElement('div');\n  gridGapRow.className = 'we-grid-gap-row';\n  gridGapRow.hidden = true;\n\n  // Adjust gridRow and gapRow to fit in two-column layout\n  gridRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--grid');\n  gapRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--gap');\n\n  gridGapRow.append(gridRow, gapRow);\n\n  // ---------------------------------------------------------------------------\n  // Assemble DOM\n  // ---------------------------------------------------------------------------\n  root.append(displayRow, directionRow, wrapRow, alignmentRow, gridGapRow);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ---------------------------------------------------------------------------\n  // Field State Registry\n  // ---------------------------------------------------------------------------\n  const fields: Record<FieldKey, FieldState> = {\n    display: {\n      kind: 'display-group',\n      property: 'display',\n      group: displayGroup,\n      handle: null,\n      row: displayRow,\n    },\n    'flex-direction': {\n      kind: 'flex-direction-group',\n      property: 'flex-direction',\n      group: directionGroup,\n      handle: null,\n      row: directionRow,\n    },\n    'flex-wrap': {\n      kind: 'select',\n      property: 'flex-wrap',\n      element: wrapSelect,\n      handle: null,\n      row: wrapRow,\n    },\n    alignment: {\n      kind: 'flex-alignment',\n      properties: ['justify-content', 'align-items'] as const,\n      justifyGroup,\n      alignGroup,\n      handle: null,\n      row: alignmentRow,\n    },\n    'grid-dimensions': {\n      kind: 'grid-dimensions',\n      properties: ['grid-template-columns', 'grid-template-rows'] as const,\n      previewButton: gridPreviewButton,\n      previewColsValue: gridPreviewColsValue,\n      previewRowsValue: gridPreviewRowsValue,\n      popover: gridPopover,\n      colsContainer,\n      rowsContainer,\n      matrix,\n      tooltip,\n      cells,\n      handle: null,\n      row: gridRow,\n    },\n    'row-gap': {\n      kind: 'input',\n      property: 'row-gap',\n      element: rowGapContainer.input,\n      container: rowGapContainer,\n      handle: null,\n      row: gapRow,\n    },\n    'column-gap': {\n      kind: 'input',\n      property: 'column-gap',\n      element: columnGapContainer.input,\n      container: columnGapContainer,\n      handle: null,\n      row: gapRow,\n    },\n  };\n\n  /** Single-property fields for iteration */\n  const STYLE_PROPS: readonly LayoutProperty[] = [\n    'display',\n    'flex-direction',\n    'flex-wrap',\n    'row-gap',\n    'column-gap',\n  ];\n  /** All field keys for iteration */\n  const FIELD_KEYS: readonly FieldKey[] = [\n    'display',\n    'flex-direction',\n    'flex-wrap',\n    'alignment',\n    'grid-dimensions',\n    'row-gap',\n    'column-gap',\n  ];\n\n  // ---------------------------------------------------------------------------\n  // Transaction Management\n  // ---------------------------------------------------------------------------\n\n  function beginTransaction(property: LayoutProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return null;\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: LayoutProperty): void {\n    const field = fields[property];\n    if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: LayoutProperty): void {\n    const field = fields[property];\n    if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function beginAlignmentTransaction(): MultiStyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields.alignment;\n    if (field.kind !== 'flex-alignment') return null;\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginMultiStyle(target, [...field.properties]);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitAlignmentTransaction(): void {\n    const field = fields.alignment;\n    if (field.kind !== 'flex-alignment') return;\n    const handle = field.handle;\n    field.handle = null;\n    handle?.commit({ merge: true });\n  }\n\n  function beginGridTransaction(): MultiStyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields['grid-dimensions'];\n    if (field.kind !== 'grid-dimensions') return null;\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginMultiStyle(target, [...field.properties]);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitGridTransaction(): void {\n    const field = fields['grid-dimensions'];\n    if (field.kind !== 'grid-dimensions') return;\n    const handle = field.handle;\n    field.handle = null;\n    handle?.commit({ merge: true });\n  }\n\n  function rollbackGridTransaction(): void {\n    const field = fields['grid-dimensions'];\n    if (field.kind !== 'grid-dimensions') return;\n    const handle = field.handle;\n    field.handle = null;\n    handle?.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of STYLE_PROPS) commitTransaction(p);\n    commitAlignmentTransaction();\n    commitGridTransaction();\n  }\n\n  // ---------------------------------------------------------------------------\n  // Visibility Control\n  // ---------------------------------------------------------------------------\n\n  function updateVisibility(): void {\n    const target = currentTarget;\n    const rawDisplay = target\n      ? readInlineValue(target, 'display') || readComputedValue(target, 'display')\n      : (displayGroup.getValue() ?? 'block');\n    const displayValue = normalizeDisplayValue(rawDisplay);\n\n    const trimmed = displayValue.trim();\n    const isFlex = trimmed === 'flex' || trimmed === 'inline-flex';\n    const isGrid = trimmed === 'grid' || trimmed === 'inline-grid';\n    const isFlexOrGrid = isFlex || isGrid;\n\n    directionRow.hidden = !isFlex;\n    wrapRow.hidden = !isFlex;\n    alignmentRow.hidden = !isFlex;\n\n    // Grid + Gap row visibility\n    gridGapRow.hidden = !isFlexOrGrid;\n    gridRow.hidden = !isGrid;\n    gapRow.hidden = !isFlexOrGrid;\n  }\n\n  // ---------------------------------------------------------------------------\n  // Field Synchronization\n  // ---------------------------------------------------------------------------\n\n  function syncField(key: FieldKey, force = false): void {\n    const field = fields[key];\n    const target = currentTarget;\n\n    // Handle display icon button group\n    if (field.kind === 'display-group') {\n      const group = field.group;\n\n      if (!target || !target.isConnected) {\n        group.setDisabled(true);\n        group.setValue(null);\n        return;\n      }\n\n      group.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, 'display');\n      const computed = readComputedValue(target, 'display');\n      let raw = (inline || computed).trim();\n      raw = normalizeDisplayValue(raw);\n      group.setValue(isDisplayValue(raw) ? raw : 'block');\n      return;\n    }\n\n    // Handle flex-direction icon button group\n    if (field.kind === 'flex-direction-group') {\n      const group = field.group;\n\n      if (!target || !target.isConnected) {\n        group.setDisabled(true);\n        group.setValue(null);\n        return;\n      }\n\n      group.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, 'flex-direction');\n      const computed = readComputedValue(target, 'flex-direction');\n      const raw = (inline || computed).trim();\n      group.setValue(isFlexDirectionValue(raw) ? raw : null);\n      return;\n    }\n\n    // Handle flex alignment (content bars)\n    if (field.kind === 'flex-alignment') {\n      if (!target || !target.isConnected) {\n        field.justifyGroup.setDisabled(true);\n        field.alignGroup.setDisabled(true);\n        field.justifyGroup.setValue(null);\n        field.alignGroup.setValue(null);\n        return;\n      }\n\n      field.justifyGroup.setDisabled(false);\n      field.alignGroup.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const justifyInline = readInlineValue(target, 'justify-content');\n      const justifyComputed = readComputedValue(target, 'justify-content');\n      const alignInline = readInlineValue(target, 'align-items');\n      const alignComputed = readComputedValue(target, 'align-items');\n\n      const justifyRaw = (justifyInline || justifyComputed).trim();\n      const alignRaw = (alignInline || alignComputed).trim();\n\n      if (isAlignmentAxisValue(justifyRaw) && isAlignmentAxisValue(alignRaw)) {\n        field.justifyGroup.setValue(justifyRaw);\n        field.alignGroup.setValue(alignRaw);\n      } else {\n        field.justifyGroup.setValue(null);\n        field.alignGroup.setValue(null);\n      }\n      return;\n    }\n\n    // Handle grid dimensions (grid-template-columns/rows)\n    if (field.kind === 'grid-dimensions') {\n      const {\n        previewButton,\n        popover,\n        colsContainer,\n        rowsContainer,\n        tooltip,\n        cells: gridCells,\n      } = field;\n\n      if (!target || !target.isConnected) {\n        previewButton.disabled = true;\n        field.previewColsValue.textContent = '—';\n        field.previewRowsValue.textContent = '—';\n        previewButton.setAttribute('aria-expanded', 'false');\n        popover.hidden = true;\n        colsContainer.input.disabled = true;\n        rowsContainer.input.disabled = true;\n        tooltip.hidden = true;\n        for (const cell of gridCells) {\n          cell.dataset.active = 'false';\n          cell.dataset.selected = 'false';\n        }\n        return;\n      }\n\n      previewButton.disabled = false;\n      colsContainer.input.disabled = false;\n      rowsContainer.input.disabled = false;\n\n      const isEditing =\n        field.handle !== null ||\n        isFieldFocused(colsContainer.input) ||\n        isFieldFocused(rowsContainer.input);\n      if (isEditing && !force) return;\n\n      const colsRaw =\n        readInlineValue(target, 'grid-template-columns') ||\n        readComputedValue(target, 'grid-template-columns');\n      const rowsRaw =\n        readInlineValue(target, 'grid-template-rows') ||\n        readComputedValue(target, 'grid-template-rows');\n\n      const cols = clampInt(countGridTracks(colsRaw) ?? 1, 1, GRID_DIMENSION_MAX);\n      const rows = clampInt(countGridTracks(rowsRaw) ?? 1, 1, GRID_DIMENSION_MAX);\n\n      colsContainer.input.value = String(cols);\n      rowsContainer.input.value = String(rows);\n      field.previewColsValue.textContent = String(cols);\n      field.previewRowsValue.textContent = String(rows);\n      // Update aria-label for screen readers\n      previewButton.setAttribute('aria-label', `Grid: ${cols} columns, ${rows} rows`);\n\n      // Default rendering uses current values\n      tooltip.hidden = true;\n      for (const cell of gridCells) {\n        const c = parseInt(cell.dataset.col ?? '0', 10);\n        const r = parseInt(cell.dataset.row ?? '0', 10);\n        const selected = c > 0 && r > 0 && c <= cols && r <= rows;\n        cell.dataset.selected = selected ? 'true' : 'false';\n        cell.dataset.active = selected ? 'true' : 'false';\n      }\n      return;\n    }\n\n    // Handle input field (row-gap / column-gap)\n    if (field.kind === 'input') {\n      const input = field.element;\n\n      if (!target || !target.isConnected) {\n        input.disabled = true;\n        input.value = '';\n        input.placeholder = '';\n        field.container.setSuffix('px');\n        return;\n      }\n\n      input.disabled = false;\n      const isEditing = field.handle !== null || isFieldFocused(input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, field.property);\n      const displayValue = inlineValue || readComputedValue(target, field.property);\n      const formatted = formatLengthForDisplay(displayValue);\n      input.value = formatted.value;\n      field.container.setSuffix(formatted.suffix);\n      input.placeholder = '';\n      return;\n    }\n\n    // Handle select field (flex-wrap)\n    if (field.kind === 'select') {\n      const select = field.element;\n\n      if (!target || !target.isConnected) {\n        select.disabled = true;\n        return;\n      }\n\n      select.disabled = false;\n      const isEditing = field.handle !== null || isFieldFocused(select);\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, field.property);\n      const computed = readComputedValue(target, field.property);\n      const val = inline || computed;\n\n      const hasOption = Array.from(select.options).some((o) => o.value === val);\n      select.value = hasOption ? val : (select.options[0]?.value ?? '');\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const key of FIELD_KEYS) syncField(key);\n    updateVisibility();\n  }\n\n  // ---------------------------------------------------------------------------\n  // Event Wiring\n  // ---------------------------------------------------------------------------\n\n  function wireSelect(property: 'flex-wrap'): void {\n    const field = fields[property];\n    if (field.kind !== 'select') return;\n    const select = field.element;\n\n    const preview = () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(select.value);\n    };\n\n    disposer.listen(select, 'input', preview);\n    disposer.listen(select, 'change', preview);\n    disposer.listen(select, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  function wireInput(property: 'row-gap' | 'column-gap'): void {\n    const field = fields[property];\n    if (field.kind !== 'input') return;\n    const input = field.element;\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (!handle) return;\n      const suffix = field.container.getSuffixText();\n      handle.set(combineLengthValue(input.value, suffix));\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  wireSelect('flex-wrap');\n  wireInput('row-gap');\n  wireInput('column-gap');\n\n  // ---------------------------------------------------------------------------\n  // Grid Dimensions Picker Wiring\n  // ---------------------------------------------------------------------------\n\n  let gridHoverCols: number | null = null;\n  let gridHoverRows: number | null = null;\n\n  function renderGridSelection(field: GridDimensionsFieldState, cols: number, rows: number): void {\n    const activeCols = gridHoverCols ?? cols;\n    const activeRows = gridHoverRows ?? rows;\n\n    for (const cell of field.cells) {\n      const c = parseInt(cell.dataset.col ?? '0', 10);\n      const r = parseInt(cell.dataset.row ?? '0', 10);\n      const selected = c > 0 && r > 0 && c <= cols && r <= rows;\n      const active = c > 0 && r > 0 && c <= activeCols && r <= activeRows;\n      cell.dataset.selected = selected ? 'true' : 'false';\n      cell.dataset.active = active ? 'true' : 'false';\n    }\n\n    if (gridHoverCols !== null && gridHoverRows !== null) {\n      field.tooltip.textContent = `${gridHoverCols} × ${gridHoverRows}`;\n      field.tooltip.hidden = false;\n    } else {\n      field.tooltip.hidden = true;\n    }\n  }\n\n  function setGridPopoverOpen(field: GridDimensionsFieldState, open: boolean): void {\n    field.popover.hidden = !open;\n    field.previewButton.setAttribute('aria-expanded', open ? 'true' : 'false');\n\n    // Reset hover when opening/closing\n    gridHoverCols = null;\n    gridHoverRows = null;\n\n    const cols = clampInt(\n      parseInt(field.colsContainer.input.value || '1', 10) || 1,\n      1,\n      GRID_DIMENSION_MAX,\n    );\n    const rows = clampInt(\n      parseInt(field.rowsContainer.input.value || '1', 10) || 1,\n      1,\n      GRID_DIMENSION_MAX,\n    );\n    renderGridSelection(field, cols, rows);\n  }\n\n  function previewGridDimensions(cols: number, rows: number): void {\n    const handle = beginGridTransaction();\n    if (!handle) return;\n    handle.set({\n      'grid-template-columns': formatGridTemplate(cols),\n      'grid-template-rows': formatGridTemplate(rows),\n    });\n  }\n\n  const gridField = fields['grid-dimensions'];\n  if (gridField.kind === 'grid-dimensions') {\n    disposer.listen(gridField.previewButton, 'click', (e: MouseEvent) => {\n      e.preventDefault();\n      setGridPopoverOpen(gridField, gridField.popover.hidden);\n      if (!gridField.popover.hidden) {\n        gridField.colsContainer.input.focus();\n        gridField.colsContainer.input.select();\n      }\n    });\n\n    // Close popover when clicking outside\n    // Use capture phase to catch clicks in Shadow DOM\n    const rootNode = gridField.previewButton.getRootNode() as Document | ShadowRoot;\n    const clickTarget = rootNode instanceof ShadowRoot ? rootNode : document;\n\n    const handleOutsideClick = (e: Event): void => {\n      if (gridField.kind !== 'grid-dimensions') return;\n      if (gridField.popover.hidden) return;\n      const target = e.target as Node;\n      // Check if click is inside gridRow (which contains both button and popover)\n      if (!gridRow.contains(target)) {\n        setGridPopoverOpen(gridField, false);\n      }\n    };\n\n    clickTarget.addEventListener('click', handleOutsideClick, true);\n    disposer.add(() => {\n      clickTarget.removeEventListener('click', handleOutsideClick, true);\n    });\n\n    // Inputs: live preview, blur commit, ESC rollback\n    const wireGridInput = (input: HTMLInputElement) => {\n      disposer.listen(input, 'input', () => {\n        const cols = clampInt(\n          parseInt(gridField.colsContainer.input.value || '1', 10) || 1,\n          1,\n          GRID_DIMENSION_MAX,\n        );\n        const rows = clampInt(\n          parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,\n          1,\n          GRID_DIMENSION_MAX,\n        );\n        renderGridSelection(gridField, cols, rows);\n        previewGridDimensions(cols, rows);\n      });\n\n      disposer.listen(input, 'blur', () => {\n        commitGridTransaction();\n        syncAllFields();\n      });\n\n      disposer.listen(input, 'keydown', (ev: KeyboardEvent) => {\n        if (ev.key === 'Enter') {\n          ev.preventDefault();\n          commitGridTransaction();\n          syncAllFields();\n          return;\n        }\n        if (ev.key === 'Escape') {\n          ev.preventDefault();\n          rollbackGridTransaction();\n          setGridPopoverOpen(gridField, false);\n          syncField('grid-dimensions', true);\n        }\n      });\n    };\n\n    wireGridInput(gridField.colsContainer.input);\n    wireGridInput(gridField.rowsContainer.input);\n\n    // Matrix hover + click select\n    disposer.listen(gridField.matrix, 'mouseover', (e: MouseEvent) => {\n      const el = e.target as HTMLElement;\n      const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;\n      if (!cell) return;\n      gridHoverCols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);\n      gridHoverRows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);\n      const cols = clampInt(\n        parseInt(gridField.colsContainer.input.value || '1', 10) || 1,\n        1,\n        GRID_DIMENSION_MAX,\n      );\n      const rows = clampInt(\n        parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,\n        1,\n        GRID_DIMENSION_MAX,\n      );\n      renderGridSelection(gridField, cols, rows);\n    });\n\n    disposer.listen(gridField.matrix, 'mouseleave', () => {\n      gridHoverCols = null;\n      gridHoverRows = null;\n      const cols = clampInt(\n        parseInt(gridField.colsContainer.input.value || '1', 10) || 1,\n        1,\n        GRID_DIMENSION_MAX,\n      );\n      const rows = clampInt(\n        parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,\n        1,\n        GRID_DIMENSION_MAX,\n      );\n      renderGridSelection(gridField, cols, rows);\n    });\n\n    disposer.listen(gridField.matrix, 'click', (e: MouseEvent) => {\n      const el = e.target as HTMLElement;\n      const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;\n      if (!cell) return;\n      const cols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);\n      const rows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);\n      gridField.colsContainer.input.value = String(cols);\n      gridField.rowsContainer.input.value = String(rows);\n      previewGridDimensions(cols, rows);\n      commitGridTransaction();\n      setGridPopoverOpen(gridField, false);\n      syncAllFields();\n    });\n  }\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/number-stepping.ts",
    "content": "/**\n * Number Stepping (Keyboard)\n *\n * Adds ArrowUp/ArrowDown stepping for number-like inputs in the property panel.\n * Supports both pure numbers and CSS length values (e.g., \"10px\", \"1.5rem\").\n *\n * Usage:\n * - size-control.ts (width/height)\n * - spacing-control.ts (margin/padding)\n * - position-control.ts (top/right/bottom/left, z-index)\n * - layout-control.ts (gap)\n * - typography-control.ts (font-size, line-height)\n * - appearance-control.ts (opacity, border-radius, border-width)\n */\n\nimport type { Disposer } from '../../../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Stepping mode: pure number or CSS length with unit */\nexport type NumberSteppingMode = 'number' | 'css-length';\n\nexport interface NumberSteppingOptions {\n  /** Mode: 'number' for pure numbers, 'css-length' for values with units */\n  mode: NumberSteppingMode;\n  /** Base step amount (default: 1) */\n  step?: number;\n  /** Step amount when Shift is held (default: 10) */\n  shiftStep?: number;\n  /** Step amount when Alt/Option is held (default: 0.1) */\n  altStep?: number;\n  /** Minimum value (optional) */\n  min?: number;\n  /** Maximum value (optional) */\n  max?: number;\n  /** Round to integer (default: false) */\n  integer?: boolean;\n  /** Allowed units for css-length mode (default: ['', 'px']) */\n  allowedUnits?: readonly string[];\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_STEP = 1;\nconst DEFAULT_SHIFT_STEP = 10;\nconst DEFAULT_ALT_STEP = 0.1;\n// Default units for CSS length values\n// Note: auto, calc(), var(), etc. are not steppable\nconst DEFAULT_ALLOWED_UNITS: readonly string[] = [\n  '',\n  'px',\n  '%',\n  'rem',\n  'em',\n  'vh',\n  'vw',\n  'vmin',\n  'vmax',\n];\nconst MAX_FRACTION_DIGITS = 10;\n\n// =============================================================================\n// Parsing Helpers\n// =============================================================================\n\ninterface ParsedNumber {\n  value: number;\n  digits: number;\n}\n\ninterface ParsedCssLength extends ParsedNumber {\n  unit: string;\n}\n\n/**\n * Count decimal digits in a numeric string\n */\nfunction countFractionDigits(raw: string): number {\n  const dotIndex = raw.indexOf('.');\n  if (dotIndex < 0) return 0;\n  return Math.max(0, raw.length - dotIndex - 1);\n}\n\n/**\n * Get decimal digits needed to represent a step value\n */\nfunction countStepDigits(step: number): number {\n  if (!Number.isFinite(step)) return 0;\n  const raw = String(step);\n  // Avoid scientific notation\n  if (raw.includes('e') || raw.includes('E')) return 0;\n  return countFractionDigits(raw);\n}\n\n/**\n * Clamp fraction digits to a reasonable range\n */\nfunction clampDigits(digits: number): number {\n  if (!Number.isFinite(digits)) return 0;\n  return Math.max(0, Math.min(MAX_FRACTION_DIGITS, Math.trunc(digits)));\n}\n\n/**\n * Format a number with specified decimal digits, trimming trailing zeros\n */\nfunction formatNumber(value: number, digits: number): string {\n  const d = clampDigits(digits);\n  const fixed = value.toFixed(d);\n  if (d === 0) return fixed;\n  // Remove trailing zeros: \"1.500\" -> \"1.5\", \"1.0\" -> \"1\"\n  return fixed.replace(/(\\.\\d*?)0+$/, '$1').replace(/\\.$/, '');\n}\n\n// Regex for parsing numbers: 10, 10., 10.5, .5, -.5\nconst NUMBER_REGEX = /^(-?(?:(?:\\d+\\.\\d+)|(?:\\d+\\.)|(?:\\d+)|(?:\\.\\d+)))$/;\n\n/**\n * Parse a pure number string\n */\nfunction parseNumberValue(raw: string): ParsedNumber | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n\n  const match = trimmed.match(NUMBER_REGEX);\n  if (!match) return null;\n\n  const numRaw = match[1] ?? '';\n  const normalized = numRaw.endsWith('.') ? numRaw.slice(0, -1) : numRaw;\n  const value = Number(normalized);\n  if (!Number.isFinite(value)) return null;\n\n  return { value, digits: countFractionDigits(normalized) };\n}\n\n// Regex for parsing CSS lengths: 10px, 10.5rem, .5em, etc.\nconst CSS_LENGTH_REGEX = /^(-?(?:(?:\\d+\\.\\d+)|(?:\\d+\\.)|(?:\\d+)|(?:\\.\\d+)))\\s*([a-zA-Z%]*)$/;\n\n/**\n * Parse a CSS length value (number + optional unit)\n */\nfunction parseCssLengthValue(raw: string, allowedUnits: readonly string[]): ParsedCssLength | null {\n  const trimmed = raw.trim();\n  if (!trimmed) return null;\n\n  const match = trimmed.match(CSS_LENGTH_REGEX);\n  if (!match) return null;\n\n  const numRaw = match[1] ?? '';\n  const unit = (match[2] ?? '').toLowerCase();\n\n  // Validate unit\n  if (!allowedUnits.includes(unit)) return null;\n\n  const normalized = numRaw.endsWith('.') ? numRaw.slice(0, -1) : numRaw;\n  const value = Number(normalized);\n  if (!Number.isFinite(value)) return null;\n\n  return { value, digits: countFractionDigits(normalized), unit };\n}\n\n// =============================================================================\n// Main Function\n// =============================================================================\n\n/**\n * Wire up keyboard stepping for a number input\n *\n * @param disposer - Disposer for cleanup\n * @param input - The input element to enhance\n * @param options - Stepping configuration\n */\nexport function wireNumberStepping(\n  disposer: Disposer,\n  input: HTMLInputElement,\n  options: NumberSteppingOptions,\n): void {\n  const {\n    mode,\n    step: baseStep = DEFAULT_STEP,\n    shiftStep = DEFAULT_SHIFT_STEP,\n    altStep = DEFAULT_ALT_STEP,\n    min,\n    max,\n    integer = false,\n    allowedUnits = DEFAULT_ALLOWED_UNITS,\n  } = options;\n\n  disposer.listen(input, 'keydown', (event: KeyboardEvent) => {\n    // Only handle arrow up/down\n    if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;\n\n    // Preserve navigation shortcuts (Cmd/Ctrl + Arrow for cursor movement)\n    if (event.metaKey || event.ctrlKey) return;\n\n    // Skip disabled/readonly inputs\n    if (input.disabled || input.readOnly) return;\n\n    const direction = event.key === 'ArrowUp' ? 1 : -1;\n\n    // Determine step size based on modifier keys\n    let delta: number;\n    if (event.altKey) {\n      delta = altStep;\n    } else if (event.shiftKey) {\n      delta = shiftStep;\n    } else {\n      delta = baseStep;\n    }\n\n    if (!Number.isFinite(delta) || delta === 0) return;\n\n    // Get current value (prefer input value, fallback to placeholder)\n    const source = (input.value || input.placeholder || '').trim();\n\n    // Parse based on mode\n    let parsed: ParsedNumber | ParsedCssLength | null = null;\n    let unit = '';\n\n    if (mode === 'number') {\n      parsed = parseNumberValue(source);\n      if (!parsed && !source) {\n        // Empty input: start from 0\n        parsed = { value: 0, digits: 0 };\n      }\n    } else {\n      const cssResult = parseCssLengthValue(source, allowedUnits);\n      if (cssResult) {\n        parsed = cssResult;\n        unit = cssResult.unit;\n      } else if (!source) {\n        // Empty input: start from 0\n        parsed = { value: 0, digits: 0 };\n        unit = '';\n      }\n    }\n\n    if (!parsed) return;\n\n    // Calculate new value\n    const digits = integer ? 0 : Math.max(parsed.digits, countStepDigits(delta));\n    let next = parsed.value + direction * delta;\n\n    // Apply constraints\n    if (typeof min === 'number') next = Math.max(min, next);\n    if (typeof max === 'number') next = Math.min(max, next);\n    if (integer) next = Math.round(next);\n\n    // Format result\n    const formatted = formatNumber(next, digits);\n    const nextRaw = mode === 'css-length' ? `${formatted}${unit}` : formatted;\n\n    // Prevent default and update input\n    event.preventDefault();\n    input.value = nextRaw;\n\n    // Dispatch input event to trigger existing preview/transaction logic\n    try {\n      input.dispatchEvent(new Event('input', { bubbles: true }));\n    } catch {\n      // Best-effort event dispatch\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/position-control.ts",
    "content": "/**\n * Position Control (Phase 3.3 - Refactored)\n *\n * Edits inline positioning and transform styles:\n * - position (icon button group): static/relative/absolute/fixed/sticky\n * - X (left), Y (top), Z (z-index) inputs\n * - rotate (transform: rotate)\n * - flip X/Y (transform: scaleX/scaleY toggles)\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignControl } from '../types';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\n\n// =============================================================================\n// Types\n// =============================================================================\n\ntype PositionValue = 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';\n\n/** Single-style field keys */\ntype StyleProperty = 'position' | 'left' | 'top' | 'z-index';\n\n/** All field keys */\ntype FieldKey = StyleProperty | 'transform';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\nconst POSITION_VALUES: readonly PositionValue[] = [\n  'static',\n  'relative',\n  'absolute',\n  'fixed',\n  'sticky',\n];\n\nfunction createBaseIconSvg(): SVGSVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n  return svg;\n}\n\nfunction createIconContainer(svg: SVGSVGElement): void {\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n  svg.prepend(container);\n}\n\n// =============================================================================\n// Transform Parsing Helpers\n// =============================================================================\n\n/** Represents a single transform function with its full string and parsed components */\ninterface TransformFunction {\n  /** Original full string, e.g. \"rotate(45deg)\" */\n  original: string;\n  /** Function name lowercase, e.g. \"rotate\" */\n  name: string;\n  /** Arguments string (without parentheses), e.g. \"45deg\" */\n  args: string;\n}\n\n/** Parsed transform state for editing */\ninterface TransformState {\n  /** All parsed transform functions in original order */\n  functions: TransformFunction[];\n  /** Index of rotate function (-1 if not found) */\n  rotateIndex: number;\n  /** Index of scaleX function (-1 if not found) */\n  scaleXIndex: number;\n  /** Index of scaleY function (-1 if not found) */\n  scaleYIndex: number;\n}\n\n/**\n * Tokenize transform string into individual functions.\n * Uses bracket-depth scanning to handle nested parentheses like var(), calc().\n */\nfunction tokenizeTransform(transform: string): TransformFunction[] {\n  const result: TransformFunction[] = [];\n  if (!transform || transform === 'none') return result;\n\n  const trimmed = transform.trim();\n  let i = 0;\n\n  while (i < trimmed.length) {\n    // Skip whitespace\n    while (i < trimmed.length && /\\s/.test(trimmed[i]!)) i++;\n    if (i >= trimmed.length) break;\n\n    // Find function name (letters, numbers, hyphens before '(')\n    const nameStart = i;\n    while (i < trimmed.length && /[\\w-]/.test(trimmed[i]!)) i++;\n    const name = trimmed.slice(nameStart, i);\n\n    // Skip whitespace before '('\n    while (i < trimmed.length && /\\s/.test(trimmed[i]!)) i++;\n\n    // Expect '('\n    if (trimmed[i] !== '(') {\n      // Not a function, skip\n      i++;\n      continue;\n    }\n\n    // Find matching ')' using bracket depth\n    const argsStart = i + 1;\n    let depth = 1;\n    i++;\n\n    while (i < trimmed.length && depth > 0) {\n      if (trimmed[i] === '(') depth++;\n      else if (trimmed[i] === ')') depth--;\n      i++;\n    }\n\n    const argsEnd = i - 1;\n    const args = trimmed.slice(argsStart, argsEnd).trim();\n    const original = trimmed.slice(nameStart, i);\n\n    result.push({\n      original,\n      name: name.toLowerCase(),\n      args,\n    });\n  }\n\n  return result;\n}\n\n/**\n * Parse transform string into editable state.\n * Finds rotate, scaleX, scaleY positions while preserving all functions.\n */\nfunction parseTransform(transform: string): TransformState {\n  const functions = tokenizeTransform(transform);\n\n  let rotateIndex = -1;\n  let scaleXIndex = -1;\n  let scaleYIndex = -1;\n\n  for (let i = 0; i < functions.length; i++) {\n    const fn = functions[i]!;\n    switch (fn.name) {\n      case 'rotate':\n        if (rotateIndex === -1) rotateIndex = i;\n        break;\n      case 'scalex':\n        if (scaleXIndex === -1) scaleXIndex = i;\n        break;\n      case 'scaley':\n        if (scaleYIndex === -1) scaleYIndex = i;\n        break;\n      // Note: We don't extract from scale(x,y) to avoid complexity\n      // User can still have scale(2) and we preserve it\n    }\n  }\n\n  return { functions, rotateIndex, scaleXIndex, scaleYIndex };\n}\n\n/**\n * Get rotate value from parsed state.\n */\nfunction getRotateValue(state: TransformState): string {\n  if (state.rotateIndex < 0) return '';\n  return state.functions[state.rotateIndex]!.args;\n}\n\n/**\n * Get scaleX value from parsed state.\n */\nfunction getScaleXValue(state: TransformState): number {\n  if (state.scaleXIndex < 0) return 1;\n  const val = parseFloat(state.functions[state.scaleXIndex]!.args);\n  return isNaN(val) ? 1 : val;\n}\n\n/**\n * Get scaleY value from parsed state.\n */\nfunction getScaleYValue(state: TransformState): number {\n  if (state.scaleYIndex < 0) return 1;\n  const val = parseFloat(state.functions[state.scaleYIndex]!.args);\n  return isNaN(val) ? 1 : val;\n}\n\n/**\n * Check if element is horizontally flipped (scaleX === -1).\n */\nfunction isFlippedX(state: TransformState): boolean {\n  return getScaleXValue(state) === -1;\n}\n\n/**\n * Check if element is vertically flipped (scaleY === -1).\n */\nfunction isFlippedY(state: TransformState): boolean {\n  return getScaleYValue(state) === -1;\n}\n\n/**\n * Set rotate value in transform state (mutates functions array).\n * Adds rotate at end if not present, updates if present, removes if empty.\n */\nfunction setRotateValue(state: TransformState, value: string): void {\n  const rotateStr = value.trim();\n\n  if (state.rotateIndex >= 0) {\n    if (rotateStr) {\n      // Update existing rotate\n      state.functions[state.rotateIndex] = {\n        original: `rotate(${rotateStr})`,\n        name: 'rotate',\n        args: rotateStr,\n      };\n    } else {\n      // Remove rotate\n      state.functions.splice(state.rotateIndex, 1);\n      // Adjust indices\n      if (state.scaleXIndex > state.rotateIndex) state.scaleXIndex--;\n      if (state.scaleYIndex > state.rotateIndex) state.scaleYIndex--;\n      state.rotateIndex = -1;\n    }\n  } else if (rotateStr) {\n    // Add new rotate at end\n    state.rotateIndex = state.functions.length;\n    state.functions.push({\n      original: `rotate(${rotateStr})`,\n      name: 'rotate',\n      args: rotateStr,\n    });\n  }\n}\n\n/**\n * Toggle flip X in transform state.\n * If currently -1, removes scaleX. Otherwise sets to -1.\n */\nfunction toggleFlipX(state: TransformState): void {\n  const currentVal = getScaleXValue(state);\n\n  if (currentVal === -1) {\n    // Remove scaleX(-1)\n    if (state.scaleXIndex >= 0) {\n      state.functions.splice(state.scaleXIndex, 1);\n      // Adjust indices\n      if (state.rotateIndex > state.scaleXIndex) state.rotateIndex--;\n      if (state.scaleYIndex > state.scaleXIndex) state.scaleYIndex--;\n      state.scaleXIndex = -1;\n    }\n  } else if (state.scaleXIndex >= 0) {\n    // Update existing scaleX to -1\n    state.functions[state.scaleXIndex] = {\n      original: 'scaleX(-1)',\n      name: 'scalex',\n      args: '-1',\n    };\n  } else {\n    // Add new scaleX(-1) at end\n    state.scaleXIndex = state.functions.length;\n    state.functions.push({\n      original: 'scaleX(-1)',\n      name: 'scalex',\n      args: '-1',\n    });\n  }\n}\n\n/**\n * Toggle flip Y in transform state.\n * If currently -1, removes scaleY. Otherwise sets to -1.\n */\nfunction toggleFlipY(state: TransformState): void {\n  const currentVal = getScaleYValue(state);\n\n  if (currentVal === -1) {\n    // Remove scaleY(-1)\n    if (state.scaleYIndex >= 0) {\n      state.functions.splice(state.scaleYIndex, 1);\n      // Adjust indices\n      if (state.rotateIndex > state.scaleYIndex) state.rotateIndex--;\n      if (state.scaleXIndex > state.scaleYIndex) state.scaleXIndex--;\n      state.scaleYIndex = -1;\n    }\n  } else if (state.scaleYIndex >= 0) {\n    // Update existing scaleY to -1\n    state.functions[state.scaleYIndex] = {\n      original: 'scaleY(-1)',\n      name: 'scaley',\n      args: '-1',\n    };\n  } else {\n    // Add new scaleY(-1) at end\n    state.scaleYIndex = state.functions.length;\n    state.functions.push({\n      original: 'scaleY(-1)',\n      name: 'scaley',\n      args: '-1',\n    });\n  }\n}\n\n/**\n * Compose transform string from state, preserving order.\n */\nfunction composeTransform(state: TransformState): string {\n  return state.functions\n    .map((fn) => fn.original)\n    .join(' ')\n    .trim();\n}\n\n/**\n * Extract numeric value from rotate angle (e.g., \"45deg\" -> \"45\")\n */\nfunction extractRotateValue(rotate: string): string {\n  if (!rotate) return '';\n  const match = rotate.match(/^(-?[\\d.]+)/);\n  return match ? match[1]! : '';\n}\n\n/**\n * Extract unit from rotate angle (e.g., \"45deg\" -> \"deg\")\n */\nfunction extractRotateUnit(rotate: string): string {\n  if (!rotate) return 'deg';\n  const match = rotate.match(/[\\d.]+(.*)$/);\n  return match && match[1] ? match[1] : 'deg';\n}\n\n// =============================================================================\n// SVG Icon Helpers\n// =============================================================================\n\nfunction createPositionIcon(position: PositionValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  const addBlock = (x: number, y: number, w: number, h: number, opacity = 1) => {\n    const rect = document.createElementNS(SVG_NS, 'rect');\n    rect.setAttribute('x', String(x));\n    rect.setAttribute('y', String(y));\n    rect.setAttribute('width', String(w));\n    rect.setAttribute('height', String(h));\n    rect.setAttribute('rx', '0.5');\n    rect.setAttribute('fill', 'currentColor');\n    if (opacity < 1) rect.setAttribute('opacity', String(opacity));\n    svg.append(rect);\n  };\n\n  const addLine = (x: number, y: number, w: number) => {\n    const line = document.createElementNS(SVG_NS, 'rect');\n    line.setAttribute('x', String(x));\n    line.setAttribute('y', String(y));\n    line.setAttribute('width', String(w));\n    line.setAttribute('height', '1');\n    line.setAttribute('rx', '0.5');\n    line.setAttribute('fill', 'currentColor');\n    svg.append(line);\n  };\n\n  const addPath = (d: string, strokeWidth = '1') => {\n    const path = document.createElementNS(SVG_NS, 'path');\n    path.setAttribute('d', d);\n    path.setAttribute('stroke', 'currentColor');\n    path.setAttribute('stroke-width', strokeWidth);\n    path.setAttribute('stroke-linecap', 'round');\n    path.setAttribute('fill', 'none');\n    svg.append(path);\n  };\n\n  switch (position) {\n    case 'static':\n      // 三条水平线表示正常文档流\n      addLine(3.5, 4.5, 8);\n      addLine(3.5, 7.5, 8);\n      addLine(3.5, 10.5, 8);\n      break;\n\n    case 'relative': {\n      // 虚线框表示原位置，实心块表示偏移后的位置\n      const ghost = document.createElementNS(SVG_NS, 'rect');\n      ghost.setAttribute('x', '3.5');\n      ghost.setAttribute('y', '3.5');\n      ghost.setAttribute('width', '4');\n      ghost.setAttribute('height', '4');\n      ghost.setAttribute('rx', '0.5');\n      ghost.setAttribute('stroke', 'currentColor');\n      ghost.setAttribute('stroke-width', '1');\n      ghost.setAttribute('stroke-dasharray', '1.5 1');\n      ghost.setAttribute('fill', 'none');\n      ghost.setAttribute('opacity', '0.5');\n      svg.append(ghost);\n      // 偏移后的实心块\n      addBlock(7.5, 7.5, 4, 4);\n      // 连接箭头\n      addPath('M5.5 7.5L7.5 9.5');\n      break;\n    }\n\n    case 'absolute':\n      // 定位参考线（从容器边缘到元素）\n      addPath('M3 5.5H6M8 3V6', '0.8');\n      // 元素块在右下角\n      addBlock(6, 6, 5, 5);\n      break;\n\n    case 'fixed': {\n      // 图钉形状表示固定\n      const pin = document.createElementNS(SVG_NS, 'circle');\n      pin.setAttribute('cx', '7.5');\n      pin.setAttribute('cy', '4');\n      pin.setAttribute('r', '1.5');\n      pin.setAttribute('fill', 'currentColor');\n      svg.append(pin);\n      // 图钉针\n      addPath('M7.5 5.5V8');\n      // 固定的元素\n      addBlock(4.5, 8, 6, 4);\n      break;\n    }\n\n    case 'sticky': {\n      // 顶部吸附线\n      const stickyLine = document.createElementNS(SVG_NS, 'rect');\n      stickyLine.setAttribute('x', '3');\n      stickyLine.setAttribute('y', '3');\n      stickyLine.setAttribute('width', '9');\n      stickyLine.setAttribute('height', '1.5');\n      stickyLine.setAttribute('rx', '0.5');\n      stickyLine.setAttribute('fill', 'currentColor');\n      stickyLine.setAttribute('opacity', '0.4');\n      svg.append(stickyLine);\n      // 吸附的元素\n      addBlock(4.5, 5, 6, 6);\n      break;\n    }\n  }\n\n  createIconContainer(svg);\n  return svg;\n}\n\nfunction createRotateIcon(): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('stroke', 'currentColor');\n  path.setAttribute('stroke-width', '1.2');\n  path.setAttribute('stroke-linecap', 'round');\n  path.setAttribute('stroke-linejoin', 'round');\n  // Circular arrow icon\n  path.setAttribute('d', 'M12 7.5a4.5 4.5 0 1 1-1.5-3.4M12 3v3h-3');\n  svg.append(path);\n\n  return svg;\n}\n\nfunction createFlipXIcon(): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 左侧镜像块（半透明表示原始）\n  const leftBlock = document.createElementNS(SVG_NS, 'rect');\n  leftBlock.setAttribute('x', '3.5');\n  leftBlock.setAttribute('y', '5');\n  leftBlock.setAttribute('width', '3');\n  leftBlock.setAttribute('height', '5');\n  leftBlock.setAttribute('rx', '0.5');\n  leftBlock.setAttribute('fill', 'currentColor');\n  leftBlock.setAttribute('opacity', '0.4');\n\n  // 右侧镜像块（实心表示翻转后）\n  const rightBlock = document.createElementNS(SVG_NS, 'rect');\n  rightBlock.setAttribute('x', '8.5');\n  rightBlock.setAttribute('y', '5');\n  rightBlock.setAttribute('width', '3');\n  rightBlock.setAttribute('height', '5');\n  rightBlock.setAttribute('rx', '0.5');\n  rightBlock.setAttribute('fill', 'currentColor');\n\n  // 中间镜像轴线\n  const axis = document.createElementNS(SVG_NS, 'path');\n  axis.setAttribute('d', 'M7.5 3V12');\n  axis.setAttribute('stroke', 'currentColor');\n  axis.setAttribute('stroke-width', '1');\n  axis.setAttribute('stroke-dasharray', '1.5 1');\n  axis.setAttribute('opacity', '0.6');\n\n  svg.append(leftBlock, rightBlock, axis);\n  createIconContainer(svg);\n  return svg;\n}\n\nfunction createFlipYIcon(): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 上方镜像块（半透明表示原始）\n  const topBlock = document.createElementNS(SVG_NS, 'rect');\n  topBlock.setAttribute('x', '5');\n  topBlock.setAttribute('y', '3.5');\n  topBlock.setAttribute('width', '5');\n  topBlock.setAttribute('height', '3');\n  topBlock.setAttribute('rx', '0.5');\n  topBlock.setAttribute('fill', 'currentColor');\n  topBlock.setAttribute('opacity', '0.4');\n\n  // 下方镜像块（实心表示翻转后）\n  const bottomBlock = document.createElementNS(SVG_NS, 'rect');\n  bottomBlock.setAttribute('x', '5');\n  bottomBlock.setAttribute('y', '8.5');\n  bottomBlock.setAttribute('width', '5');\n  bottomBlock.setAttribute('height', '3');\n  bottomBlock.setAttribute('rx', '0.5');\n  bottomBlock.setAttribute('fill', 'currentColor');\n\n  // 中间镜像轴线\n  const axis = document.createElementNS(SVG_NS, 'path');\n  axis.setAttribute('d', 'M3 7.5H12');\n  axis.setAttribute('stroke', 'currentColor');\n  axis.setAttribute('stroke-width', '1');\n  axis.setAttribute('stroke-dasharray', '1.5 1');\n  axis.setAttribute('opacity', '0.6');\n\n  svg.append(topBlock, bottomBlock, axis);\n  createIconContainer(svg);\n  return svg;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) {\n      return rootNode.activeElement === el;\n    }\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    if (!style || typeof style.getPropertyValue !== 'function') return '';\n    return style.getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction normalizeZIndex(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return '';\n  if (/^-?\\d+\\.$/.test(trimmed)) return trimmed.slice(0, -1);\n  return trimmed;\n}\n\nfunction isPositionValue(value: string): value is PositionValue {\n  return (POSITION_VALUES as readonly string[]).includes(value);\n}\n\n// =============================================================================\n// Field State Types\n// =============================================================================\n\ninterface IconButtonGroupFieldState {\n  kind: 'icon-button-group';\n  property: 'position';\n  group: IconButtonGroup<PositionValue>;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface InputFieldState {\n  kind: 'input';\n  property: 'left' | 'top' | 'z-index';\n  input: HTMLInputElement;\n  container: InputContainer;\n  handle: StyleTransactionHandle | null;\n}\n\ninterface TransformFieldState {\n  kind: 'transform';\n  rotateInput: HTMLInputElement;\n  rotateContainer: InputContainer;\n  flipXBtn: HTMLButtonElement;\n  flipYBtn: HTMLButtonElement;\n  handle: StyleTransactionHandle | null;\n  /** Cached transform state for editing */\n  cached: TransformState;\n}\n\ntype FieldState = IconButtonGroupFieldState | InputFieldState | TransformFieldState;\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface PositionControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n}\n\nexport function createPositionControl(options: PositionControlOptions): DesignControl {\n  const { container, transactionManager } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ---------------------------------------------------------------------------\n  // Position Row (icon button group)\n  // ---------------------------------------------------------------------------\n  const positionRow = document.createElement('div');\n  positionRow.className = 'we-field';\n\n  const positionLabel = document.createElement('span');\n  positionLabel.className = 'we-field-label';\n  positionLabel.textContent = 'Position';\n\n  const positionMount = document.createElement('div');\n  positionMount.className = 'we-field-content';\n\n  positionRow.append(positionLabel, positionMount);\n\n  const positionGroup = createIconButtonGroup<PositionValue>({\n    container: positionMount,\n    ariaLabel: 'Position type',\n    columns: 5,\n    items: POSITION_VALUES.map((pos) => ({\n      value: pos,\n      ariaLabel: pos,\n      title: pos,\n      icon: createPositionIcon(pos),\n    })),\n    onChange: (value) => {\n      const handle = beginStyleTransaction('position');\n      if (handle) handle.set(value);\n      commitStyleTransaction('position');\n      syncAllFields();\n    },\n  });\n  disposer.add(() => positionGroup.dispose());\n\n  // ---------------------------------------------------------------------------\n  // X / Y row (left / top)\n  // ---------------------------------------------------------------------------\n  const xyRow = document.createElement('div');\n  xyRow.className = 'we-field-row';\n\n  const xContainer = createInputContainer({\n    ariaLabel: 'X (Left)',\n    inputMode: 'decimal',\n    prefix: 'X',\n    suffix: 'px',\n  });\n\n  const yContainer = createInputContainer({\n    ariaLabel: 'Y (Top)',\n    inputMode: 'decimal',\n    prefix: 'Y',\n    suffix: 'px',\n  });\n\n  xyRow.append(xContainer.root, yContainer.root);\n\n  wireNumberStepping(disposer, xContainer.input, { mode: 'css-length' });\n  wireNumberStepping(disposer, yContainer.input, { mode: 'css-length' });\n\n  // ---------------------------------------------------------------------------\n  // Z row (z-index)\n  // ---------------------------------------------------------------------------\n  const zRow = document.createElement('div');\n  zRow.className = 'we-field';\n\n  const zLabel = document.createElement('span');\n  zLabel.className = 'we-field-label';\n  zLabel.textContent = 'Z-Index';\n\n  const zContainer = createInputContainer({\n    ariaLabel: 'Z-Index',\n    inputMode: 'numeric',\n    prefix: 'Z',\n    suffix: null,\n  });\n\n  zRow.append(zLabel, zContainer.root);\n\n  wireNumberStepping(disposer, zContainer.input, { mode: 'number', integer: true });\n\n  // ---------------------------------------------------------------------------\n  // Rotate + Flip row (transform)\n  // ---------------------------------------------------------------------------\n  const transformRow = document.createElement('div');\n  transformRow.className = 'we-field';\n\n  const transformLabel = document.createElement('span');\n  transformLabel.className = 'we-field-label';\n  transformLabel.textContent = 'Rotate';\n\n  const transformContent = document.createElement('div');\n  transformContent.className = 'we-field-content';\n  transformContent.style.display = 'flex';\n  transformContent.style.gap = '4px';\n  transformContent.style.alignItems = 'center';\n\n  const rotateContainer = createInputContainer({\n    ariaLabel: 'Rotate',\n    inputMode: 'decimal',\n    prefix: createRotateIcon(),\n    suffix: 'deg',\n  });\n  rotateContainer.root.style.flex = '1';\n\n  wireNumberStepping(disposer, rotateContainer.input, { mode: 'number', step: 1, shiftStep: 15 });\n\n  // Flip X button\n  const flipXBtn = document.createElement('button');\n  flipXBtn.type = 'button';\n  flipXBtn.className = 'we-toggle-btn';\n  flipXBtn.setAttribute('aria-label', 'Flip horizontal');\n  flipXBtn.setAttribute('aria-pressed', 'false');\n  flipXBtn.dataset.tooltip = 'Flip horizontal';\n  flipXBtn.append(createFlipXIcon());\n\n  // Flip Y button\n  const flipYBtn = document.createElement('button');\n  flipYBtn.type = 'button';\n  flipYBtn.className = 'we-toggle-btn';\n  flipYBtn.setAttribute('aria-label', 'Flip vertical');\n  flipYBtn.setAttribute('aria-pressed', 'false');\n  flipYBtn.dataset.tooltip = 'Flip vertical';\n  flipYBtn.append(createFlipYIcon());\n\n  transformContent.append(rotateContainer.root, flipXBtn, flipYBtn);\n  transformRow.append(transformLabel, transformContent);\n\n  // ---------------------------------------------------------------------------\n  // Assemble DOM\n  // ---------------------------------------------------------------------------\n  root.append(positionRow, xyRow, zRow, transformRow);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Field State Registry\n  // ==========================================================================\n\n  const fields: Record<FieldKey, FieldState> = {\n    position: {\n      kind: 'icon-button-group',\n      property: 'position',\n      group: positionGroup,\n      handle: null,\n    },\n    left: {\n      kind: 'input',\n      property: 'left',\n      input: xContainer.input,\n      container: xContainer,\n      handle: null,\n    },\n    top: {\n      kind: 'input',\n      property: 'top',\n      input: yContainer.input,\n      container: yContainer,\n      handle: null,\n    },\n    'z-index': {\n      kind: 'input',\n      property: 'z-index',\n      input: zContainer.input,\n      container: zContainer,\n      handle: null,\n    },\n    transform: {\n      kind: 'transform',\n      rotateInput: rotateContainer.input,\n      rotateContainer: rotateContainer,\n      flipXBtn,\n      flipYBtn,\n      handle: null,\n      cached: { functions: [], rotateIndex: -1, scaleXIndex: -1, scaleYIndex: -1 },\n    },\n  };\n\n  const STYLE_PROPERTIES: readonly StyleProperty[] = ['position', 'left', 'top', 'z-index'];\n  const FIELD_KEYS: readonly FieldKey[] = ['position', 'left', 'top', 'z-index', 'transform'];\n\n  // ==========================================================================\n  // Transaction Management\n  // ==========================================================================\n\n  function beginStyleTransaction(property: StyleProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.kind === 'transform') return null;\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitStyleTransaction(property: StyleProperty): void {\n    const field = fields[property];\n    if (field.kind === 'transform') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackStyleTransaction(property: StyleProperty): void {\n    const field = fields[property];\n    if (field.kind === 'transform') return;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function beginTransformTransaction(): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields.transform as TransformFieldState;\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, 'transform');\n    field.handle = handle;\n\n    // Cache current transform components\n    const currentTransform =\n      readInlineValue(target, 'transform') || readComputedValue(target, 'transform');\n    field.cached = parseTransform(currentTransform);\n\n    return handle;\n  }\n\n  function commitTransformTransaction(): void {\n    const field = fields.transform as TransformFieldState;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransformTransaction(): void {\n    const field = fields.transform as TransformFieldState;\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of STYLE_PROPERTIES) commitStyleTransaction(p);\n    commitTransformTransaction();\n  }\n\n  // ==========================================================================\n  // Field Synchronization\n  // ==========================================================================\n\n  function syncField(key: FieldKey, force = false): void {\n    const field = fields[key];\n    const target = currentTarget;\n\n    // Handle icon button group (position)\n    if (field.kind === 'icon-button-group') {\n      const group = field.group;\n\n      if (!target || !target.isConnected) {\n        group.setDisabled(true);\n        group.setValue(null);\n        return;\n      }\n\n      group.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const inline = readInlineValue(target, 'position');\n      const computed = readComputedValue(target, 'position');\n      const raw = (inline || computed).trim();\n      group.setValue(isPositionValue(raw) ? raw : 'static');\n      return;\n    }\n\n    // Handle input field (left, top, z-index)\n    if (field.kind === 'input') {\n      const input = field.input;\n\n      if (!target || !target.isConnected) {\n        input.disabled = true;\n        input.value = '';\n        input.placeholder = '';\n        // Reset suffix to default\n        if (field.property === 'z-index') {\n          field.container.setSuffix(null);\n        } else {\n          field.container.setSuffix('px');\n        }\n        return;\n      }\n\n      input.disabled = false;\n      const isEditing = field.handle !== null || isFieldFocused(input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, field.property);\n      const displayValue = inlineValue || readComputedValue(target, field.property);\n\n      // z-index is unitless\n      if (field.property === 'z-index') {\n        input.value = displayValue;\n        field.container.setSuffix(null);\n      } else {\n        const formatted = formatLengthForDisplay(displayValue);\n        input.value = formatted.value;\n        field.container.setSuffix(formatted.suffix);\n      }\n      input.placeholder = '';\n      return;\n    }\n\n    // Handle transform field (rotate + flip)\n    if (field.kind === 'transform') {\n      const { rotateInput, rotateContainer, flipXBtn, flipYBtn } = field;\n\n      if (!target || !target.isConnected) {\n        rotateInput.disabled = true;\n        rotateInput.value = '';\n        rotateInput.placeholder = '';\n        rotateContainer.setSuffix('deg');\n        flipXBtn.disabled = true;\n        flipYBtn.disabled = true;\n        flipXBtn.setAttribute('aria-pressed', 'false');\n        flipYBtn.setAttribute('aria-pressed', 'false');\n        return;\n      }\n\n      rotateInput.disabled = false;\n      flipXBtn.disabled = false;\n      flipYBtn.disabled = false;\n\n      const isEditing = field.handle !== null || isFieldFocused(rotateInput);\n      if (isEditing && !force) return;\n\n      const transformValue =\n        readInlineValue(target, 'transform') || readComputedValue(target, 'transform');\n      const state = parseTransform(transformValue);\n\n      // Update rotate input\n      const rotateArgs = getRotateValue(state);\n      const rotateValue = extractRotateValue(rotateArgs);\n      const rotateUnit = extractRotateUnit(rotateArgs);\n      rotateInput.value = rotateValue;\n      rotateContainer.setSuffix(rotateUnit || 'deg');\n\n      // Update flip buttons\n      flipXBtn.setAttribute('aria-pressed', isFlippedX(state) ? 'true' : 'false');\n      flipYBtn.setAttribute('aria-pressed', isFlippedY(state) ? 'true' : 'false');\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const key of FIELD_KEYS) syncField(key);\n  }\n\n  // ==========================================================================\n  // Event Wiring - Style Inputs\n  // ==========================================================================\n\n  function wireStyleInput(property: 'left' | 'top' | 'z-index'): void {\n    const field = fields[property] as InputFieldState;\n    const input = field.input;\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginStyleTransaction(property);\n      if (!handle) return;\n\n      if (property === 'z-index') {\n        handle.set(normalizeZIndex(input.value));\n      } else {\n        const suffix = field.container.getSuffixText();\n        handle.set(combineLengthValue(input.value, suffix));\n      }\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitStyleTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitStyleTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackStyleTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  wireStyleInput('left');\n  wireStyleInput('top');\n  wireStyleInput('z-index');\n\n  // ==========================================================================\n  // Event Wiring - Transform\n  // ==========================================================================\n\n  const transformField = fields.transform as TransformFieldState;\n\n  // Rotate input\n  disposer.listen(transformField.rotateInput, 'input', () => {\n    const handle = beginTransformTransaction();\n    if (!handle) return;\n\n    const value = transformField.rotateInput.value.trim();\n    const unit = transformField.rotateContainer.getSuffixText() || 'deg';\n    const rotateStr = value ? `${value}${unit}` : '';\n\n    setRotateValue(transformField.cached, rotateStr);\n    handle.set(composeTransform(transformField.cached));\n  });\n\n  disposer.listen(transformField.rotateInput, 'blur', () => {\n    commitTransformTransaction();\n    syncAllFields();\n  });\n\n  disposer.listen(transformField.rotateInput, 'keydown', (e: KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      commitTransformTransaction();\n      syncAllFields();\n      transformField.rotateInput.blur();\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      rollbackTransformTransaction();\n      syncField('transform', true);\n    }\n  });\n\n  // Flip X button\n  disposer.listen(transformField.flipXBtn, 'click', (e: MouseEvent) => {\n    e.preventDefault();\n    const handle = beginTransformTransaction();\n    if (!handle) return;\n\n    toggleFlipX(transformField.cached);\n    handle.set(composeTransform(transformField.cached));\n    commitTransformTransaction();\n    syncAllFields();\n  });\n\n  // Flip Y button\n  disposer.listen(transformField.flipYBtn, 'click', (e: MouseEvent) => {\n    e.preventDefault();\n    const handle = beginTransformTransaction();\n    if (!handle) return;\n\n    toggleFlipY(transformField.cached);\n    handle.set(composeTransform(transformField.cached));\n    commitTransformTransaction();\n    syncAllFields();\n  });\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) {\n      commitAllTransactions();\n    }\n    currentTarget = element;\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/size-control.ts",
    "content": "/**\n * Size Control (Phase 3.5 + Mode Selection)\n *\n * Design control for editing inline width and height styles with mode selection.\n *\n * Features:\n * - Mode selection: Fixed (custom value), Fit (fit-content/auto), Fill (100%)\n * - Live preview via TransactionManager.beginStyle().set()\n * - Shows real values (inline if set, otherwise computed)\n * - ArrowUp/ArrowDown keyboard stepping for numeric values\n * - Blur commits, Enter commits + blurs, ESC rollbacks\n * - Pure numbers default to px\n * - Empty value clears inline style\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignControl } from '../types';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\n\n// =============================================================================\n// Types\n// =============================================================================\n\ntype SizeProperty = 'width' | 'height';\n\n/** Size mode determines how dimension value is calculated */\ntype SizeMode = 'fixed' | 'fit' | 'fill';\n\ninterface SizeModeOption {\n  value: SizeMode;\n  label: string;\n}\n\ninterface FieldState {\n  property: SizeProperty;\n  column: HTMLElement;\n  modeSelect: HTMLSelectElement;\n  input: HTMLInputElement;\n  container: InputContainer;\n  /** Cached fixed value for mode switching (per-target, cleared on target change) */\n  lastFixedValue: string;\n  handle: StyleTransactionHandle | null;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SIZE_MODE_OPTIONS: readonly SizeModeOption[] = [\n  { value: 'fixed', label: 'Fixed' },\n  { value: 'fit', label: 'Fit' },\n  { value: 'fill', label: 'Fill' },\n] as const;\n\n/** Keywords that indicate fit mode */\nconst FIT_KEYWORDS = ['auto', 'fit-content', 'max-content', 'min-content'] as const;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Check if element is focused in Shadow DOM context\n */\nfunction isElementFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Read inline style property value from element\n */\nfunction readInlineValue(element: Element, property: SizeProperty): string {\n  try {\n    const style = (element as HTMLElement).style;\n    if (!style || typeof style.getPropertyValue !== 'function') return '';\n    return style.getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Read computed style property value from element\n */\nfunction readComputedValue(element: Element, property: SizeProperty): string {\n  try {\n    const computed = window.getComputedStyle(element);\n    return computed.getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Get bounding rect dimension as px string\n * Preserves 2 decimal places for sub-pixel accuracy\n */\nfunction getBoundingRectPx(element: Element, property: SizeProperty): string {\n  try {\n    const rect = element.getBoundingClientRect();\n    const value = property === 'width' ? rect.width : rect.height;\n    if (!Number.isFinite(value)) return '0px';\n    // Round to 2 decimal places for sub-pixel layouts\n    const rounded = Math.round(value * 100) / 100;\n    return `${rounded}px`;\n  } catch {\n    return '0px';\n  }\n}\n\n/**\n * Infer size mode from CSS value\n *\n * Priority:\n * - '100%' -> fill\n * - fit keywords (auto, fit-content, etc.) -> fit\n * - Everything else (px, %, calc, var, etc.) -> fixed\n */\nfunction inferSizeMode(value: string): SizeMode {\n  const trimmed = value.trim().toLowerCase();\n  if (!trimmed) return 'fixed';\n\n  // Fill: exactly 100%\n  if (trimmed === '100%') return 'fill';\n\n  // Fit: various content-sizing keywords\n  for (const keyword of FIT_KEYWORDS) {\n    if (trimmed === keyword || trimmed.startsWith(`${keyword}(`)) {\n      return 'fit';\n    }\n  }\n\n  // Everything else is fixed (including other percentages, calc, var, etc.)\n  return 'fixed';\n}\n\n/**\n * Get the CSS value for fit mode\n * If the current value is already a fit keyword, preserve it.\n * Otherwise uses fit-content if supported, or falls back to auto.\n */\nfunction getFitValue(property: SizeProperty, currentValue: string): string {\n  const trimmed = currentValue.trim().toLowerCase();\n\n  // If already a fit keyword, preserve it\n  for (const keyword of FIT_KEYWORDS) {\n    if (trimmed === keyword || trimmed.startsWith(`${keyword}(`)) {\n      return currentValue.trim(); // Preserve original casing\n    }\n  }\n\n  // Default fit value\n  try {\n    if (typeof CSS !== 'undefined' && CSS.supports?.(property, 'fit-content')) {\n      return 'fit-content';\n    }\n  } catch {\n    // Ignore\n  }\n  return 'auto';\n}\n\n/**\n * Create mode select element\n */\nfunction createModeSelect(ariaLabel: string): HTMLSelectElement {\n  const select = document.createElement('select');\n  select.className = 'we-select we-size-mode-select';\n  select.setAttribute('aria-label', ariaLabel);\n\n  for (const { value, label } of SIZE_MODE_OPTIONS) {\n    const option = document.createElement('option');\n    option.value = value;\n    option.textContent = label;\n    select.appendChild(option);\n  }\n\n  return select;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface SizeControlOptions {\n  /** Container element to mount the control */\n  container: HTMLElement;\n  /** TransactionManager for style editing with undo/redo */\n  transactionManager: TransactionManager;\n}\n\n/**\n * Create a Size control for editing width/height with mode selection\n */\nexport function createSizeControl(options: SizeControlOptions): DesignControl {\n  const { container, transactionManager } = options;\n  const disposer = new Disposer();\n\n  // State\n  let currentTarget: Element | null = null;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  /**\n   * Create a size field column with mode select and input\n   */\n  function createSizeField(property: SizeProperty, prefix: string): FieldState {\n    const column = document.createElement('div');\n    column.className = 'we-size-field';\n\n    // Mode select\n    const modeSelect = createModeSelect(`${property} mode`);\n\n    // Input container\n    const inputContainer = createInputContainer({\n      ariaLabel: property.charAt(0).toUpperCase() + property.slice(1),\n      inputMode: 'decimal',\n      prefix,\n      suffix: 'px',\n    });\n\n    // Wire keyboard stepping\n    wireNumberStepping(disposer, inputContainer.input, { mode: 'css-length' });\n\n    column.append(modeSelect, inputContainer.root);\n\n    return {\n      property,\n      column,\n      modeSelect,\n      input: inputContainer.input,\n      container: inputContainer,\n      lastFixedValue: '',\n      handle: null,\n    };\n  }\n\n  // Create width and height fields\n  const widthField = createSizeField('width', 'W');\n  const heightField = createSizeField('height', 'H');\n\n  // Row layout\n  const row = document.createElement('div');\n  row.className = 'we-field-row';\n  row.append(widthField.column, heightField.column);\n\n  root.append(row);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // Field map for iteration\n  const fields: Record<SizeProperty, FieldState> = {\n    width: widthField,\n    height: heightField,\n  };\n\n  // ==========================================================================\n  // Transaction Management\n  // ==========================================================================\n\n  function beginTransaction(property: SizeProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: SizeProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: SizeProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    commitTransaction('width');\n    commitTransaction('height');\n  }\n\n  // ==========================================================================\n  // Visibility Control\n  // ==========================================================================\n\n  /**\n   * Update input visibility based on mode\n   * Input is only visible in fixed mode\n   */\n  function updateInputVisibility(field: FieldState, mode: SizeMode): void {\n    field.container.root.hidden = mode !== 'fixed';\n  }\n\n  // ==========================================================================\n  // Sync / Render\n  // ==========================================================================\n\n  /**\n   * Get fixed value for mode switching\n   * Prioritizes: lastFixedValue > computed > bounding rect\n   */\n  function getFixedValueCandidate(field: FieldState, target: Element): string {\n    // Try cached fixed value\n    const cached = field.lastFixedValue.trim();\n    if (cached && inferSizeMode(cached) === 'fixed') {\n      return cached;\n    }\n\n    // Try computed value\n    const computed = readComputedValue(target, field.property);\n    if (computed && inferSizeMode(computed) === 'fixed') {\n      return computed;\n    }\n\n    // Fallback to bounding rect\n    return getBoundingRectPx(target, field.property);\n  }\n\n  /**\n   * Sync a single field's display with element styles\n   */\n  function syncField(property: SizeProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n\n    // Disabled state when no target\n    if (!target || !target.isConnected) {\n      field.modeSelect.value = 'fixed';\n      field.modeSelect.disabled = true;\n      updateInputVisibility(field, 'fixed');\n      field.input.value = '';\n      field.input.placeholder = '';\n      field.input.disabled = true;\n      field.container.setSuffix('px');\n      return;\n    }\n\n    field.modeSelect.disabled = false;\n    field.input.disabled = false;\n\n    // Don't overwrite during active editing (unless forced)\n    if (!force) {\n      const isEditing =\n        field.handle !== null ||\n        isElementFocused(field.input) ||\n        isElementFocused(field.modeSelect);\n      if (isEditing) return;\n    }\n\n    // Get current value and infer mode\n    const inlineValue = readInlineValue(target, property);\n    const displayValue = inlineValue || readComputedValue(target, property);\n    const mode = inferSizeMode(inlineValue || displayValue);\n\n    // Update mode select and visibility\n    field.modeSelect.value = mode;\n    updateInputVisibility(field, mode);\n\n    // Track fixed value for mode switching\n    if (mode === 'fixed') {\n      const candidate = inlineValue || displayValue;\n      if (candidate && inferSizeMode(candidate) === 'fixed') {\n        field.lastFixedValue = candidate;\n      }\n    }\n\n    // Update input value (only relevant for fixed mode)\n    if (mode === 'fixed') {\n      const formatted = formatLengthForDisplay(displayValue);\n      field.input.value = formatted.value;\n      field.input.placeholder = '';\n      field.container.setSuffix(formatted.suffix);\n    }\n  }\n\n  function syncAllFields(): void {\n    syncField('width');\n    syncField('height');\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  /**\n   * Wire mode select event handlers\n   */\n  function wireModeSelect(property: SizeProperty): void {\n    const field = fields[property];\n    const select = field.modeSelect;\n\n    const handleModeChange = () => {\n      const target = currentTarget;\n      if (!target || !target.isConnected) return;\n\n      const mode = select.value as SizeMode;\n      const previousMode = inferSizeMode(\n        readInlineValue(target, property) || readComputedValue(target, property),\n      );\n\n      // Save current fixed value before switching away\n      if (previousMode === 'fixed' && mode !== 'fixed') {\n        const suffix = field.container.getSuffixText();\n        const combined = combineLengthValue(field.input.value, suffix);\n        if (combined) field.lastFixedValue = combined;\n      }\n\n      updateInputVisibility(field, mode);\n\n      const handle = beginTransaction(property);\n      if (!handle) return;\n\n      switch (mode) {\n        case 'fit': {\n          const currentValue =\n            readInlineValue(target, property) || readComputedValue(target, property);\n          handle.set(getFitValue(property, currentValue));\n          break;\n        }\n        case 'fill':\n          handle.set('100%');\n          break;\n        case 'fixed': {\n          // Restore fixed value\n          const fixedValue = getFixedValueCandidate(field, target);\n          field.lastFixedValue = fixedValue;\n          handle.set(fixedValue);\n\n          // Update input to show restored value\n          const formatted = formatLengthForDisplay(fixedValue);\n          field.input.value = formatted.value;\n          field.container.setSuffix(formatted.suffix);\n          break;\n        }\n      }\n    };\n\n    disposer.listen(select, 'input', handleModeChange);\n    disposer.listen(select, 'change', handleModeChange);\n\n    disposer.listen(select, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  /**\n   * Wire input field event handlers\n   */\n  function wireInput(property: SizeProperty): void {\n    const field = fields[property];\n    const input = field.input;\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (!handle) return;\n\n      const suffix = field.container.getSuffixText();\n      const combined = combineLengthValue(input.value, suffix);\n      handle.set(combined);\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  // Wire all event handlers\n  wireModeSelect('width');\n  wireModeSelect('height');\n  wireInput('width');\n  wireInput('height');\n\n  // ==========================================================================\n  // Public API (DesignControl interface)\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) {\n      commitAllTransactions();\n      // Clear cached fixed values when target changes to avoid cross-element pollution\n      fields.width.lastFixedValue = '';\n      fields.height.lastFixedValue = '';\n    }\n    currentTarget = element;\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  // Initial state\n  syncAllFields();\n\n  return {\n    setTarget,\n    refresh,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/spacing-control.ts",
    "content": "/**\n * Spacing Control (Phase 3.6)\n *\n * Grid-based editor for inline padding and margin.\n *\n * Design ref: attr-ui.html:292-370\n *\n * Features:\n * - Separate Padding and Margin sections with 2x2 grid layout\n * - Direction-indicating SVG icons as input prefixes\n * - Dynamic unit suffix display\n * - Shows real values (inline if set, otherwise computed)\n * - ArrowUp/ArrowDown keyboard stepping for numeric values\n * - Live preview via TransactionManager.beginStyle().set()\n * - Blur commits, Enter commits + blurs, ESC rollbacks\n * - Pure numbers default to px\n * - Empty value clears inline style\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignControl } from '../types';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { combineLengthValue, formatLengthForDisplay } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\n/** All spacing properties in section order */\nconst SPACING_PROPERTIES = [\n  'padding-top',\n  'padding-right',\n  'padding-bottom',\n  'padding-left',\n  'margin-top',\n  'margin-right',\n  'margin-bottom',\n  'margin-left',\n] as const;\n\ntype SpacingProperty = (typeof SPACING_PROPERTIES)[number];\n\n/** SVG path data for edge direction icons (design ref: attr-ui.html:308-368) */\nconst EDGE_ICON_PATHS: Record<SpacingProperty, string> = {\n  // Padding icons: horizontal line with vertical segment pointing inward\n  'padding-top': 'M2 4h11M7.5 4v3.5',\n  'padding-right': 'M4 2v11M4 7.5h3.5',\n  'padding-bottom': 'M2 11h11M7.5 11v-3.5',\n  'padding-left': 'M11 2v11M11 7.5h-3.5',\n  // Margin icons: line with segment pointing outward\n  'margin-top': 'M2 4h11M7.5 4v-3',\n  'margin-right': 'M11 2v11M11 7.5h3',\n  'margin-bottom': 'M2 11h11M7.5 11v3',\n  'margin-left': 'M4 2v11M4 7.5h-3',\n};\n\n// =============================================================================\n// Types\n// =============================================================================\n\ninterface FieldState {\n  property: SpacingProperty;\n  input: HTMLInputElement;\n  container: InputContainer;\n  handle: StyleTransactionHandle | null;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction formatAriaLabel(property: SpacingProperty): string {\n  const [box, edge] = property.split('-') as [string, string];\n  return `${box.charAt(0).toUpperCase()}${box.slice(1)} ${edge}`;\n}\n\nfunction isInputFocused(input: HTMLInputElement): boolean {\n  try {\n    const rootNode = input.getRootNode();\n    if (rootNode instanceof ShadowRoot) {\n      return rootNode.activeElement === input;\n    }\n    return document.activeElement === input;\n  } catch {\n    return false;\n  }\n}\n\nfunction readInlineValue(element: Element, property: SpacingProperty): string {\n  try {\n    const style = (element as HTMLElement).style;\n    if (!style || typeof style.getPropertyValue !== 'function') return '';\n    return style.getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: SpacingProperty): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\nfunction createEdgeIcon(pathD: string): SVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n\n  const path = document.createElementNS(SVG_NS, 'path');\n  path.setAttribute('d', pathD);\n  path.setAttribute('stroke', 'currentColor');\n  svg.append(path);\n\n  return svg;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface SpacingControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n}\n\nexport function createSpacingControl(options: SpacingControlOptions): DesignControl {\n  const { container, transactionManager } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n\n  // ---------------------------------------------------------------------------\n  // Field Factory\n  // ---------------------------------------------------------------------------\n\n  function createField(property: SpacingProperty): FieldState {\n    const inputContainer = createInputContainer({\n      ariaLabel: formatAriaLabel(property),\n      inputMode: 'decimal',\n      prefix: createEdgeIcon(EDGE_ICON_PATHS[property]),\n      suffix: 'px',\n    });\n\n    wireNumberStepping(disposer, inputContainer.input, { mode: 'css-length' });\n\n    return {\n      property,\n      input: inputContainer.input,\n      container: inputContainer,\n      handle: null,\n    };\n  }\n\n  // ---------------------------------------------------------------------------\n  // Create Fields\n  // ---------------------------------------------------------------------------\n\n  const fields = Object.create(null) as Record<SpacingProperty, FieldState>;\n  for (const property of SPACING_PROPERTIES) {\n    fields[property] = createField(property);\n  }\n\n  // ---------------------------------------------------------------------------\n  // Section Factory\n  // ---------------------------------------------------------------------------\n\n  function createSection(\n    title: string,\n    properties: readonly [SpacingProperty, SpacingProperty, SpacingProperty, SpacingProperty],\n  ): HTMLDivElement {\n    const section = document.createElement('div');\n    section.className = 'we-spacing-section';\n\n    // Header with title\n    const header = document.createElement('div');\n    header.className = 'we-spacing-header';\n    header.textContent = title;\n    section.append(header);\n\n    // 2x2 Grid\n    const grid = document.createElement('div');\n    grid.className = 'we-spacing-grid';\n\n    // Row 1: top, right\n    grid.append(fields[properties[0]].container.root);\n    grid.append(fields[properties[1]].container.root);\n    // Row 2: bottom, left\n    grid.append(fields[properties[2]].container.root);\n    grid.append(fields[properties[3]].container.root);\n\n    section.append(grid);\n    return section;\n  }\n\n  // ---------------------------------------------------------------------------\n  // DOM Structure\n  // ---------------------------------------------------------------------------\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  root.append(\n    createSection('Padding', ['padding-top', 'padding-right', 'padding-bottom', 'padding-left']),\n    createSection('Margin', ['margin-top', 'margin-right', 'margin-bottom', 'margin-left']),\n  );\n\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ---------------------------------------------------------------------------\n  // Transaction Management\n  // ---------------------------------------------------------------------------\n\n  function beginTransaction(property: SpacingProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n\n    const field = fields[property];\n    if (field.handle) return field.handle;\n\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: SpacingProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: SpacingProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const prop of SPACING_PROPERTIES) {\n      commitTransaction(prop);\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Field Synchronization\n  // ---------------------------------------------------------------------------\n\n  function syncField(property: SpacingProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n\n    if (!target || !target.isConnected) {\n      field.input.value = '';\n      field.input.placeholder = '';\n      field.input.disabled = true;\n      field.container.setSuffix('px');\n      return;\n    }\n\n    field.input.disabled = false;\n\n    const isEditing = field.handle !== null || isInputFocused(field.input);\n    if (isEditing && !force) return;\n\n    const inlineValue = readInlineValue(target, property);\n    const displayValue = inlineValue || readComputedValue(target, property);\n    const formatted = formatLengthForDisplay(displayValue);\n    field.input.value = formatted.value;\n    field.input.placeholder = '';\n    field.container.setSuffix(formatted.suffix);\n  }\n\n  function syncAllFields(): void {\n    for (const prop of SPACING_PROPERTIES) {\n      syncField(prop);\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Event Wiring\n  // ---------------------------------------------------------------------------\n\n  function wireField(property: SpacingProperty): void {\n    const field = fields[property];\n    const input = field.input;\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (!handle) return;\n      // Combine input value with current suffix to preserve unit\n      const suffix = field.container.getSuffixText();\n      handle.set(combineLengthValue(input.value, suffix));\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  for (const prop of SPACING_PROPERTIES) {\n    wireField(prop);\n  }\n\n  // ---------------------------------------------------------------------------\n  // Public API\n  // ---------------------------------------------------------------------------\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n    syncAllFields();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/token-picker.ts",
    "content": "/**\n * Token Picker Control (Phase 5.4)\n *\n * A dropdown picker for selecting CSS design tokens (custom properties).\n * Integrates with DesignTokensService for token discovery and resolution.\n *\n * Features:\n * - Shows available tokens for the current element context\n * - Filter tokens by typing\n * - Preview token computed value\n * - Select token to apply var(--token) to a CSS property\n * - \"Show all\" toggle for full root token list vs context tokens\n *\n * Usage pattern:\n * - Attach to an input field as an \"enhancement\"\n * - When user clicks the token button, show dropdown\n * - On selection, callback returns the var(--token) value\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { ContextToken, CssVarName, DesignTokensService } from '../../../core/design-tokens';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Options for creating a token picker */\nexport interface TokenPickerOptions {\n  /** Container element (should be positioned relative) */\n  container: HTMLElement;\n  /** Design tokens service instance */\n  tokensService: DesignTokensService;\n  /** Called when user selects a token */\n  onSelect: (tokenName: CssVarName, cssValue: string) => void;\n  /** Optional filter by token kind (future use) */\n  tokenKind?: 'color' | 'length' | 'all';\n  /** Max items to show before scrolling */\n  maxVisible?: number;\n}\n\n/** Token picker public interface */\nexport interface TokenPicker {\n  /** Set the target element (tokens are filtered by context) */\n  setTarget(element: Element | null): void;\n  /** Show the picker dropdown */\n  show(): void;\n  /** Hide the picker dropdown */\n  hide(): void;\n  /** Toggle dropdown visibility */\n  toggle(): boolean;\n  /** Check if dropdown is visible */\n  isVisible(): boolean;\n  /** Refresh token list */\n  refresh(): void;\n  /** Cleanup */\n  dispose(): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst DEFAULT_MAX_VISIBLE = 8;\nconst FILTER_DEBOUNCE_MS = 100;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a token picker component.\n */\nexport function createTokenPicker(options: TokenPickerOptions): TokenPicker {\n  const { container, tokensService, onSelect, maxVisible = DEFAULT_MAX_VISIBLE } = options;\n\n  const disposer = new Disposer();\n\n  // State\n  let currentTarget: Element | null = null;\n  let contextTokens: ContextToken[] = [];\n  let filteredTokens: ContextToken[] = [];\n  let showAllTokens = false;\n  let filterText = '';\n  let filterTimeoutId: ReturnType<typeof setTimeout> | null = null;\n  let selectedIndex = -1;\n\n  // ===========================================================================\n  // DOM Structure\n  // ===========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-token-picker';\n  root.hidden = true;\n\n  // Filter input\n  const filterInput = document.createElement('input');\n  filterInput.type = 'text';\n  filterInput.className = 'we-token-filter';\n  filterInput.placeholder = 'Filter tokens...';\n  filterInput.autocomplete = 'off';\n  filterInput.spellcheck = false;\n\n  // Toggle for \"show all\"\n  const toggleRow = document.createElement('div');\n  toggleRow.className = 'we-token-toggle-row';\n\n  const toggleLabel = document.createElement('label');\n  toggleLabel.className = 'we-token-toggle-label';\n\n  const toggleCheckbox = document.createElement('input');\n  toggleCheckbox.type = 'checkbox';\n  toggleCheckbox.className = 'we-token-toggle-checkbox';\n\n  const toggleText = document.createElement('span');\n  toggleText.textContent = 'Show all root tokens';\n\n  toggleLabel.append(toggleCheckbox, toggleText);\n  toggleRow.append(toggleLabel);\n\n  // Token list\n  const listContainer = document.createElement('div');\n  listContainer.className = 'we-token-list';\n  listContainer.style.maxHeight = `${maxVisible * 36}px`;\n\n  // Empty state\n  const emptyState = document.createElement('div');\n  emptyState.className = 'we-token-empty';\n  emptyState.textContent = 'No tokens found';\n  emptyState.hidden = true;\n\n  root.append(filterInput, toggleRow, listContainer, emptyState);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ===========================================================================\n  // Token Loading\n  // ===========================================================================\n\n  function loadTokens(): void {\n    if (!currentTarget || !currentTarget.isConnected) {\n      contextTokens = [];\n      filteredTokens = [];\n      return;\n    }\n\n    if (showAllTokens) {\n      // Get all root tokens\n      const root = currentTarget.getRootNode() as Document | ShadowRoot;\n      const result = tokensService.getRootTokens(root);\n      // Convert to ContextToken format with computed values\n      contextTokens = result.tokens.map((token) => {\n        const resolution = tokensService.resolveToken(currentTarget!, token.name);\n        return {\n          token,\n          computedValue: resolution.computedValue,\n        };\n      });\n    } else {\n      // Get only context-available tokens\n      const result = tokensService.getContextTokens(currentTarget);\n      contextTokens = [...result.tokens];\n    }\n\n    applyFilter();\n  }\n\n  function applyFilter(): void {\n    const query = filterText.toLowerCase().trim();\n\n    if (!query) {\n      filteredTokens = contextTokens;\n    } else {\n      filteredTokens = contextTokens.filter((ct) => {\n        const name = ct.token.name.toLowerCase();\n        const value = ct.computedValue.toLowerCase();\n        return name.includes(query) || value.includes(query);\n      });\n    }\n\n    selectedIndex = filteredTokens.length > 0 ? 0 : -1;\n    renderList();\n  }\n\n  // ===========================================================================\n  // Rendering\n  // ===========================================================================\n\n  function renderList(): void {\n    listContainer.innerHTML = '';\n\n    if (filteredTokens.length === 0) {\n      emptyState.hidden = false;\n      listContainer.hidden = true;\n      return;\n    }\n\n    emptyState.hidden = true;\n    listContainer.hidden = false;\n\n    for (let i = 0; i < filteredTokens.length; i++) {\n      const ct = filteredTokens[i]!;\n      const item = createTokenItem(ct, i);\n      listContainer.append(item);\n    }\n\n    updateSelectedHighlight();\n  }\n\n  function createTokenItem(ct: ContextToken, index: number): HTMLElement {\n    const item = document.createElement('button');\n    item.type = 'button';\n    item.className = 'we-token-item';\n    item.dataset.index = String(index);\n    item.dataset.name = ct.token.name;\n\n    // Token name\n    const nameEl = document.createElement('span');\n    nameEl.className = 'we-token-name';\n    nameEl.textContent = ct.token.name;\n\n    // Computed value preview\n    const valueEl = document.createElement('span');\n    valueEl.className = 'we-token-value';\n    valueEl.textContent = ct.computedValue || '(unset)';\n\n    // Color swatch for color-like values\n    const computedLower = ct.computedValue.toLowerCase();\n    const isColor =\n      computedLower.startsWith('#') ||\n      computedLower.startsWith('rgb') ||\n      computedLower.startsWith('hsl') ||\n      /^[a-z]+$/.test(computedLower); // Named colors\n\n    if (isColor && ct.computedValue) {\n      const swatch = document.createElement('span');\n      swatch.className = 'we-token-swatch';\n      swatch.style.backgroundColor = ct.computedValue;\n      item.append(swatch);\n    }\n\n    item.append(nameEl, valueEl);\n    return item;\n  }\n\n  function updateSelectedHighlight(): void {\n    const items = listContainer.querySelectorAll('.we-token-item');\n    items.forEach((item, i) => {\n      item.classList.toggle('we-token-item--selected', i === selectedIndex);\n    });\n\n    // Scroll selected item into view\n    if (selectedIndex >= 0 && selectedIndex < items.length) {\n      const selectedItem = items[selectedIndex] as HTMLElement;\n      selectedItem.scrollIntoView({ block: 'nearest' });\n    }\n  }\n\n  // ===========================================================================\n  // Selection\n  // ===========================================================================\n\n  function selectToken(index: number): void {\n    if (index < 0 || index >= filteredTokens.length) return;\n\n    const ct = filteredTokens[index]!;\n    const cssValue = tokensService.formatCssVar(ct.token.name);\n\n    hide();\n    onSelect(ct.token.name, cssValue);\n  }\n\n  function selectCurrent(): void {\n    if (selectedIndex >= 0) {\n      selectToken(selectedIndex);\n    }\n  }\n\n  // ===========================================================================\n  // Event Handlers\n  // ===========================================================================\n\n  // Filter input\n  disposer.listen(filterInput, 'input', () => {\n    filterText = filterInput.value;\n\n    // Debounce filter\n    if (filterTimeoutId) {\n      clearTimeout(filterTimeoutId);\n    }\n    filterTimeoutId = setTimeout(() => {\n      filterTimeoutId = null;\n      applyFilter();\n    }, FILTER_DEBOUNCE_MS);\n  });\n\n  // Keyboard navigation\n  disposer.listen(filterInput, 'keydown', (e: KeyboardEvent) => {\n    switch (e.key) {\n      case 'ArrowDown':\n        e.preventDefault();\n        if (filteredTokens.length > 0) {\n          selectedIndex = Math.min(selectedIndex + 1, filteredTokens.length - 1);\n          updateSelectedHighlight();\n        }\n        break;\n\n      case 'ArrowUp':\n        e.preventDefault();\n        if (filteredTokens.length > 0) {\n          selectedIndex = Math.max(selectedIndex - 1, 0);\n          updateSelectedHighlight();\n        }\n        break;\n\n      case 'Enter':\n        e.preventDefault();\n        selectCurrent();\n        break;\n\n      case 'Escape':\n        e.preventDefault();\n        hide();\n        break;\n    }\n  });\n\n  // Toggle checkbox\n  disposer.listen(toggleCheckbox, 'change', () => {\n    showAllTokens = toggleCheckbox.checked;\n    loadTokens();\n  });\n\n  // Item clicks\n  disposer.listen(listContainer, 'click', (e: MouseEvent) => {\n    const target = e.target as HTMLElement;\n    const item = target.closest('.we-token-item') as HTMLElement | null;\n    if (!item) return;\n\n    const index = parseInt(item.dataset.index ?? '-1', 10);\n    if (index >= 0) {\n      selectToken(index);\n    }\n  });\n\n  // Prevent blur when clicking inside picker\n  disposer.listen(root, 'mousedown', (e: MouseEvent) => {\n    e.preventDefault();\n  });\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    currentTarget = element && element.isConnected ? element : null;\n    filterText = '';\n    filterInput.value = '';\n\n    if (root.hidden) return; // Only load if visible\n    loadTokens();\n  }\n\n  function show(): void {\n    if (disposer.isDisposed) return;\n    if (!root.hidden) return;\n\n    root.hidden = false;\n    loadTokens();\n    filterInput.focus();\n  }\n\n  function hide(): void {\n    if (disposer.isDisposed) return;\n    root.hidden = true;\n    filterText = '';\n    filterInput.value = '';\n    selectedIndex = -1;\n  }\n\n  function toggle(): boolean {\n    if (root.hidden) {\n      show();\n      return true;\n    } else {\n      hide();\n      return false;\n    }\n  }\n\n  function isVisible(): boolean {\n    return !root.hidden;\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    if (root.hidden) return;\n    loadTokens();\n  }\n\n  function dispose(): void {\n    if (filterTimeoutId) {\n      clearTimeout(filterTimeoutId);\n      filterTimeoutId = null;\n    }\n    currentTarget = null;\n    contextTokens = [];\n    filteredTokens = [];\n    disposer.dispose();\n  }\n\n  return {\n    setTarget,\n    show,\n    hide,\n    toggle,\n    isVisible,\n    refresh,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/controls/typography-control.ts",
    "content": "/**\n * Typography Control (Phase 3.7)\n *\n * Edits inline typography styles:\n * - font-family (select + custom input)\n * - font-size (input)\n * - font-weight (select)\n * - line-height (input)\n * - letter-spacing (input)\n * - text-align (icon button group)\n * - vertical-align (icon button group)\n * - color (input with optional token picker)\n *\n * Phase 5.4: Added optional DesignTokensService integration for color field.\n */\n\nimport { Disposer } from '../../../utils/disposables';\nimport type { StyleTransactionHandle, TransactionManager } from '../../../core/transaction-manager';\nimport type { DesignTokensService } from '../../../core/design-tokens';\nimport { createColorField, type ColorField } from './color-field';\nimport { createGradientControl } from './gradient-control';\nimport { createInputContainer, type InputContainer } from '../components/input-container';\nimport { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';\nimport { combineLengthValue, formatLengthForDisplay, hasExplicitUnit } from './css-helpers';\nimport { wireNumberStepping } from './number-stepping';\nimport type { DesignControl } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\n\nconst FONT_WEIGHT_VALUES = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const;\nconst TEXT_ALIGN_VALUES = ['left', 'center', 'right', 'justify'] as const;\nconst VERTICAL_ALIGN_VALUES = ['baseline', 'middle', 'top', 'bottom'] as const;\n\n/** Text color type: solid uses 'color' property, gradient uses background-clip: text */\nconst TEXT_COLOR_TYPE_VALUES = ['solid', 'gradient'] as const;\ntype TextColorType = (typeof TEXT_COLOR_TYPE_VALUES)[number];\n\ntype TextAlignValue = (typeof TEXT_ALIGN_VALUES)[number];\ntype VerticalAlignValue = (typeof VERTICAL_ALIGN_VALUES)[number];\nconst FONT_FAMILY_PRESET_VALUES = [\n  'inherit',\n  'system-ui',\n  'sans-serif',\n  'serif',\n  'monospace',\n] as const;\nconst FONT_FAMILY_CUSTOM_VALUE = 'custom';\n\ntype TypographyProperty =\n  | 'font-family'\n  | 'font-size'\n  | 'font-weight'\n  | 'line-height'\n  | 'letter-spacing'\n  | 'text-align'\n  | 'vertical-align'\n  | 'color';\n\n/** Standard input/select field state */\ninterface StandardFieldState {\n  kind: 'standard';\n  property: TypographyProperty;\n  element: HTMLSelectElement | HTMLInputElement;\n  handle: StyleTransactionHandle | null;\n  /** InputContainer reference for input fields (null/undefined for selects) */\n  container?: InputContainer;\n}\n\n/** Font-family field state (preset select + optional custom input) */\ninterface FontFamilyFieldState {\n  kind: 'font-family';\n  property: 'font-family';\n  select: HTMLSelectElement;\n  custom: InputContainer;\n  controlsContainer: HTMLElement;\n  handle: StyleTransactionHandle | null;\n}\n\n/** Text-align field state (icon button group) */\ninterface TextAlignFieldState {\n  kind: 'text-align';\n  property: 'text-align';\n  group: IconButtonGroup<TextAlignValue>;\n  handle: StyleTransactionHandle | null;\n}\n\n/** Vertical-align field state (icon button group) */\ninterface VerticalAlignFieldState {\n  kind: 'vertical-align';\n  property: 'vertical-align';\n  group: IconButtonGroup<VerticalAlignValue>;\n  handle: StyleTransactionHandle | null;\n}\n\n/** Color field state */\ninterface ColorFieldState {\n  kind: 'color';\n  property: TypographyProperty;\n  field: ColorField;\n  handle: StyleTransactionHandle | null;\n}\n\ntype FieldState =\n  | StandardFieldState\n  | FontFamilyFieldState\n  | TextAlignFieldState\n  | VerticalAlignFieldState\n  | ColorFieldState;\n\n// =============================================================================\n// SVG Icon Helpers\n// =============================================================================\n\nfunction createBaseIconSvg(): SVGSVGElement {\n  const svg = document.createElementNS(SVG_NS, 'svg');\n  svg.setAttribute('viewBox', '0 0 15 15');\n  svg.setAttribute('fill', 'none');\n  svg.setAttribute('aria-hidden', 'true');\n  svg.setAttribute('focusable', 'false');\n  return svg;\n}\n\nfunction createTextAlignIcon(value: TextAlignValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 容器边框（虚线矩形表示容器）\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n\n  // 文本行的位置配置：每行的 [x起点, 宽度]\n  const lineConfigs: Record<TextAlignValue, Array<[number, number]>> = {\n    left: [\n      [3.5, 8], // 长行\n      [3.5, 5], // 短行\n      [3.5, 6.5], // 中行\n    ],\n    center: [\n      [3.5, 8], // 长行居中\n      [5, 5], // 短行居中\n      [4.25, 6.5], // 中行居中\n    ],\n    right: [\n      [3.5, 8], // 长行\n      [6.5, 5], // 短行靠右\n      [5.5, 6.5], // 中行靠右\n    ],\n    justify: [\n      [3.5, 8], // 全宽\n      [3.5, 8], // 全宽\n      [3.5, 8], // 全宽\n    ],\n  };\n\n  const yPositions = [4.5, 7.5, 10.5];\n  const configs = lineConfigs[value];\n\n  configs.forEach(([x, width], index) => {\n    const line = document.createElementNS(SVG_NS, 'rect');\n    line.setAttribute('x', String(x));\n    line.setAttribute('y', String(yPositions[index] - 0.5));\n    line.setAttribute('width', String(width));\n    line.setAttribute('height', '1');\n    line.setAttribute('rx', '0.5');\n    line.setAttribute('fill', 'currentColor');\n    svg.append(line);\n  });\n\n  svg.prepend(container);\n  return svg;\n}\n\nfunction createVerticalAlignIcon(value: VerticalAlignValue): SVGElement {\n  const svg = createBaseIconSvg();\n\n  // 容器边框（虚线矩形表示容器）\n  const container = document.createElementNS(SVG_NS, 'rect');\n  container.setAttribute('x', '2');\n  container.setAttribute('y', '2');\n  container.setAttribute('width', '11');\n  container.setAttribute('height', '11');\n  container.setAttribute('rx', '1.5');\n  container.setAttribute('stroke', 'currentColor');\n  container.setAttribute('stroke-width', '1');\n  container.setAttribute('stroke-dasharray', '2 1');\n  container.setAttribute('fill', 'none');\n  container.setAttribute('opacity', '0.5');\n\n  // 内容块的 Y 坐标根据对齐方式不同\n  const blockY: Record<VerticalAlignValue, number> = {\n    top: 3.5, // 顶部对齐\n    middle: 5.5, // 居中对齐\n    bottom: 7.5, // 底部对齐\n    baseline: 6.5, // baseline 稍微偏下\n  };\n\n  // 两个小方块表示子元素\n  const block1 = document.createElementNS(SVG_NS, 'rect');\n  block1.setAttribute('x', '4');\n  block1.setAttribute('y', String(blockY[value]));\n  block1.setAttribute('width', '3');\n  block1.setAttribute('height', '4');\n  block1.setAttribute('rx', '0.5');\n  block1.setAttribute('fill', 'currentColor');\n\n  const block2 = document.createElementNS(SVG_NS, 'rect');\n  block2.setAttribute('x', '8');\n  block2.setAttribute('y', String(blockY[value]));\n  block2.setAttribute('width', '3');\n  block2.setAttribute('height', '4');\n  block2.setAttribute('rx', '0.5');\n  block2.setAttribute('fill', 'currentColor');\n\n  svg.append(container, block1, block2);\n\n  // baseline 模式添加基线指示线\n  if (value === 'baseline') {\n    const baselinePath = document.createElementNS(SVG_NS, 'path');\n    baselinePath.setAttribute('d', 'M3 10H12');\n    baselinePath.setAttribute('stroke', 'currentColor');\n    baselinePath.setAttribute('stroke-width', '1');\n    baselinePath.setAttribute('stroke-dasharray', '1.5 1');\n    svg.append(baselinePath);\n  }\n\n  return svg;\n}\n\nfunction isTextAlignValue(value: string): value is TextAlignValue {\n  return (TEXT_ALIGN_VALUES as readonly string[]).includes(value);\n}\n\nfunction isVerticalAlignValue(value: string): value is VerticalAlignValue {\n  return (VERTICAL_ALIGN_VALUES as readonly string[]).includes(value);\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isFieldFocused(el: HTMLElement): boolean {\n  try {\n    const rootNode = el.getRootNode();\n    if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;\n    return document.activeElement === el;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Normalize line-height value.\n * Keeps unitless numbers as-is (e.g., \"1.5\" stays \"1.5\", not \"1.5px\")\n * because unitless line-height is relative to font-size.\n */\nfunction normalizeLineHeight(raw: string): string {\n  const trimmed = raw.trim();\n  if (!trimmed) return '';\n  // Keep unitless numbers as-is for line-height\n  return trimmed;\n}\n\nfunction readInlineValue(element: Element, property: string): string {\n  try {\n    const style = (element as HTMLElement).style;\n    return style?.getPropertyValue?.(property)?.trim() ?? '';\n  } catch {\n    return '';\n  }\n}\n\nfunction readComputedValue(element: Element, property: string): string {\n  try {\n    return window.getComputedStyle(element).getPropertyValue(property).trim();\n  } catch {\n    return '';\n  }\n}\n\n/**\n * Check if a value is a gradient background-image.\n */\nfunction isGradientBackgroundValue(raw: string): boolean {\n  return /\\b(?:linear|radial|conic)-gradient\\s*\\(/i.test(raw.trim());\n}\n\n/**\n * Check if text-fill-color is transparent (for gradient text detection).\n */\nfunction isTransparentTextFillColor(raw: string): boolean {\n  const v = raw.trim().toLowerCase();\n  if (!v) return false;\n  if (v === 'transparent') return true;\n  // Some browsers compute transparent as rgba(..., 0)\n  if (/^rgba\\([^)]*,\\s*0\\s*\\)$/.test(v)) return true;\n  return false;\n}\n\n/**\n * Infer text color type from element's computed styles.\n * Returns 'gradient' if background-clip: text pattern is detected.\n */\nfunction inferTextColorType(target: Element): TextColorType {\n  const bgImage =\n    readInlineValue(target, 'background-image') || readComputedValue(target, 'background-image');\n  const bgClip =\n    readInlineValue(target, '-webkit-background-clip') ||\n    readComputedValue(target, '-webkit-background-clip');\n  const textFill =\n    readInlineValue(target, '-webkit-text-fill-color') ||\n    readComputedValue(target, '-webkit-text-fill-color');\n\n  const hasGradientBg =\n    bgImage && bgImage.toLowerCase() !== 'none' && isGradientBackgroundValue(bgImage);\n  const hasClipText = bgClip.toLowerCase().includes('text');\n  const hasTransparentFill = isTransparentTextFillColor(textFill);\n\n  return hasGradientBg && hasClipText && hasTransparentFill ? 'gradient' : 'solid';\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport interface TypographyControlOptions {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n  /** Optional: DesignTokensService for token picker integration (Phase 5.4) */\n  tokensService?: DesignTokensService;\n}\n\nexport function createTypographyControl(options: TypographyControlOptions): DesignControl {\n  const { container, transactionManager, tokensService } = options;\n  const disposer = new Disposer();\n\n  let currentTarget: Element | null = null;\n  let currentTextColorType: TextColorType = 'solid';\n\n  const root = document.createElement('div');\n  root.className = 'we-field-group';\n\n  // ---------------------------------------------------------------------------\n  // Font Family (preset select + optional custom input)\n  // ---------------------------------------------------------------------------\n  const fontFamilyRow = document.createElement('div');\n  fontFamilyRow.className = 'we-field';\n  const fontFamilyLabel = document.createElement('span');\n  fontFamilyLabel.className = 'we-field-label';\n  fontFamilyLabel.textContent = 'Font';\n\n  const fontFamilyControls = document.createElement('div');\n  fontFamilyControls.className = 'we-field-content';\n  fontFamilyControls.style.display = 'flex';\n  fontFamilyControls.style.flexDirection = 'column';\n  fontFamilyControls.style.gap = '4px';\n\n  const fontFamilySelect = document.createElement('select');\n  fontFamilySelect.className = 'we-select';\n  fontFamilySelect.setAttribute('aria-label', 'Font Family');\n  for (const v of FONT_FAMILY_PRESET_VALUES) {\n    const opt = document.createElement('option');\n    opt.value = v;\n    opt.textContent = v;\n    fontFamilySelect.append(opt);\n  }\n  const customFontOpt = document.createElement('option');\n  customFontOpt.value = FONT_FAMILY_CUSTOM_VALUE;\n  customFontOpt.textContent = 'Custom…';\n  fontFamilySelect.append(customFontOpt);\n\n  const fontFamilyCustomContainer = createInputContainer({\n    ariaLabel: 'Custom Font Family',\n    prefix: null,\n    suffix: null,\n    placeholder: 'e.g. Inter, system-ui',\n  });\n  fontFamilyCustomContainer.root.style.display = 'none';\n  fontFamilyCustomContainer.input.disabled = true;\n\n  fontFamilyControls.append(fontFamilySelect, fontFamilyCustomContainer.root);\n  fontFamilyRow.append(fontFamilyLabel, fontFamilyControls);\n\n  // ---------------------------------------------------------------------------\n  // Font Size (with input-container for suffix support)\n  // ---------------------------------------------------------------------------\n  const fontSizeRow = document.createElement('div');\n  fontSizeRow.className = 'we-field';\n  const fontSizeLabel = document.createElement('span');\n  fontSizeLabel.className = 'we-field-label';\n  fontSizeLabel.textContent = 'Size';\n  const fontSizeContainer = createInputContainer({\n    ariaLabel: 'Font Size',\n    inputMode: 'decimal',\n    prefix: null,\n    suffix: 'px',\n  });\n  fontSizeRow.append(fontSizeLabel, fontSizeContainer.root);\n\n  // Font Weight\n  const fontWeightRow = document.createElement('div');\n  fontWeightRow.className = 'we-field';\n  const fontWeightLabel = document.createElement('span');\n  fontWeightLabel.className = 'we-field-label';\n  fontWeightLabel.textContent = 'Weight';\n  const fontWeightSelect = document.createElement('select');\n  fontWeightSelect.className = 'we-select';\n  fontWeightSelect.setAttribute('aria-label', 'Font Weight');\n  for (const v of FONT_WEIGHT_VALUES) {\n    const opt = document.createElement('option');\n    opt.value = v;\n    opt.textContent = v;\n    fontWeightSelect.append(opt);\n  }\n  fontWeightRow.append(fontWeightLabel, fontWeightSelect);\n\n  // Line Height (with input-container, suffix only shown if value has unit)\n  const lineHeightRow = document.createElement('div');\n  lineHeightRow.className = 'we-field';\n  const lineHeightLabel = document.createElement('span');\n  lineHeightLabel.className = 'we-field-label';\n  lineHeightLabel.textContent = 'Line Height';\n  const lineHeightContainer = createInputContainer({\n    ariaLabel: 'Line Height',\n    inputMode: 'decimal',\n    prefix: null,\n    suffix: null, // Will be set dynamically based on value\n  });\n  lineHeightRow.append(lineHeightLabel, lineHeightContainer.root);\n\n  // ---------------------------------------------------------------------------\n  // Letter Spacing\n  // ---------------------------------------------------------------------------\n  const letterSpacingRow = document.createElement('div');\n  letterSpacingRow.className = 'we-field';\n  const letterSpacingLabel = document.createElement('span');\n  letterSpacingLabel.className = 'we-field-label';\n  letterSpacingLabel.textContent = 'Spacing';\n  const letterSpacingContainer = createInputContainer({\n    ariaLabel: 'Letter Spacing',\n    inputMode: 'decimal',\n    prefix: null,\n    suffix: 'px',\n  });\n  letterSpacingRow.append(letterSpacingLabel, letterSpacingContainer.root);\n\n  // Wire up keyboard stepping for arrow up/down\n  wireNumberStepping(disposer, fontSizeContainer.input, { mode: 'css-length' });\n  wireNumberStepping(disposer, lineHeightContainer.input, {\n    mode: 'css-length',\n    step: 0.1,\n    shiftStep: 1,\n    altStep: 0.01,\n  });\n  wireNumberStepping(disposer, letterSpacingContainer.input, {\n    mode: 'css-length',\n    step: 0.1,\n    shiftStep: 1,\n    altStep: 0.01,\n  });\n\n  // ---------------------------------------------------------------------------\n  // Text Align (icon button group)\n  // ---------------------------------------------------------------------------\n  const textAlignRow = document.createElement('div');\n  textAlignRow.className = 'we-field';\n  const textAlignLabel = document.createElement('span');\n  textAlignLabel.className = 'we-field-label';\n  textAlignLabel.textContent = 'Text Align';\n  const textAlignMount = document.createElement('div');\n  textAlignMount.className = 'we-field-content';\n  textAlignRow.append(textAlignLabel, textAlignMount);\n\n  // ---------------------------------------------------------------------------\n  // Vertical Align (icon button group)\n  // ---------------------------------------------------------------------------\n  const verticalAlignRow = document.createElement('div');\n  verticalAlignRow.className = 'we-field';\n  const verticalAlignLabel = document.createElement('span');\n  verticalAlignLabel.className = 'we-field-label';\n  verticalAlignLabel.textContent = 'Vertical Align';\n  const verticalAlignMount = document.createElement('div');\n  verticalAlignMount.className = 'we-field-content';\n  verticalAlignRow.append(verticalAlignLabel, verticalAlignMount);\n\n  // ---------------------------------------------------------------------------\n  // Text Color Type selector (solid / gradient)\n  // ---------------------------------------------------------------------------\n  const textColorTypeRow = document.createElement('div');\n  textColorTypeRow.className = 'we-field';\n\n  const textColorTypeLabel = document.createElement('span');\n  textColorTypeLabel.className = 'we-field-label';\n  textColorTypeLabel.textContent = 'Type';\n\n  const textColorTypeSelect = document.createElement('select');\n  textColorTypeSelect.className = 'we-select';\n  textColorTypeSelect.setAttribute('aria-label', 'Text Color Type');\n  for (const v of TEXT_COLOR_TYPE_VALUES) {\n    const opt = document.createElement('option');\n    opt.value = v;\n    opt.textContent = v.charAt(0).toUpperCase() + v.slice(1);\n    textColorTypeSelect.append(opt);\n  }\n  textColorTypeRow.append(textColorTypeLabel, textColorTypeSelect);\n\n  // ---------------------------------------------------------------------------\n  // Color (with ColorField - TokenPill and TokenPicker are now built into ColorField)\n  // ---------------------------------------------------------------------------\n  const colorRow = document.createElement('div');\n  colorRow.className = 'we-field';\n\n  const colorLabel = document.createElement('span');\n  colorLabel.className = 'we-field-label';\n  colorLabel.textContent = 'Color';\n\n  const colorFieldContainer = document.createElement('div');\n  colorFieldContainer.style.minWidth = '0';\n\n  colorRow.append(colorLabel, colorFieldContainer);\n\n  // Gradient mount for text gradient (uses background-image + background-clip: text)\n  const textGradientMount = document.createElement('div');\n\n  // Create combined row for Size and Weight\n  const sizeAndWeightRow = document.createElement('div');\n  sizeAndWeightRow.className = 'we-field-row';\n  fontSizeRow.style.flex = '1';\n  fontSizeRow.style.minWidth = '0';\n  fontWeightRow.style.flex = '1';\n  fontWeightRow.style.minWidth = '0';\n  sizeAndWeightRow.append(fontSizeRow, fontWeightRow);\n\n  // Create combined row for Line Height and Spacing\n  const lineHeightAndSpacingRow = document.createElement('div');\n  lineHeightAndSpacingRow.className = 'we-field-row';\n  lineHeightRow.style.flex = '1';\n  lineHeightRow.style.minWidth = '0';\n  letterSpacingRow.style.flex = '1';\n  letterSpacingRow.style.minWidth = '0';\n  lineHeightAndSpacingRow.append(lineHeightRow, letterSpacingRow);\n\n  root.append(\n    fontFamilyRow,\n    sizeAndWeightRow,\n    lineHeightAndSpacingRow,\n    textAlignRow,\n    verticalAlignRow,\n    textColorTypeRow,\n    colorRow,\n    textGradientMount,\n  );\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // -------------------------------------------------------------------------\n  // Create IconButtonGroup instances for text-align and vertical-align\n  // -------------------------------------------------------------------------\n  const textAlignGroup = createIconButtonGroup<TextAlignValue>({\n    container: textAlignMount,\n    ariaLabel: 'Text Align',\n    columns: 4,\n    items: TEXT_ALIGN_VALUES.map((v) => ({\n      value: v,\n      ariaLabel: `text-align: ${v}`,\n      title: v.charAt(0).toUpperCase() + v.slice(1),\n      icon: createTextAlignIcon(v),\n    })),\n    onChange: (value) => {\n      const handle = beginTransaction('text-align');\n      if (handle) handle.set(value);\n      commitTransaction('text-align');\n      syncAllFields();\n    },\n  });\n  disposer.add(() => textAlignGroup.dispose());\n\n  const verticalAlignGroup = createIconButtonGroup<VerticalAlignValue>({\n    container: verticalAlignMount,\n    ariaLabel: 'Vertical Align',\n    columns: 4,\n    items: VERTICAL_ALIGN_VALUES.map((v) => ({\n      value: v,\n      ariaLabel: `vertical-align: ${v}`,\n      title: v.charAt(0).toUpperCase() + v.slice(1),\n      icon: createVerticalAlignIcon(v),\n    })),\n    onChange: (value) => {\n      const handle = beginTransaction('vertical-align');\n      if (handle) handle.set(value);\n      commitTransaction('vertical-align');\n      syncAllFields();\n    },\n  });\n  disposer.add(() => verticalAlignGroup.dispose());\n\n  // -------------------------------------------------------------------------\n  // Create ColorField instance for text color\n  // (TokenPill and TokenPicker are built into ColorField when tokensService is provided)\n  // -------------------------------------------------------------------------\n  const textColorField = createColorField({\n    container: colorFieldContainer,\n    ariaLabel: 'Text Color',\n    tokensService,\n    getTokenTarget: () => currentTarget,\n    onInput: (value) => {\n      const handle = beginTransaction('color');\n      if (handle) handle.set(value);\n    },\n    onCommit: () => {\n      commitTransaction('color');\n      syncAllFields();\n    },\n    onCancel: () => {\n      rollbackTransaction('color');\n      syncField('color', true);\n    },\n  });\n  disposer.add(() => textColorField.dispose());\n\n  // -------------------------------------------------------------------------\n  // Text Gradient Control (uses background-image + background-clip: text)\n  // Note: This intentionally uses background-image which may conflict with\n  // Background control. Users should be aware that text gradient and element\n  // background cannot be used simultaneously on the same element.\n  // -------------------------------------------------------------------------\n  const textGradientControl = createGradientControl({\n    container: textGradientMount,\n    transactionManager,\n    tokensService,\n    property: 'background-image',\n    // Disable 'none' option since transparent text-fill-color with no background\n    // would make text invisible\n    allowNone: false,\n  });\n  disposer.add(() => textGradientControl.dispose());\n\n  // -------------------------------------------------------------------------\n  // Field state map\n  // -------------------------------------------------------------------------\n  const fields: Record<TypographyProperty, FieldState> = {\n    'font-family': {\n      kind: 'font-family',\n      property: 'font-family',\n      select: fontFamilySelect,\n      custom: fontFamilyCustomContainer,\n      controlsContainer: fontFamilyControls,\n      handle: null,\n    },\n    'font-size': {\n      kind: 'standard',\n      property: 'font-size',\n      element: fontSizeContainer.input,\n      container: fontSizeContainer,\n      handle: null,\n    },\n    'font-weight': {\n      kind: 'standard',\n      property: 'font-weight',\n      element: fontWeightSelect,\n      handle: null,\n    },\n    'line-height': {\n      kind: 'standard',\n      property: 'line-height',\n      element: lineHeightContainer.input,\n      container: lineHeightContainer,\n      handle: null,\n    },\n    'letter-spacing': {\n      kind: 'standard',\n      property: 'letter-spacing',\n      element: letterSpacingContainer.input,\n      container: letterSpacingContainer,\n      handle: null,\n    },\n    'text-align': {\n      kind: 'text-align',\n      property: 'text-align',\n      group: textAlignGroup,\n      handle: null,\n    },\n    'vertical-align': {\n      kind: 'vertical-align',\n      property: 'vertical-align',\n      group: verticalAlignGroup,\n      handle: null,\n    },\n    color: { kind: 'color', property: 'color', field: textColorField, handle: null },\n  };\n\n  const PROPS: readonly TypographyProperty[] = [\n    'font-family',\n    'font-size',\n    'font-weight',\n    'line-height',\n    'letter-spacing',\n    'text-align',\n    'vertical-align',\n    'color',\n  ];\n\n  function beginTransaction(property: TypographyProperty): StyleTransactionHandle | null {\n    if (disposer.isDisposed) return null;\n    const target = currentTarget;\n    if (!target || !target.isConnected) return null;\n    const field = fields[property];\n    if (field.handle) return field.handle;\n    const handle = transactionManager.beginStyle(target, property);\n    field.handle = handle;\n    return handle;\n  }\n\n  function commitTransaction(property: TypographyProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.commit({ merge: true });\n  }\n\n  function rollbackTransaction(property: TypographyProperty): void {\n    const field = fields[property];\n    const handle = field.handle;\n    field.handle = null;\n    if (handle) handle.rollback();\n  }\n\n  function commitAllTransactions(): void {\n    for (const p of PROPS) commitTransaction(p);\n  }\n\n  // -------------------------------------------------------------------------\n  // Text Color Type (Solid / Gradient)\n  // -------------------------------------------------------------------------\n\n  /**\n   * Update visibility of color-related rows based on currentTextColorType.\n   */\n  function updateTextColorTypeVisibility(): void {\n    colorRow.hidden = currentTextColorType !== 'solid';\n    textGradientMount.hidden = currentTextColorType !== 'gradient';\n  }\n\n  /**\n   * Set text color type and apply necessary CSS changes.\n   * Uses multiStyle transaction to atomically set background-clip text properties.\n   */\n  function setTextColorType(type: TextColorType): void {\n    const target = currentTarget;\n\n    currentTextColorType = type;\n    textColorTypeSelect.value = type;\n    updateTextColorTypeVisibility();\n\n    if (!target || !target.isConnected) return;\n\n    // Ensure we don't leave an open 'color' handle when switching modes\n    commitTransaction('color');\n\n    // Use multiStyle to atomically manage text gradient properties\n    const handle = transactionManager.beginMultiStyle(target, [\n      'background-image',\n      '-webkit-background-clip',\n      '-webkit-text-fill-color',\n    ]);\n    if (!handle) return;\n\n    if (type === 'solid') {\n      // Clear text gradient properties when switching to solid color\n      handle.set({\n        'background-image': '',\n        '-webkit-background-clip': '',\n        '-webkit-text-fill-color': '',\n      });\n    } else {\n      // Set up text gradient properties\n      const inlineBg = readInlineValue(target, 'background-image');\n      const computedBg = readComputedValue(target, 'background-image');\n      const currentBg = inlineBg || computedBg;\n\n      // Use existing gradient or provide a default\n      const hasValidGradient = currentBg && isGradientBackgroundValue(currentBg);\n      const gradientValue = hasValidGradient\n        ? currentBg\n        : 'linear-gradient(90deg, #000000, #ffffff)';\n\n      handle.set({\n        'background-image': gradientValue,\n        '-webkit-background-clip': 'text',\n        '-webkit-text-fill-color': 'transparent',\n      });\n    }\n\n    handle.commit({ merge: true });\n  }\n\n  // Wire text color type selector change event\n  disposer.listen(textColorTypeSelect, 'change', () => {\n    const type = textColorTypeSelect.value as TextColorType;\n    setTextColorType(type);\n    textGradientControl.refresh();\n    syncAllFields();\n  });\n\n  function syncField(property: TypographyProperty, force = false): void {\n    const field = fields[property];\n    const target = currentTarget;\n\n    // Handle font-family field (preset select + custom input)\n    if (field.kind === 'font-family') {\n      const presetValues = FONT_FAMILY_PRESET_VALUES as readonly string[];\n\n      if (!target || !target.isConnected) {\n        field.select.disabled = true;\n        field.select.value = presetValues[0] ?? 'inherit';\n        field.custom.input.disabled = true;\n        field.custom.input.value = '';\n        field.custom.root.style.display = 'none';\n        return;\n      }\n\n      field.select.disabled = false;\n\n      const isEditing =\n        field.handle !== null || isFieldFocused(field.select) || isFieldFocused(field.custom.input);\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const displayValue = inlineValue || readComputedValue(target, property);\n      const normalized = displayValue.trim().toLowerCase();\n\n      if (presetValues.includes(normalized)) {\n        field.select.value = normalized;\n        field.custom.root.style.display = 'none';\n        field.custom.input.disabled = true;\n      } else {\n        field.select.value = FONT_FAMILY_CUSTOM_VALUE;\n        field.custom.root.style.display = '';\n        field.custom.input.disabled = false;\n        field.custom.input.value = displayValue;\n      }\n      return;\n    }\n\n    // Handle text-align (icon button group)\n    if (field.kind === 'text-align') {\n      const group = field.group;\n\n      if (!target || !target.isConnected) {\n        group.setDisabled(true);\n        group.setValue(null);\n        return;\n      }\n\n      group.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      const raw = (inlineValue || computedValue).trim();\n      group.setValue(isTextAlignValue(raw) ? raw : 'left');\n      return;\n    }\n\n    // Handle vertical-align (icon button group)\n    if (field.kind === 'vertical-align') {\n      const group = field.group;\n\n      if (!target || !target.isConnected) {\n        group.setDisabled(true);\n        group.setValue(null);\n        return;\n      }\n\n      group.setDisabled(false);\n      const isEditing = field.handle !== null;\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      const raw = (inlineValue || computedValue).trim();\n      // Default to baseline if value is not in our common values\n      group.setValue(isVerticalAlignValue(raw) ? raw : 'baseline');\n      return;\n    }\n\n    if (field.kind === 'color') {\n      // Handle ColorField\n      const colorField = field.field;\n\n      if (!target || !target.isConnected) {\n        colorField.setDisabled(true);\n        colorField.setValue('');\n        colorField.setPlaceholder('');\n        return;\n      }\n\n      colorField.setDisabled(false);\n\n      const isEditing = field.handle !== null || colorField.isFocused();\n      if (isEditing && !force) return;\n\n      // Display real value: prefer inline style, fallback to computed style\n      const inlineValue = readInlineValue(target, property);\n      const computedValue = readComputedValue(target, property);\n      if (inlineValue) {\n        colorField.setValue(inlineValue);\n        // Pass computed value as placeholder when using CSS variables\n        // so color-field can resolve the actual color for swatch display\n        colorField.setPlaceholder(/\\bvar\\s*\\(/i.test(inlineValue) ? computedValue : '');\n      } else {\n        colorField.setValue(computedValue);\n        colorField.setPlaceholder('');\n      }\n      return;\n    }\n\n    // Handle standard input/select (remaining fields)\n    const el = field.element;\n\n    if (!target || !target.isConnected) {\n      el.disabled = true;\n      if (el instanceof HTMLInputElement) {\n        el.value = '';\n        el.placeholder = '';\n        // Reset suffix to defaults\n        if (field.container) {\n          if (property === 'font-size' || property === 'letter-spacing') {\n            field.container.setSuffix('px');\n          } else if (property === 'line-height') {\n            field.container.setSuffix(null);\n          }\n        }\n      }\n      return;\n    }\n\n    el.disabled = false;\n    const isEditing = field.handle !== null || isFieldFocused(el);\n\n    if (el instanceof HTMLInputElement) {\n      if (isEditing && !force) return;\n\n      const inlineValue = readInlineValue(target, property);\n      const displayValue = inlineValue || readComputedValue(target, property);\n\n      // Update value and suffix dynamically\n      if (field.container) {\n        if (property === 'font-size' || property === 'letter-spacing') {\n          const formatted = formatLengthForDisplay(displayValue);\n          el.value = formatted.value;\n          field.container.setSuffix(formatted.suffix);\n        } else if (property === 'line-height') {\n          // Line-height: only show suffix if value has explicit unit\n          if (hasExplicitUnit(displayValue)) {\n            const formatted = formatLengthForDisplay(displayValue);\n            el.value = formatted.value;\n            field.container.setSuffix(formatted.suffix);\n          } else {\n            el.value = displayValue;\n            field.container.setSuffix(null);\n          }\n        } else {\n          el.value = displayValue;\n        }\n      } else {\n        el.value = displayValue;\n      }\n      el.placeholder = '';\n    } else {\n      const inline = readInlineValue(target, property);\n      const computed = readComputedValue(target, property);\n      if (isEditing && !force) return;\n      const val = inline || computed;\n      const hasOption = Array.from(el.options).some((o) => o.value === val);\n      el.value = hasOption ? val : (el.options[0]?.value ?? '');\n    }\n  }\n\n  function syncAllFields(): void {\n    for (const p of PROPS) syncField(p);\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n    textColorTypeSelect.disabled = !hasTarget;\n    updateTextColorTypeVisibility();\n  }\n\n  function wireSelect(property: TypographyProperty): void {\n    const field = fields[property];\n    if (field.kind !== 'standard') return;\n\n    const select = field.element as HTMLSelectElement;\n\n    const preview = () => {\n      const handle = beginTransaction(property);\n      if (handle) handle.set(select.value);\n    };\n\n    disposer.listen(select, 'input', preview);\n    disposer.listen(select, 'change', preview);\n    disposer.listen(select, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  function wireInput(\n    property: TypographyProperty,\n    normalize: (v: string, suffix: string | null) => string = (v) => v.trim(),\n  ): void {\n    const field = fields[property];\n    if (field.kind !== 'standard') return;\n\n    const input = field.element as HTMLInputElement;\n\n    disposer.listen(input, 'input', () => {\n      const handle = beginTransaction(property);\n      if (!handle) return;\n      // Get current suffix from container to preserve unit\n      const suffix = field.container?.getSuffixText() ?? null;\n      handle.set(normalize(input.value, suffix));\n    });\n\n    disposer.listen(input, 'blur', () => {\n      commitTransaction(property);\n      syncAllFields();\n    });\n\n    disposer.listen(input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction(property);\n        syncAllFields();\n        input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction(property);\n        syncField(property, true);\n      }\n    });\n  }\n\n  // ---------------------------------------------------------------------------\n  // Wire font-family (preset select + custom input)\n  // ---------------------------------------------------------------------------\n  function wireFontFamily(): void {\n    const field = fields['font-family'];\n    if (field.kind !== 'font-family') return;\n\n    const { select, custom, controlsContainer } = field;\n\n    const updateCustomVisibility = () => {\n      const isCustom = select.value === FONT_FAMILY_CUSTOM_VALUE;\n      custom.root.style.display = isCustom ? '' : 'none';\n      custom.input.disabled = !isCustom;\n      if (isCustom) custom.input.focus();\n    };\n\n    const previewSelect = () => {\n      updateCustomVisibility();\n      if (select.value === FONT_FAMILY_CUSTOM_VALUE) return;\n      const handle = beginTransaction('font-family');\n      if (handle) handle.set(select.value);\n    };\n\n    disposer.listen(select, 'input', previewSelect);\n    disposer.listen(select, 'change', previewSelect);\n\n    disposer.listen(custom.input, 'input', () => {\n      const handle = beginTransaction('font-family');\n      if (handle) handle.set(custom.input.value.trim());\n    });\n\n    // Commit when focus leaves the whole font-family control\n    disposer.listen(controlsContainer, 'focusout', (e: FocusEvent) => {\n      const next = e.relatedTarget;\n      if (next instanceof Node && controlsContainer.contains(next)) return;\n      commitTransaction('font-family');\n      syncAllFields();\n    });\n\n    disposer.listen(select, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction('font-family');\n        syncAllFields();\n        select.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction('font-family');\n        syncField('font-family', true);\n      }\n    });\n\n    disposer.listen(custom.input, 'keydown', (e: KeyboardEvent) => {\n      if (e.key === 'Enter') {\n        e.preventDefault();\n        commitTransaction('font-family');\n        syncAllFields();\n        custom.input.blur();\n      } else if (e.key === 'Escape') {\n        e.preventDefault();\n        rollbackTransaction('font-family');\n        syncField('font-family', true);\n      }\n    });\n  }\n\n  // Wire standard inputs/selects (color field is wired via its own callbacks)\n  // Note: text-align and vertical-align are now handled by IconButtonGroup with onChange callbacks\n  wireFontFamily();\n  wireInput('font-size', combineLengthValue);\n  wireSelect('font-weight');\n  // line-height is special: can be unitless (like 1.5) or with unit (like 24px)\n  wireInput('line-height', (v, suffix) => {\n    const trimmed = v.trim();\n    if (!trimmed) return '';\n    // If user typed a unit explicitly (like \"24px\"), use as-is\n    if (/[a-zA-Z%]/.test(trimmed)) return trimmed;\n    // For pure numbers, append suffix if exists, otherwise keep unitless\n    return suffix ? `${trimmed}${suffix}` : trimmed;\n  });\n  wireInput('letter-spacing', combineLengthValue);\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n    if (element !== currentTarget) commitAllTransactions();\n    currentTarget = element;\n\n    // Infer text color type from element styles\n    if (element && element.isConnected) {\n      currentTextColorType = inferTextColorType(element);\n    } else {\n      currentTextColorType = 'solid';\n    }\n    textColorTypeSelect.value = currentTextColorType;\n    updateTextColorTypeVisibility();\n\n    // Update gradient control target\n    textGradientControl.setTarget(element);\n    syncAllFields();\n    // Token picker target is now managed by ColorField internally via getTokenTarget callback\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n\n    // Re-infer text color type from element to handle external changes (CSS panel, Undo/Redo)\n    const target = currentTarget;\n    if (target && target.isConnected) {\n      const inferredType = inferTextColorType(target);\n      if (inferredType !== currentTextColorType) {\n        currentTextColorType = inferredType;\n        textColorTypeSelect.value = inferredType;\n      }\n    }\n\n    textGradientControl.refresh();\n    syncAllFields();\n  }\n\n  function dispose(): void {\n    commitAllTransactions();\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  syncAllFields();\n\n  return { setTarget, refresh, dispose };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/css-defaults.ts",
    "content": "/**\n * CSS Defaults Provider\n *\n * Computes baseline (browser-default) computed style values for element tag names.\n * Used by the CSS panel to hide active declarations that match defaults.\n *\n * Isolation strategy:\n * - Mount a hidden host element with `all: initial` into the document.\n * - Attach an isolated ShadowRoot and insert probe elements (one per tag name).\n * - Page author styles do not cross the shadow boundary, so probe values reflect UA defaults.\n */\n\nexport interface CssDefaultsProvider {\n  /** Precompute/cache baseline values for a tag + set of properties. */\n  ensureBaselineValues(tagName: string, properties: readonly string[]): void;\n  /** Get baseline computed value for a tag + property (cached). */\n  getBaselineValue(tagName: string, property: string): string;\n  /** Cleanup DOM and caches. */\n  dispose(): void;\n}\n\ninterface ProbeRoot {\n  host: HTMLDivElement;\n  shadow: ShadowRoot;\n  container: HTMLDivElement;\n}\n\nfunction normalizeTagName(tagName: string): string {\n  return String(tagName ?? '')\n    .trim()\n    .toLowerCase();\n}\n\nfunction normalizePropertyName(property: string): string {\n  return String(property ?? '').trim();\n}\n\nexport function createCssDefaultsProvider(): CssDefaultsProvider {\n  let disposed = false;\n  let probeRoot: ProbeRoot | null = null;\n\n  const probeByTag = new Map<string, Element>();\n  const cacheByTag = new Map<string, Map<string, string>>();\n\n  function ensureProbeRoot(): ProbeRoot | null {\n    if (disposed) return null;\n    if (typeof document === 'undefined') return null;\n\n    if (probeRoot?.host?.isConnected) return probeRoot;\n\n    const mountPoint = document.documentElement ?? document.body;\n    if (!mountPoint) return null;\n\n    const host = document.createElement('div');\n    host.setAttribute('aria-hidden', 'true');\n    // Use fixed size to avoid layout-dependent property issues\n    // all: initial resets inherited styles, fixed positioning takes out of flow\n    host.style.cssText =\n      'all: initial;' +\n      'display: block;' +\n      'position: fixed;' +\n      'left: -100000px;' +\n      'top: 0;' +\n      'width: 100px;' +\n      'height: 100px;' +\n      'overflow: hidden;' +\n      'pointer-events: none;' +\n      'contain: layout style paint;' +\n      'z-index: -1;' +\n      'visibility: hidden;';\n\n    const shadow = host.attachShadow({ mode: 'open' });\n\n    const container = document.createElement('div');\n    container.style.cssText = 'all: initial; display: block;';\n    shadow.append(container);\n\n    mountPoint.append(host);\n    probeRoot = { host, shadow, container };\n    return probeRoot;\n  }\n\n  function ensureProbeElement(tagName: string): Element | null {\n    const tag = normalizeTagName(tagName);\n    if (!tag) return null;\n\n    const existing = probeByTag.get(tag);\n    if (existing?.isConnected) return existing;\n\n    const root = ensureProbeRoot();\n    if (!root) return null;\n\n    let probe: Element;\n    try {\n      probe = document.createElement(tag);\n    } catch {\n      probe = document.createElement('div');\n    }\n\n    root.container.append(probe);\n    probeByTag.set(tag, probe);\n    return probe;\n  }\n\n  function ensureBaselineValues(tagName: string, properties: readonly string[]): void {\n    const tag = normalizeTagName(tagName);\n    if (!tag) return;\n\n    const list = (properties ?? []).map((p) => normalizePropertyName(p)).filter(Boolean);\n    if (list.length === 0) return;\n\n    const perTag = cacheByTag.get(tag) ?? new Map<string, string>();\n    if (!cacheByTag.has(tag)) cacheByTag.set(tag, perTag);\n\n    const missing: string[] = [];\n    for (const prop of list) {\n      if (!perTag.has(prop)) missing.push(prop);\n    }\n    if (missing.length === 0) return;\n\n    const probe = ensureProbeElement(tag);\n    if (!probe) return;\n\n    let computed: CSSStyleDeclaration | null = null;\n    try {\n      computed = window.getComputedStyle(probe);\n    } catch {\n      computed = null;\n    }\n\n    if (!computed) {\n      for (const prop of missing) perTag.set(prop, '');\n      return;\n    }\n\n    for (const prop of missing) {\n      let value = '';\n      try {\n        value = String(computed.getPropertyValue(prop) ?? '').trim();\n      } catch {\n        value = '';\n      }\n      perTag.set(prop, value);\n    }\n  }\n\n  function getBaselineValue(tagName: string, property: string): string {\n    const tag = normalizeTagName(tagName);\n    const prop = normalizePropertyName(property);\n    if (!tag || !prop) return '';\n\n    ensureBaselineValues(tag, [prop]);\n    return cacheByTag.get(tag)?.get(prop) ?? '';\n  }\n\n  function dispose(): void {\n    disposed = true;\n\n    try {\n      probeRoot?.host?.remove();\n    } catch {\n      // Best-effort\n    }\n\n    probeRoot = null;\n    probeByTag.clear();\n    cacheByTag.clear();\n  }\n\n  return {\n    ensureBaselineValues,\n    getBaselineValue,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/css-panel.ts",
    "content": "/**\n * CSS Panel (Phase 4.6 + 4.7)\n *\n * Displays CSS rules and their sources for the selected element.\n * Similar to Chrome DevTools Styles panel.\n *\n * Features:\n * - Shows inline styles, matched CSS rules, and inherited styles\n * - Displays selector, specificity, and source file\n * - Shows which declarations are active vs overridden (strikethrough)\n * - Collapsible sections for inherited rules\n * - Supports Shadow DOM stylesheets\n * - Class editing with chips UI (Phase 4.7)\n */\n\nimport { Disposer } from '../../utils/disposables';\nimport type { TransactionManager } from '../../core/transaction-manager';\nimport type { DesignControl } from './types';\nimport { createClassEditor, MAX_SUGGESTION_CACHE, type ClassEditor } from './class-editor';\nimport { createCssDefaultsProvider, type CssDefaultsProvider } from './css-defaults';\nimport {\n  collectCssPanelSnapshot,\n  type CssPanelSnapshot,\n  type CssSectionView,\n  type CssRuleView,\n  type CssDeclView,\n} from '../../core/cssom-styles-collector';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface CssPanelOptions {\n  /** Container element to mount the panel */\n  container: HTMLElement;\n  /** TransactionManager for class edits (Phase 4.7) */\n  transactionManager?: TransactionManager;\n  /** Notify parent that class list changed (e.g., refresh header label) */\n  onClassChange?: () => void;\n}\n\n/** Extended interface for CSS panel with visibility control */\nexport interface CssPanel extends DesignControl {\n  /** Notify the panel that it is now visible/hidden */\n  setVisible(visible: boolean): void;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Format specificity as a human-readable string: (i, a, b, c)\n */\nfunction formatSpecificity(spec: readonly [number, number, number, number] | undefined): string {\n  if (!spec) return '';\n  return `(${spec[0]}, ${spec[1]}, ${spec[2]}, ${spec[3]})`;\n}\n\n/**\n * Read class list from element (compatible with SVG elements)\n */\nfunction readElementClasses(element: Element): string[] {\n  try {\n    const list = (element as HTMLElement).classList;\n    if (list && typeof list[Symbol.iterator] === 'function') {\n      return Array.from(list).filter(Boolean);\n    }\n  } catch {\n    // Fall back to attribute parsing\n  }\n\n  try {\n    const raw = element.getAttribute('class') ?? '';\n    return raw\n      .split(/\\s+/)\n      .map((t) => t.trim())\n      .filter(Boolean);\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Apply class list to element (compatible with SVG elements)\n */\nfunction applyClassListToElement(element: Element, classes: readonly string[]): void {\n  const seen = new Set<string>();\n  const normalized: string[] = [];\n\n  for (const raw of classes ?? []) {\n    const token = String(raw ?? '').trim();\n    if (!token) continue;\n    if (seen.has(token)) continue;\n    seen.add(token);\n    normalized.push(token);\n  }\n\n  const value = normalized.join(' ').trim();\n  try {\n    if (value) {\n      element.setAttribute('class', value);\n    } else {\n      element.removeAttribute('class');\n    }\n  } catch {\n    // Best-effort\n  }\n}\n\n// =============================================================================\n// Class Suggestions (Phase 4.7)\n// =============================================================================\n\n/**\n * Unescape CSS identifier (handles hex escapes and simple backslash escapes)\n *\n * Examples:\n * - 'sm\\\\:bg-red-500' -> 'sm:bg-red-500'\n * - '\\\\31 23' -> '123'\n */\nfunction unescapeCssIdentifier(input: string): string {\n  const s = String(input ?? '');\n  let out = '';\n\n  for (let i = 0; i < s.length; i++) {\n    const ch = s[i]!;\n    if (ch !== '\\\\') {\n      out += ch;\n      continue;\n    }\n\n    // Trailing backslash - ignore\n    if (i >= s.length - 1) break;\n\n    let j = i + 1;\n    let hex = '';\n\n    // Collect hex digits (max 6)\n    while (j < s.length && hex.length < 6 && /[0-9a-fA-F]/.test(s[j]!)) {\n      hex += s[j]!;\n      j += 1;\n    }\n\n    if (hex.length > 0) {\n      const codePoint = Number.parseInt(hex, 16);\n      // Validate code point is within Unicode range\n      if (Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff) {\n        out += String.fromCodePoint(codePoint);\n        // Consume optional whitespace after hex escape\n        if (j < s.length && /\\s/.test(s[j]!)) j += 1;\n        i = j - 1;\n        continue;\n      }\n    }\n\n    // Simple escape: take the next character literally\n    out += s[j] ?? '';\n    i = j;\n  }\n\n  return out;\n}\n\n/**\n * Consume a CSS class identifier starting at `start` position\n * Returns the end position (exclusive)\n */\nfunction consumeClassIdent(selector: string, start: number): number {\n  for (let i = start; i < selector.length; i++) {\n    const ch = selector[i]!;\n\n    if (ch === '\\\\') {\n      // Skip escape sequence\n      const next = i + 1;\n      if (next >= selector.length) {\n        // Trailing backslash - end of ident\n        return selector.length;\n      }\n\n      // Check if next char is hex digit\n      if (/[0-9a-fA-F]/.test(selector[next]!)) {\n        // Hex escape: consume up to 6 hex digits\n        let j = next;\n        let hexCount = 0;\n        while (j < selector.length && hexCount < 6 && /[0-9a-fA-F]/.test(selector[j]!)) {\n          j += 1;\n          hexCount += 1;\n        }\n        // Consume optional whitespace after hex escape\n        if (j < selector.length && /\\s/.test(selector[j]!)) {\n          j += 1;\n        }\n        i = j - 1;\n      } else {\n        // Simple escape: skip the backslash and next character\n        // This handles \\: \\/ \\. etc.\n        i = next;\n      }\n      continue;\n    }\n\n    // Terminators for ident in a selector context\n    if (\n      /\\s/.test(ch) ||\n      ch === '.' ||\n      ch === '#' ||\n      ch === ':' ||\n      ch === '[' ||\n      ch === ']' ||\n      ch === '(' ||\n      ch === ')' ||\n      ch === ',' ||\n      ch === '>' ||\n      ch === '+' ||\n      ch === '~' ||\n      ch === '|'\n    ) {\n      return i;\n    }\n  }\n\n  return selector.length;\n}\n\n/**\n * Extract class names from a CSS selector string\n * Handles CSS escapes (e.g., Tailwind's `sm\\:bg-red-500`)\n */\nfunction extractClassNamesFromSelector(selector: string): string[] {\n  const out: string[] = [];\n  const s = String(selector ?? '');\n\n  let bracketDepth = 0;\n  let quote: \"'\" | '\"' | null = null;\n\n  for (let i = 0; i < s.length; i++) {\n    const ch = s[i]!;\n\n    // Track quoted strings (mostly inside attribute selectors)\n    if (quote) {\n      if (ch === '\\\\') {\n        i += 1;\n        continue;\n      }\n      if (ch === quote) quote = null;\n      continue;\n    }\n\n    if (ch === '\"' || ch === \"'\") {\n      quote = ch;\n      continue;\n    }\n\n    if (ch === '[') {\n      bracketDepth += 1;\n      continue;\n    }\n    if (ch === ']') {\n      bracketDepth = Math.max(0, bracketDepth - 1);\n      continue;\n    }\n\n    // Ignore class-like tokens inside attribute selector bodies\n    if (bracketDepth > 0) continue;\n\n    // Look for class selector start\n    if (ch !== '.') continue;\n\n    const start = i + 1;\n    if (start >= s.length) continue;\n\n    const end = consumeClassIdent(s, start);\n    const raw = s.slice(start, end);\n    const cls = unescapeCssIdentifier(raw).trim();\n    if (cls) out.push(cls);\n    i = end - 1;\n  }\n\n  return out;\n}\n\n/**\n * Collect class suggestions from CSS snapshot\n * Extracts class names from matched selectors\n */\nfunction collectClassSuggestions(snapshot: CssPanelSnapshot): string[] {\n  const out: string[] = [];\n  const seen = new Set<string>();\n\n  for (const section of snapshot.sections) {\n    for (const rule of section.rules) {\n      const selector = rule.matchedSelector ?? rule.selector;\n      for (const cls of extractClassNamesFromSelector(selector)) {\n        if (!cls) continue;\n        if (seen.has(cls)) continue;\n        seen.add(cls);\n        out.push(cls);\n        if (out.length >= MAX_SUGGESTION_CACHE) return out;\n      }\n    }\n  }\n\n  return out;\n}\n\n/**\n * Check if a declaration is a design token (CSS custom property)\n */\nfunction isDesignToken(declName: string): boolean {\n  return declName.trim().startsWith('--');\n}\n\n/**\n * Global/universal selectors to filter out (only show element-specific styles)\n */\nconst GLOBAL_SELECTORS = new Set(['*', 'html', 'body', ':root', ':where(*)', ':is(*)']);\n\n/**\n * Check if a selector is a global/universal selector that should be filtered\n */\nfunction isGlobalSelector(selector: string): boolean {\n  const normalized = selector.trim().toLowerCase();\n  if (GLOBAL_SELECTORS.has(normalized)) return true;\n  // Also filter selectors that are just combinations of global selectors\n  // e.g., \"html *\", \"body *\", \"*, html\", \":root *\"\n  const parts = normalized.split(/\\s*,\\s*/);\n  return parts.every((part) => {\n    const tokens = part.split(/\\s+/).filter(Boolean);\n    return tokens.every((t) => GLOBAL_SELECTORS.has(t) || t === '>' || t === '+' || t === '~');\n  });\n}\n\n// =============================================================================\n// Default Value Filtering\n// =============================================================================\n\ninterface DefaultValueFilterContext {\n  defaults: CssDefaultsProvider;\n  tagName: string;\n  computedStyle: CSSStyleDeclaration | null;\n}\n\n/**\n * Get the longhand properties affected by a declaration\n */\nfunction getDeclAffectedProperties(decl: CssDeclView): readonly string[] {\n  if (Array.isArray((decl as CssDeclView & { affects?: string[] }).affects)) {\n    const affects = (decl as CssDeclView & { affects?: string[] }).affects;\n    if (affects && affects.length > 0) return affects;\n  }\n  return [decl.name];\n}\n\n/**\n * Collect all properties that need baseline values for comparison\n */\nfunction collectBaselineProperties(snapshot: CssPanelSnapshot): string[] {\n  const out = new Set<string>();\n\n  for (const section of snapshot.sections) {\n    for (const rule of section.rules) {\n      for (const decl of rule.decls) {\n        if (decl.status !== 'active') continue;\n        if (isDesignToken(decl.name)) continue;\n\n        for (const prop of getDeclAffectedProperties(decl)) {\n          const name = String(prop ?? '').trim();\n          if (name) out.add(name);\n        }\n      }\n    }\n  }\n\n  return Array.from(out);\n}\n\n/**\n * Check if an active declaration's computed value matches browser default\n */\nfunction isDefaultValueDecl(decl: CssDeclView, ctx: DefaultValueFilterContext): boolean {\n  if (decl.status !== 'active') return false;\n  if (!ctx.computedStyle) return false;\n\n  const props = getDeclAffectedProperties(decl);\n  let hasComparable = false;\n\n  for (const propRaw of props) {\n    const prop = String(propRaw ?? '').trim();\n    if (!prop) continue;\n\n    let computed = '';\n    try {\n      computed = String(ctx.computedStyle.getPropertyValue(prop) ?? '').trim();\n    } catch {\n      computed = '';\n    }\n\n    const baseline = ctx.defaults.getBaselineValue(ctx.tagName, prop);\n    if (computed || baseline) hasComparable = true;\n    if (computed !== baseline) return false;\n  }\n\n  return hasComparable;\n}\n\n/**\n * Check if a declaration should be rendered (after all filters)\n */\nfunction shouldRenderDecl(decl: CssDeclView, ctx: DefaultValueFilterContext): boolean {\n  if (isDesignToken(decl.name)) return false;\n  if (isDefaultValueDecl(decl, ctx)) return false;\n  return true;\n}\n\n/**\n * Check if global selector filtering should apply for this element\n * Don't filter global selectors when the selected element is html/body itself\n */\nfunction shouldFilterGlobalSelector(selector: string, tagName: string): boolean {\n  if (!isGlobalSelector(selector)) return false;\n  // If selected element is html/body/:root, don't filter their matching selectors\n  const tag = tagName.toLowerCase();\n  if (tag === 'html' || tag === 'body') return false;\n  return true;\n}\n\n/**\n * Create a rule block element\n * Returns null if all declarations are filtered out (design tokens) or selector is global\n */\nfunction createRuleBlock(\n  rule: CssRuleView,\n  disposer: Disposer,\n  ctx: DefaultValueFilterContext,\n): HTMLElement | null {\n  // Filter out global selectors (*, html, body, etc.) - only keep element-specific styles\n  const matchedSelector = rule.matchedSelector ?? rule.selector;\n  if (rule.origin === 'rule' && shouldFilterGlobalSelector(matchedSelector, ctx.tagName)) {\n    return null;\n  }\n\n  // Filter out design tokens and declarations matching browser defaults\n  const visibleDecls = rule.decls.filter((decl) => shouldRenderDecl(decl, ctx));\n  if (visibleDecls.length === 0) return null;\n\n  const block = document.createElement('div');\n  block.className = 'we-css-rule';\n  block.dataset.ruleId = rule.id;\n  block.dataset.origin = rule.origin;\n\n  // Rule header: selector and source\n  const header = document.createElement('div');\n  header.className = 'we-css-rule-header';\n\n  const selector = document.createElement('span');\n  selector.className = 'we-css-rule-selector';\n  selector.textContent = rule.matchedSelector ?? rule.selector;\n  selector.title = rule.selector;\n\n  header.append(selector);\n\n  // Source info (file name or \"element.style\")\n  if (rule.source) {\n    const source = document.createElement('span');\n    source.className = 'we-css-rule-source';\n    source.textContent = rule.source.label;\n    if (rule.source.url) {\n      source.title = rule.source.url;\n    }\n    header.append(source);\n  }\n\n  // Specificity badge (optional, shown on hover or always for rules)\n  if (rule.origin === 'rule' && rule.specificity) {\n    const specBadge = document.createElement('span');\n    specBadge.className = 'we-css-rule-spec';\n    specBadge.textContent = formatSpecificity(rule.specificity);\n    specBadge.title = 'Specificity (inline, id, class, type)';\n    header.append(specBadge);\n  }\n\n  block.append(header);\n\n  // Declarations list (filtered)\n  const declsContainer = document.createElement('div');\n  declsContainer.className = 'we-css-decls';\n\n  for (const decl of visibleDecls) {\n    const declEl = createDeclaration(decl);\n    declsContainer.append(declEl);\n  }\n\n  block.append(declsContainer);\n\n  return block;\n}\n\n/**\n * Create a declaration element\n */\nfunction createDeclaration(decl: CssDeclView): HTMLElement {\n  const el = document.createElement('div');\n  el.className = 'we-css-decl';\n  el.dataset.status = decl.status;\n\n  // Property name\n  const name = document.createElement('span');\n  name.className = 'we-css-decl-name';\n  name.textContent = decl.name;\n\n  // Colon\n  const colon = document.createElement('span');\n  colon.className = 'we-css-decl-colon';\n  colon.textContent = ': ';\n\n  // Value container (for grid layout with !important outside truncated area)\n  const valueContainer = document.createElement('span');\n  valueContainer.className = 'we-css-decl-value-container';\n\n  // Property value\n  const value = document.createElement('span');\n  value.className = 'we-css-decl-value';\n  value.textContent = decl.value;\n  valueContainer.append(value);\n\n  // Important badge (separate element to avoid truncation)\n  if (decl.important) {\n    const imp = document.createElement('span');\n    imp.className = 'we-css-decl-important';\n    imp.textContent = '!important';\n    valueContainer.append(imp);\n  }\n\n  // Semicolon\n  const semi = document.createElement('span');\n  semi.className = 'we-css-decl-semi';\n  semi.textContent = ';';\n\n  el.append(name, colon, valueContainer, semi);\n\n  return el;\n}\n\n/**\n * Check if a rule has any renderable declarations (after filtering)\n */\nfunction hasRenderableRule(rule: CssRuleView, ctx: DefaultValueFilterContext): boolean {\n  // Filter out global selectors (unless selected element is html/body)\n  const matchedSelector = rule.matchedSelector ?? rule.selector;\n  if (rule.origin === 'rule' && shouldFilterGlobalSelector(matchedSelector, ctx.tagName)) {\n    return false;\n  }\n  // Check if any declarations should be rendered\n  return rule.decls.some((decl) => shouldRenderDecl(decl, ctx));\n}\n\n/**\n * Check if a section has any renderable rules (after filtering)\n */\nfunction hasRenderableDecls(section: CssSectionView, ctx: DefaultValueFilterContext): boolean {\n  return section.rules.some((rule) => hasRenderableRule(rule, ctx));\n}\n\n/**\n * Create a section element (inline, matched, or inherited)\n * Returns null if all rules are filtered out\n */\nfunction createSection(\n  section: CssSectionView,\n  disposer: Disposer,\n  ctx: DefaultValueFilterContext,\n): HTMLElement | null {\n  // Skip sections with no renderable declarations after filtering\n  if (!hasRenderableDecls(section, ctx)) return null;\n\n  const el = document.createElement('div');\n  el.className = 'we-css-section';\n  el.dataset.kind = section.kind;\n\n  // Section header (for inherited sections)\n  if (section.kind === 'inherited') {\n    const header = document.createElement('div');\n    header.className = 'we-css-section-header';\n\n    const title = document.createElement('span');\n    title.className = 'we-css-section-title';\n    title.textContent = section.title;\n\n    header.append(title);\n    el.append(header);\n  }\n\n  // Rules\n  const rulesContainer = document.createElement('div');\n  rulesContainer.className = 'we-css-section-rules';\n\n  for (const rule of section.rules) {\n    const ruleEl = createRuleBlock(rule, disposer, ctx);\n    if (ruleEl) rulesContainer.append(ruleEl);\n  }\n\n  // Defensive: if all rules were filtered out, return null\n  if (rulesContainer.childElementCount === 0) return null;\n\n  el.append(rulesContainer);\n\n  return el;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\n/**\n * Create a CSS Panel component\n */\nexport function createCssPanel(options: CssPanelOptions): CssPanel {\n  const { container, transactionManager, onClassChange } = options;\n  const disposer = new Disposer();\n\n  // CSS defaults provider for filtering browser default values\n  const defaultsProvider = createCssDefaultsProvider();\n  disposer.add(() => defaultsProvider.dispose());\n\n  // State\n  let currentTarget: Element | null = null;\n  let snapshot: CssPanelSnapshot | null = null;\n  let classSuggestions: string[] = [];\n  let classEditor: ClassEditor | null = null;\n  let isVisible = false;\n  let needsRefresh = false;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-css-panel';\n\n  // Class editor mount point (Phase 4.7)\n  const classEditorMount = document.createElement('div');\n  classEditorMount.className = 'we-css-class-editor-mount';\n\n  // Stats/info bar\n  const infoBar = document.createElement('div');\n  infoBar.className = 'we-css-info';\n  infoBar.hidden = true;\n\n  // Empty state\n  const emptyState = document.createElement('div');\n  emptyState.className = 'we-css-empty';\n  emptyState.textContent = 'No styles';\n\n  // Warnings container\n  const warningsContainer = document.createElement('div');\n  warningsContainer.className = 'we-css-warnings';\n  warningsContainer.hidden = true;\n\n  // Sections container\n  const sectionsContainer = document.createElement('div');\n  sectionsContainer.className = 'we-css-sections';\n\n  // Create ClassEditor (Phase 4.7)\n  classEditor = createClassEditor({\n    container: classEditorMount,\n    onClassChange: (nextClasses) => {\n      const target = currentTarget;\n      if (!target || !target.isConnected) return;\n\n      const beforeClasses = readElementClasses(target);\n\n      if (transactionManager) {\n        // Use transaction manager for undo/redo support\n        transactionManager.recordClass(target, beforeClasses, nextClasses);\n      } else {\n        // Fallback: apply directly without transaction\n        applyClassListToElement(target, nextClasses);\n      }\n\n      // Sync UI with actual DOM state (in case normalized differently)\n      classEditor?.setClasses(readElementClasses(target));\n\n      // Notify parent (e.g., to update header label)\n      onClassChange?.();\n\n      // Refresh CSS rules (class change affects matched rules)\n      collectAndRender();\n    },\n    getSuggestions: () => classSuggestions,\n  });\n\n  root.append(classEditorMount, infoBar, warningsContainer, emptyState, sectionsContainer);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Render Functions\n  // ==========================================================================\n\n  function renderSnapshot(): void {\n    // Clear previous content\n    sectionsContainer.innerHTML = '';\n    warningsContainer.innerHTML = '';\n\n    if (!snapshot) {\n      emptyState.hidden = false;\n      emptyState.textContent = 'Select an element to view styles';\n      infoBar.hidden = true;\n      warningsContainer.hidden = true;\n      return;\n    }\n\n    // Build filter context for default value comparison\n    const tagName = currentTarget ? currentTarget.tagName.toLowerCase() : '';\n    let computedStyle: CSSStyleDeclaration | null = null;\n    try {\n      if (currentTarget?.isConnected) {\n        computedStyle = window.getComputedStyle(currentTarget);\n      }\n    } catch {\n      computedStyle = null;\n    }\n\n    const filterCtx: DefaultValueFilterContext = {\n      defaults: defaultsProvider,\n      tagName,\n      computedStyle,\n    };\n\n    // Pre-cache baseline values for all relevant properties\n    if (computedStyle && tagName) {\n      defaultsProvider.ensureBaselineValues(tagName, collectBaselineProperties(snapshot));\n    }\n\n    // Check if there are any renderable rules (after all filters)\n    const hasRules = snapshot.sections.some((section) => hasRenderableDecls(section, filterCtx));\n\n    if (!hasRules) {\n      emptyState.hidden = false;\n      emptyState.textContent = 'No CSS rules matched';\n      infoBar.hidden = true;\n    } else {\n      emptyState.hidden = true;\n\n      // Info bar\n      const { stats } = snapshot;\n      infoBar.textContent = `${stats.matchedRules} rules matched (${stats.styleSheets} stylesheets, ${stats.rulesScanned} rules scanned)`;\n      infoBar.hidden = false;\n    }\n\n    // Render warnings (if any)\n    if (snapshot.warnings.length > 0) {\n      warningsContainer.hidden = false;\n      for (const warning of snapshot.warnings.slice(0, 5)) {\n        const warningEl = document.createElement('div');\n        warningEl.className = 'we-css-warning';\n        warningEl.textContent = warning;\n        warningsContainer.append(warningEl);\n      }\n      if (snapshot.warnings.length > 5) {\n        const more = document.createElement('div');\n        more.className = 'we-css-warning-more';\n        more.textContent = `...and ${snapshot.warnings.length - 5} more warnings`;\n        warningsContainer.append(more);\n      }\n    } else {\n      warningsContainer.hidden = true;\n    }\n\n    // Render sections\n    for (const section of snapshot.sections) {\n      const sectionEl = createSection(section, disposer, filterCtx);\n      if (sectionEl) sectionsContainer.append(sectionEl);\n    }\n  }\n\n  function collectAndRender(): void {\n    // Only collect if visible (performance optimization)\n    if (!isVisible) {\n      needsRefresh = true;\n      return;\n    }\n\n    if (!currentTarget || !currentTarget.isConnected) {\n      snapshot = null;\n      classSuggestions = [];\n      classEditor?.setTarget(null);\n      renderSnapshot();\n      return;\n    }\n\n    // Collect snapshot (only direct styles, no inherited)\n    snapshot = collectCssPanelSnapshot(currentTarget, {\n      maxInheritanceDepth: 0,\n    });\n\n    // Update class suggestions cache (Phase 4.7)\n    classSuggestions = snapshot ? collectClassSuggestions(snapshot) : [];\n\n    renderSnapshot();\n    needsRefresh = false;\n  }\n\n  // ==========================================================================\n  // Public API (DesignControl interface)\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    currentTarget = element;\n    classEditor?.setTarget(element);\n    collectAndRender();\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    classEditor?.refresh();\n    collectAndRender();\n  }\n\n  function setVisible(visible: boolean): void {\n    if (disposer.isDisposed) return;\n\n    isVisible = visible;\n\n    // If becoming visible and needs refresh, collect now\n    if (visible && needsRefresh) {\n      collectAndRender();\n    }\n  }\n\n  function dispose(): void {\n    currentTarget = null;\n    snapshot = null;\n    classEditor?.dispose();\n    classEditor = null;\n    classSuggestions = [];\n    isVisible = false;\n    needsRefresh = false;\n    disposer.dispose();\n  }\n\n  // Initial state\n  renderSnapshot();\n\n  return {\n    setTarget,\n    refresh,\n    setVisible,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/index.ts",
    "content": "/**\n * Property Panel Module\n *\n * Exports the property panel component and its types.\n */\n\nexport { createPropertyPanel } from './property-panel';\nexport type { PropertyPanel, PropertyPanelOptions, PropertyPanelTab, DesignControl } from './types';\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/property-panel.ts",
    "content": "/**\n * Property Panel\n *\n * Right-side panel displaying Design controls, CSS styles, Props, and DOM tree for selected elements.\n *\n * Features:\n * - Tab switching between Design, CSS, Props, and DOM views\n * - Collapsible control groups (Position, Layout, Size, Spacing, Typography, Appearance, Border, Background, Effects)\n * - CSS panel showing matched rules and inheritance (Phase 4.6)\n * - Props panel for React/Vue component props editing (Phase 7.3)\n * - Empty state when no element is selected\n * - Close button integration\n * - Automatic control initialization and lifecycle management\n */\n\nimport { Disposer } from '../../utils/disposables';\nimport { installFloatingDrag, type FloatingPosition } from '../floating-drag';\nimport { createChevronIcon, createChevronUpIcon, createGripIcon } from '../icons';\nimport type {\n  PropertyPanel,\n  PropertyPanelOptions,\n  PropertyPanelTab,\n  ControlGroup,\n  DesignControl,\n} from './types';\nimport { createSizeControl } from './controls/size-control';\nimport { createSpacingControl } from './controls/spacing-control';\nimport { createPositionControl } from './controls/position-control';\nimport { createLayoutControl } from './controls/layout-control';\nimport { createTypographyControl } from './controls/typography-control';\nimport { createAppearanceControl } from './controls/appearance-control';\nimport { createBorderControl } from './controls/border-control';\nimport { createBackgroundControl } from './controls/background-control';\nimport { createEffectsControl } from './controls/effects-control';\nimport { createComponentsTree, type ComponentsTree } from './components-tree';\nimport { createCssPanel, type CssPanel } from './css-panel';\nimport { createPropsPanel, type PropsPanel } from './props-panel';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Control group configuration */\nconst CONTROL_GROUPS = [\n  { id: 'position', label: 'Position', collapsible: true },\n  { id: 'layout', label: 'Layout', collapsible: true },\n  { id: 'size', label: 'Size', collapsible: true },\n  { id: 'spacing', label: 'Spacing', collapsible: true },\n  { id: 'typography', label: 'Typography', collapsible: true },\n  { id: 'appearance', label: 'Appearance', collapsible: true },\n  { id: 'border', label: 'Border', collapsible: true },\n  { id: 'background', label: 'Background', collapsible: true },\n  { id: 'effects', label: 'Effects', collapsible: false },\n] as const;\n\ntype ControlGroupId = (typeof CONTROL_GROUPS)[number]['id'];\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nlet groupIdSeq = 0;\n\n/**\n * Format element label for display (tag + id/classes)\n */\nfunction formatTargetLabel(element: Element): string {\n  const tag = element.tagName.toLowerCase();\n  const htmlEl = element as HTMLElement;\n  const id = htmlEl.id?.trim();\n\n  if (id) {\n    return `${tag}#${id}`;\n  }\n\n  const classes = Array.from(element.classList ?? []).slice(0, 2);\n  if (classes.length > 0) {\n    return `${tag}.${classes.join('.')}`;\n  }\n\n  return tag;\n}\n\n/**\n * Create a control group (optionally collapsible)\n */\nfunction createControlGroup(\n  groupId: string,\n  label: string,\n  disposer: Disposer,\n  opts?: { collapsible?: boolean },\n): ControlGroup {\n  const uniqueId = `we_group_${groupId}_${++groupIdSeq}`;\n  const collapsible = opts?.collapsible ?? true;\n  let collapsed = false;\n\n  // Group container\n  const root = document.createElement('section');\n  root.className = 'we-group';\n  root.dataset.group = groupId;\n  root.dataset.collapsed = 'false';\n\n  // Header (div wrapper to allow button + actions)\n  const header = document.createElement('div');\n  header.className = 'we-group-header';\n\n  const labelSpan = document.createElement('span');\n  labelSpan.textContent = label;\n\n  // Toggle element (button when collapsible; static label otherwise)\n  let toggleEl: HTMLButtonElement | HTMLDivElement;\n\n  if (collapsible) {\n    const toggleBtn = document.createElement('button');\n    toggleBtn.type = 'button';\n    toggleBtn.className = 'we-group-toggle';\n    toggleBtn.setAttribute('aria-expanded', 'true');\n    toggleBtn.setAttribute('aria-controls', uniqueId);\n    toggleBtn.append(labelSpan, createChevronIcon());\n\n    // Toggle handler\n    disposer.listen(toggleBtn, 'click', (event) => {\n      event.preventDefault();\n      toggle();\n    });\n\n    toggleEl = toggleBtn;\n  } else {\n    const staticLabel = document.createElement('div');\n    staticLabel.className = 'we-group-toggle we-group-toggle--static';\n    staticLabel.append(labelSpan);\n    toggleEl = staticLabel;\n  }\n\n  // Actions container (for add buttons, etc.)\n  const headerActions = document.createElement('div');\n  headerActions.className = 'we-group-header-actions';\n\n  header.append(toggleEl, headerActions);\n\n  // Body container\n  const body = document.createElement('div');\n  body.className = 'we-group-body';\n  body.id = uniqueId;\n\n  root.append(header, body);\n\n  function setCollapsed(value: boolean): void {\n    if (!collapsible) return;\n    collapsed = value;\n    root.dataset.collapsed = collapsed ? 'true' : 'false';\n    (toggleEl as HTMLButtonElement).setAttribute('aria-expanded', collapsed ? 'false' : 'true');\n  }\n\n  function isCollapsed(): boolean {\n    return collapsed;\n  }\n\n  function toggle(): void {\n    if (!collapsible) return;\n    setCollapsed(!collapsed);\n  }\n\n  return {\n    root,\n    body,\n    headerActions,\n    setCollapsed,\n    isCollapsed,\n    toggle,\n  };\n}\n\n// =============================================================================\n// Property Panel Implementation\n// =============================================================================\n\n/**\n * Create the Property Panel component\n */\nexport function createPropertyPanel(options: PropertyPanelOptions): PropertyPanel {\n  const disposer = new Disposer();\n\n  // State\n  let currentTarget: Element | null = null;\n  let currentTab: PropertyPanelTab = options.defaultTab ?? 'design';\n  let minimized = false;\n  let floatingPosition: FloatingPosition | null = options.initialPosition ?? null;\n  const controlGroups = new Map<ControlGroupId, ControlGroup>();\n  const controls: DesignControl[] = [];\n  let componentsTree: ComponentsTree | null = null;\n  let cssPanel: CssPanel | null = null;\n  let propsPanel: PropsPanel | null = null;\n\n  // References to specific controls for live style sync (Bug 3 fix)\n  let sizeControl: DesignControl | null = null;\n  let positionControl: DesignControl | null = null;\n  let spacingControl: DesignControl | null = null;\n\n  // Live style sync state (MutationObserver for external style changes)\n  let styleObserver: MutationObserver | null = null;\n  let styleObserverTarget: Element | null = null;\n  let styleObserverRafId: number | null = null;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  // Root panel container\n  const root = document.createElement('aside');\n  root.className = 'we-panel we-prop-panel';\n  root.setAttribute('role', 'complementary');\n  root.setAttribute('aria-label', 'Properties');\n  root.dataset.tab = currentTab;\n  root.dataset.empty = 'true';\n  root.dataset.minimized = 'false';\n  root.dataset.dragged = floatingPosition ? 'true' : 'false';\n\n  // Header (symmetric layout: drag | tabs | minimize)\n  const header = document.createElement('header');\n  header.className = 'we-header';\n\n  // Left: Drag handle (grip)\n  const dragHandle = document.createElement('button');\n  dragHandle.type = 'button';\n  dragHandle.className = 'we-drag-handle';\n  dragHandle.setAttribute('aria-label', 'Drag property panel');\n  dragHandle.dataset.tooltip = 'Drag';\n  dragHandle.append(createGripIcon());\n\n  // Target label (hidden, kept for data binding)\n  const targetLabel = document.createElement('div');\n  targetLabel.className = 'we-prop-target';\n  targetLabel.hidden = true;\n\n  // Tab buttons\n  const tabsContainer = document.createElement('div');\n  tabsContainer.className = 'we-prop-tabs';\n  tabsContainer.setAttribute('role', 'tablist');\n  tabsContainer.setAttribute('aria-label', 'Property tabs');\n\n  const designTabBtn = document.createElement('button');\n  designTabBtn.type = 'button';\n  designTabBtn.className = 'we-tab';\n  designTabBtn.setAttribute('role', 'tab');\n  designTabBtn.dataset.tab = 'design';\n  designTabBtn.textContent = 'Design';\n\n  const cssTabBtn = document.createElement('button');\n  cssTabBtn.type = 'button';\n  cssTabBtn.className = 'we-tab';\n  cssTabBtn.setAttribute('role', 'tab');\n  cssTabBtn.dataset.tab = 'css';\n  cssTabBtn.textContent = 'CSS';\n\n  const propsTabBtn = document.createElement('button');\n  propsTabBtn.type = 'button';\n  propsTabBtn.className = 'we-tab';\n  propsTabBtn.setAttribute('role', 'tab');\n  propsTabBtn.dataset.tab = 'props';\n  propsTabBtn.textContent = 'Props';\n\n  const domTabBtn = document.createElement('button');\n  domTabBtn.type = 'button';\n  domTabBtn.className = 'we-tab';\n  domTabBtn.setAttribute('role', 'tab');\n  domTabBtn.dataset.tab = 'dom';\n  domTabBtn.textContent = 'DOM';\n\n  tabsContainer.append(designTabBtn, cssTabBtn, propsTabBtn, domTabBtn);\n\n  // Right: Minimize/expand button with chevron icon\n  const minimizeBtn = document.createElement('button');\n  minimizeBtn.type = 'button';\n  minimizeBtn.className = 'we-icon-btn we-minimize-btn';\n  minimizeBtn.setAttribute('aria-label', 'Minimize property panel');\n  minimizeBtn.dataset.tooltip = 'Minimize';\n  minimizeBtn.append(createChevronUpIcon());\n\n  // Symmetric layout: drag (left) | tabs (center) | minimize (right)\n  header.append(dragHandle, tabsContainer, minimizeBtn, targetLabel);\n\n  // Body container\n  const body = document.createElement('div');\n  body.className = 'we-prop-body';\n\n  // Empty state message\n  const emptyState = document.createElement('div');\n  emptyState.className = 'we-prop-empty';\n  emptyState.textContent = 'Select an element to view and edit its properties.';\n\n  // Design panel (contains control groups)\n  const designPanel = document.createElement('div');\n  designPanel.className = 'we-prop-tab-content';\n  designPanel.dataset.tabContent = 'design';\n\n  // Create control groups\n  for (const { id, label, collapsible } of CONTROL_GROUPS) {\n    const group = createControlGroup(id, label, disposer, { collapsible });\n    controlGroups.set(id, group);\n    designPanel.append(group.root);\n  }\n\n  // CSS panel (Phase 4.6)\n  const cssPanelContainer = document.createElement('div');\n  cssPanelContainer.className = 'we-prop-tab-content';\n  cssPanelContainer.dataset.tabContent = 'css';\n\n  // Props panel (Phase 7.3)\n  const propsPanelContainer = document.createElement('div');\n  propsPanelContainer.className = 'we-prop-tab-content';\n  propsPanelContainer.dataset.tabContent = 'props';\n\n  // DOM panel (Components tree - Phase 3.2)\n  const domPanel = document.createElement('div');\n  domPanel.className = 'we-prop-tab-content';\n  domPanel.dataset.tabContent = 'dom';\n\n  body.append(emptyState, designPanel, cssPanelContainer, propsPanelContainer, domPanel);\n  root.append(header, body);\n\n  // Mount to container\n  options.container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Floating Drag (Panel Position)\n  // ==========================================================================\n\n  const CLAMP_MARGIN_PX = 16;\n\n  function clampToViewport(position: FloatingPosition): FloatingPosition {\n    const rect = root.getBoundingClientRect();\n    const viewportW = window.innerWidth;\n    const viewportH = window.innerHeight;\n\n    const margin = CLAMP_MARGIN_PX;\n    const maxLeft = Math.max(margin, viewportW - margin - rect.width);\n    const maxTop = Math.max(margin, viewportH - margin - rect.height);\n\n    const left = Number.isFinite(position.left) ? position.left : 0;\n    const top = Number.isFinite(position.top) ? position.top : 0;\n\n    return {\n      left: Math.round(Math.min(maxLeft, Math.max(margin, left))),\n      top: Math.round(Math.min(maxTop, Math.max(margin, top))),\n    };\n  }\n\n  function syncFloatingPositionStyles(): void {\n    root.dataset.dragged = floatingPosition ? 'true' : 'false';\n\n    // While minimized, prefer the existing minimized layout (top-right)\n    if (!floatingPosition || minimized) {\n      root.style.left = '';\n      root.style.top = '';\n      root.style.right = '';\n      root.style.bottom = '';\n      return;\n    }\n\n    root.style.left = `${floatingPosition.left}px`;\n    root.style.top = `${floatingPosition.top}px`;\n    root.style.right = 'auto';\n    root.style.bottom = 'auto';\n  }\n\n  function setPosition(position: FloatingPosition | null): void {\n    floatingPosition = position ? clampToViewport(position) : null;\n    syncFloatingPositionStyles();\n    options.onPositionChange?.(floatingPosition);\n  }\n\n  function getPosition(): FloatingPosition | null {\n    return floatingPosition;\n  }\n\n  // Install drag behavior\n  disposer.add(\n    installFloatingDrag({\n      handleEl: dragHandle,\n      targetEl: root,\n      clampMargin: CLAMP_MARGIN_PX,\n      onPositionChange: (pos) => setPosition(pos),\n    }),\n  );\n\n  // Apply initial position (if provided)\n  if (floatingPosition !== null) {\n    setPosition(floatingPosition);\n  } else {\n    syncFloatingPositionStyles();\n  }\n\n  // ==========================================================================\n  // Initialize Controls\n  // ==========================================================================\n\n  /**\n   * Initialize all design controls.\n   * Controls are created once and manage their own lifecycle.\n   */\n  function initializeControls(): void {\n    // Size control (width/height) - save reference for live sync\n    const sizeGroup = controlGroups.get('size');\n    if (sizeGroup) {\n      sizeControl = createSizeControl({\n        container: sizeGroup.body,\n        transactionManager: options.transactionManager,\n      });\n      controls.push(sizeControl);\n    }\n\n    // Spacing control (margin/padding) - save reference for live sync\n    const spacingGroup = controlGroups.get('spacing');\n    if (spacingGroup) {\n      spacingControl = createSpacingControl({\n        container: spacingGroup.body,\n        transactionManager: options.transactionManager,\n      });\n      controls.push(spacingControl);\n    }\n\n    // Position control (position, top/right/bottom/left, z-index) - save reference for live sync\n    const positionGroup = controlGroups.get('position');\n    if (positionGroup) {\n      positionControl = createPositionControl({\n        container: positionGroup.body,\n        transactionManager: options.transactionManager,\n      });\n      controls.push(positionControl);\n    }\n\n    // Layout control (display, flex-direction, justify-content, align-items, gap)\n    const layoutGroup = controlGroups.get('layout');\n    if (layoutGroup) {\n      const layoutControl = createLayoutControl({\n        container: layoutGroup.body,\n        transactionManager: options.transactionManager,\n      });\n      controls.push(layoutControl);\n    }\n\n    // Typography control (font-size, font-weight, line-height, text-align, color)\n    const typographyGroup = controlGroups.get('typography');\n    if (typographyGroup) {\n      const typographyControl = createTypographyControl({\n        container: typographyGroup.body,\n        transactionManager: options.transactionManager,\n        tokensService: options.tokensService,\n      });\n      controls.push(typographyControl);\n    }\n\n    // Appearance control (overflow, box-sizing, opacity)\n    const appearanceGroup = controlGroups.get('appearance');\n    if (appearanceGroup) {\n      const appearanceControl = createAppearanceControl({\n        container: appearanceGroup.body,\n        transactionManager: options.transactionManager,\n      });\n      controls.push(appearanceControl);\n    }\n\n    // Border control (border-width, border-style, border-color, border-radius)\n    const borderGroup = controlGroups.get('border');\n    if (borderGroup) {\n      const borderControl = createBorderControl({\n        container: borderGroup.body,\n        transactionManager: options.transactionManager,\n        tokensService: options.tokensService,\n      });\n      controls.push(borderControl);\n    }\n\n    // Background control (background-color, gradient, background-image)\n    const backgroundGroup = controlGroups.get('background');\n    if (backgroundGroup) {\n      const backgroundControl = createBackgroundControl({\n        container: backgroundGroup.body,\n        transactionManager: options.transactionManager,\n        tokensService: options.tokensService,\n      });\n      controls.push(backgroundControl);\n    }\n\n    // Effects control (box-shadow, filter blur, backdrop-filter blur)\n    const effectsGroup = controlGroups.get('effects');\n    if (effectsGroup) {\n      const effectsControl = createEffectsControl({\n        container: effectsGroup.body,\n        transactionManager: options.transactionManager,\n        tokensService: options.tokensService,\n        headerActionsContainer: effectsGroup.headerActions,\n      });\n      controls.push(effectsControl);\n    }\n  }\n\n  // Initialize controls immediately\n  initializeControls();\n\n  // Initialize Components Tree (Phase 3.2)\n  componentsTree = createComponentsTree({\n    container: domPanel,\n    onSelect: (element) => {\n      // When user clicks an element in the tree, select it\n      options.onSelectElement(element);\n    },\n  });\n\n  // Initialize CSS Panel (Phase 4.6 + 4.7)\n  cssPanel = createCssPanel({\n    container: cssPanelContainer,\n    transactionManager: options.transactionManager,\n    onClassChange: () => {\n      // Keep header label in sync with class edits (Phase 4.7)\n      if (currentTarget) {\n        targetLabel.textContent = formatTargetLabel(currentTarget);\n      }\n    },\n  });\n\n  // Initialize Props Panel (Phase 7.3)\n  propsPanel = createPropsPanel({\n    container: propsPanelContainer,\n    propsBridge: options.propsBridge,\n  });\n\n  // ==========================================================================\n  // Tab Event Handlers\n  // ==========================================================================\n\n  disposer.listen(designTabBtn, 'click', (event) => {\n    event.preventDefault();\n    setTab('design');\n  });\n\n  disposer.listen(cssTabBtn, 'click', (event) => {\n    event.preventDefault();\n    setTab('css');\n  });\n\n  disposer.listen(propsTabBtn, 'click', (event) => {\n    event.preventDefault();\n    setTab('props');\n  });\n\n  disposer.listen(domTabBtn, 'click', (event) => {\n    event.preventDefault();\n    setTab('dom');\n  });\n\n  // Minimize button handler\n  disposer.listen(minimizeBtn, 'click', (event) => {\n    event.preventDefault();\n    setMinimized(!minimized);\n  });\n\n  // ==========================================================================\n  // Minimize State\n  // ==========================================================================\n\n  /**\n   * Toggle minimized state of property panel\n   */\n  function setMinimized(value: boolean): void {\n    minimized = value;\n    root.dataset.minimized = minimized ? 'true' : 'false';\n\n    // Hide/show body and header elements\n    body.hidden = minimized;\n    tabsContainer.hidden = minimized;\n\n    // Update minimize button label and tooltip\n    minimizeBtn.setAttribute(\n      'aria-label',\n      minimized ? 'Expand property panel' : 'Minimize property panel',\n    );\n    minimizeBtn.dataset.tooltip = minimized ? 'Expand' : 'Minimize';\n\n    // Keep minimized layout stable while preserving stored floating position.\n    // When restoring, re-apply stored position (and clamp with current size).\n    if (!minimized && floatingPosition) {\n      setPosition(floatingPosition);\n    } else {\n      syncFloatingPositionStyles();\n    }\n  }\n\n  // ==========================================================================\n  // Render Functions\n  // ==========================================================================\n\n  /**\n   * Update tab button states and panel visibility\n   */\n  function renderTabs(): void {\n    root.dataset.tab = currentTab;\n\n    designTabBtn.setAttribute('aria-selected', currentTab === 'design' ? 'true' : 'false');\n    cssTabBtn.setAttribute('aria-selected', currentTab === 'css' ? 'true' : 'false');\n    propsTabBtn.setAttribute('aria-selected', currentTab === 'props' ? 'true' : 'false');\n    domTabBtn.setAttribute('aria-selected', currentTab === 'dom' ? 'true' : 'false');\n\n    // Show/hide panels based on tab and target\n    const hasTarget = currentTarget !== null;\n    designPanel.hidden = !hasTarget || currentTab !== 'design';\n    cssPanelContainer.hidden = !hasTarget || currentTab !== 'css';\n    propsPanelContainer.hidden = !hasTarget || currentTab !== 'props';\n    domPanel.hidden = !hasTarget || currentTab !== 'dom';\n\n    // Notify panels of visibility change (for lazy loading optimization)\n    cssPanel?.setVisible(hasTarget && currentTab === 'css');\n    propsPanel?.setVisible(hasTarget && currentTab === 'props');\n  }\n\n  /**\n   * Update empty state visibility\n   */\n  function renderEmptyState(): void {\n    const hasTarget = currentTarget !== null;\n    root.dataset.empty = hasTarget ? 'false' : 'true';\n    emptyState.hidden = hasTarget;\n\n    if (!hasTarget) {\n      targetLabel.textContent = '';\n    }\n\n    renderTabs();\n  }\n\n  /**\n   * Update all controls with current target\n   */\n  function updateControls(): void {\n    for (const control of controls) {\n      control.setTarget(currentTarget);\n    }\n    // Also update Components Tree\n    componentsTree?.setTarget(currentTarget);\n    // Also update CSS Panel\n    cssPanel?.setTarget(currentTarget);\n    // Also update Props Panel\n    propsPanel?.setTarget(currentTarget);\n  }\n\n  // ==========================================================================\n  // Live Style Sync (for external style mutations like resize handles)\n  // ==========================================================================\n\n  /**\n   * Cancel pending rAF for style observer\n   */\n  function cancelStyleObserverRaf(): void {\n    if (styleObserverRafId !== null) {\n      cancelAnimationFrame(styleObserverRafId);\n      styleObserverRafId = null;\n    }\n  }\n\n  /**\n   * Schedule a throttled refresh of size/position/spacing controls.\n   * Uses rAF to coalesce multiple style mutations within the same frame.\n   */\n  function scheduleLiveStyleRefresh(): void {\n    if (disposer.isDisposed) return;\n    if (styleObserverRafId !== null) return;\n\n    styleObserverRafId = requestAnimationFrame(() => {\n      styleObserverRafId = null;\n      if (disposer.isDisposed) return;\n      if (!currentTarget || !currentTarget.isConnected) return;\n\n      // Only refresh controls that are affected by resize operations\n      sizeControl?.refresh();\n      positionControl?.refresh();\n      spacingControl?.refresh();\n    });\n  }\n\n  /**\n   * Disconnect the style observer and clean up\n   */\n  function disconnectStyleObserver(): void {\n    cancelStyleObserverRaf();\n\n    if (styleObserver) {\n      try {\n        styleObserver.disconnect();\n      } catch {\n        // Best-effort cleanup\n      }\n    }\n\n    styleObserver = null;\n    styleObserverTarget = null;\n  }\n\n  /**\n   * Connect a MutationObserver to watch for style attribute changes on the target.\n   * This enables live sync when external code (like resize handles) modifies inline styles.\n   */\n  function connectStyleObserver(target: Element | null): void {\n    disconnectStyleObserver();\n\n    if (!target || !target.isConnected) return;\n    if (typeof MutationObserver === 'undefined') return;\n\n    styleObserverTarget = target;\n\n    styleObserver = new MutationObserver(() => {\n      if (disposer.isDisposed) return;\n      // Ignore late events after selection changes\n      if (styleObserverTarget !== currentTarget) return;\n      scheduleLiveStyleRefresh();\n    });\n\n    try {\n      styleObserver.observe(target, {\n        attributes: true,\n        attributeFilter: ['style'],\n      });\n    } catch {\n      // Some nodes may reject observation; ignore\n      disconnectStyleObserver();\n    }\n  }\n\n  // Register cleanup for style observer\n  disposer.add(disconnectStyleObserver);\n\n  // ==========================================================================\n  // Public API (PropertyPanel interface)\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    currentTarget = element;\n\n    if (element) {\n      targetLabel.textContent = formatTargetLabel(element);\n    }\n\n    renderEmptyState();\n    updateControls();\n\n    // Connect style observer for live sync (resize handles, etc.)\n    connectStyleObserver(currentTarget);\n  }\n\n  function setTab(tab: PropertyPanelTab): void {\n    if (disposer.isDisposed) return;\n\n    currentTab = tab;\n    renderTabs();\n  }\n\n  function getTab(): PropertyPanelTab {\n    return currentTab;\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n\n    // Refresh header label (class changes may affect display)\n    if (currentTarget) {\n      targetLabel.textContent = formatTargetLabel(currentTarget);\n    }\n\n    for (const control of controls) {\n      control.refresh();\n    }\n    // Also refresh Components Tree\n    componentsTree?.refresh();\n    // Also refresh CSS Panel\n    cssPanel?.refresh();\n    // Also refresh Props Panel\n    propsPanel?.refresh();\n  }\n\n  function dispose(): void {\n    // Dispose Components Tree\n    componentsTree?.dispose();\n    componentsTree = null;\n\n    // Dispose CSS Panel\n    cssPanel?.dispose();\n    cssPanel = null;\n\n    // Dispose Props Panel\n    propsPanel?.dispose();\n    propsPanel = null;\n\n    // Dispose all controls\n    for (const control of controls) {\n      control.dispose();\n    }\n    controls.length = 0;\n    controlGroups.clear();\n\n    currentTarget = null;\n    disposer.dispose();\n  }\n\n  // Initial render\n  renderEmptyState();\n\n  return {\n    setTarget,\n    setTab,\n    getTab,\n    refresh,\n    getPosition,\n    setPosition,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/props-panel.ts",
    "content": "/**\n * Props Panel (Phase 7.3)\n *\n * Displays runtime component props (React/Vue) for the selected element.\n * Editing is performed via PropsBridge and applies immediately in the page.\n *\n * Features:\n * - Shows component name and framework\n * - Displays props with type information\n * - Supports editing primitive props (string/number/boolean)\n * - Shows capability status (canRead/canWrite/needsRefresh)\n * - Debounced writes to avoid high-frequency updates\n *\n * Constraints:\n * - Runtime-only (no source edits)\n * - Only supports editing top-level primitive props\n */\n\nimport type { ElementLocator } from '@/common/web-editor-types';\nimport { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';\nimport { createElementLocator } from '../../core/locator';\nimport type {\n  FrameworkType,\n  HookStatus,\n  PropsBridge,\n  PropsResponseData,\n  SerializedPropEntry,\n  SerializedValue,\n} from '../../core/props-bridge';\nimport { Disposer } from '../../utils/disposables';\nimport type { DesignControl } from './types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface PropsPanelOptions {\n  container: HTMLElement;\n  propsBridge: PropsBridge;\n}\n\nexport interface PropsPanel extends DesignControl {\n  setVisible(visible: boolean): void;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst WRITE_DEBOUNCE_MS = 250;\n\nconst DANGEROUS_PROP_KEYS = new Set([\n  '__proto__',\n  'constructor',\n  'prototype',\n  '__defineGetter__',\n  '__defineSetter__',\n  '__lookupGetter__',\n  '__lookupSetter__',\n]);\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isDangerousPropKey(key: string): boolean {\n  return DANGEROUS_PROP_KEYS.has(String(key ?? '').trim());\n}\n\nfunction formatFramework(framework: FrameworkType | undefined, version?: string): string {\n  // Only show version for known frameworks to avoid \"Unknown x.y.z\" display\n  if (framework === 'react') {\n    const trimmedVersion = version?.trim();\n    return trimmedVersion ? `React ${trimmedVersion}` : 'React';\n  }\n  if (framework === 'vue') {\n    const trimmedVersion = version?.trim();\n    return trimmedVersion ? `Vue ${trimmedVersion}` : 'Vue';\n  }\n  return 'Unknown';\n}\n\nfunction formatHookStatus(hookStatus: HookStatus | undefined): string {\n  return hookStatus ? String(hookStatus) : '';\n}\n\n/**\n * Format debug source for display.\n * Returns empty string if source is invalid/missing.\n */\nfunction formatDebugSource(source: unknown): string {\n  if (!source || typeof source !== 'object') return '';\n\n  const rec = source as Record<string, unknown>;\n  const file = typeof rec.file === 'string' ? rec.file.trim() : '';\n  if (!file) return '';\n\n  const lineRaw = Number(rec.line);\n  const columnRaw = Number(rec.column);\n  const line = Number.isFinite(lineRaw) && lineRaw > 0 ? lineRaw : undefined;\n  const column = Number.isFinite(columnRaw) && columnRaw > 0 ? columnRaw : undefined;\n\n  if (!line) return file;\n  return column ? `${file}:${line}:${column}` : `${file}:${line}`;\n}\n\nfunction formatSerializedValue(value: SerializedValue): string {\n  switch (value.kind) {\n    case 'null':\n      return 'null';\n    case 'undefined':\n      return 'undefined';\n    case 'boolean':\n      return value.value ? 'true' : 'false';\n    case 'number':\n      if (value.special) return value.special;\n      if (typeof value.value === 'number') return String(value.value);\n      return 'NaN';\n    case 'string':\n      return value.truncated ? `\"${value.value}…\"` : JSON.stringify(value.value);\n    case 'bigint':\n      return `${value.value}n`;\n    case 'symbol':\n      return `Symbol(${value.description})`;\n    case 'function':\n      return `ƒ ${value.name ?? '(anonymous)'}`;\n    case 'react_element':\n      return value.display;\n    case 'dom_element': {\n      const tag = String(value.tagName ?? '').toLowerCase() || 'element';\n      const id = value.id ? `#${value.id}` : '';\n      const cls = value.className\n        ? `.${String(value.className).split(/\\s+/).filter(Boolean).slice(0, 2).join('.')}`\n        : '';\n      return `<${tag}${id}${cls}>`;\n    }\n    case 'date':\n      return value.value;\n    case 'regexp':\n      return `/${value.source}/${value.flags}`;\n    case 'error':\n      return `${value.name}: ${value.message}`;\n    case 'circular':\n      return `[Circular #${value.refId}]`;\n    case 'max_depth':\n      return value.preview;\n    case 'array':\n      return `Array(${value.length})`;\n    case 'object':\n      return `${value.name ?? 'Object'} {…}`;\n    case 'map':\n      return `Map(${value.size})`;\n    case 'set':\n      return `Set(${value.size})`;\n    case 'unknown':\n      return value.preview;\n    default:\n      return String((value as { kind?: string }).kind ?? 'unknown');\n  }\n}\n\nfunction canRenderEditableNumber(value: Extract<SerializedValue, { kind: 'number' }>): boolean {\n  if (value.special) return false;\n  if (typeof value.value !== 'number') return false;\n  return Number.isFinite(value.value);\n}\n\nfunction parseNumberInput(raw: string): { ok: true; value: number } | { ok: false } {\n  const trimmed = raw.trim();\n  if (!trimmed) return { ok: false };\n\n  // Accept intermediate \"10.\" as 10 (keeps UX consistent with style controls)\n  if (/^-?\\d+\\.$/.test(trimmed)) {\n    const n = Number(trimmed.slice(0, -1));\n    return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };\n  }\n\n  // Pure number patterns: \"10\", \"-10\", \"10.5\", \".5\", \"-.5\"\n  if (/^-?(?:\\d+|\\d*\\.\\d+)$/.test(trimmed)) {\n    const n = Number(trimmed);\n    return Number.isFinite(n) ? { ok: true, value: n } : { ok: false };\n  }\n\n  return { ok: false };\n}\n\nfunction mergeResponseData(\n  prev: PropsResponseData | null,\n  next: PropsResponseData | undefined,\n): PropsResponseData | null {\n  if (!next) return prev;\n  if (!prev) return next;\n\n  return {\n    ...prev,\n    ...next,\n    capabilities: next.capabilities ?? prev.capabilities,\n    props: next.props ?? prev.props,\n    meta: { ...(prev.meta ?? {}), ...(next.meta ?? {}) },\n  };\n}\n\nfunction buildStatusLine(\n  loading: boolean,\n  data: PropsResponseData | null,\n  error: string | null,\n): string {\n  if (loading) return 'Loading…';\n\n  if (!data) {\n    return error ? `Error • ${error}` : 'Waiting for selection…';\n  }\n\n  const parts: string[] = [];\n  const caps = data.capabilities;\n\n  if (caps) {\n    parts.push(`read: ${caps.canRead ? 'yes' : 'no'}`);\n    parts.push(`write: ${caps.canWrite ? 'yes' : 'no'}`);\n  } else {\n    parts.push('read: unknown');\n    parts.push('write: unknown');\n  }\n\n  const hook = formatHookStatus(data.hookStatus);\n  if (hook) parts.push(`hook: ${hook}`);\n\n  if (data.needsRefresh) parts.push('needs refresh');\n  if (error) parts.push('error');\n\n  return parts.join(' • ');\n}\n\nfunction getCanWrite(data: PropsResponseData | null): boolean {\n  return Boolean(data?.capabilities?.canWrite) && !data?.needsRefresh;\n}\n\nfunction getCanRead(data: PropsResponseData | null): boolean {\n  return Boolean(data?.capabilities?.canRead);\n}\n\nfunction findPropEntry(data: PropsResponseData | null, key: string): SerializedPropEntry | null {\n  const props = data?.props;\n  if (!props || !Array.isArray(props.entries)) return null;\n  return props.entries.find((e) => e.key === key) ?? null;\n}\n\nfunction setInputFromEntry(entry: SerializedPropEntry, input: HTMLInputElement): void {\n  input.classList.remove('we-props-input--invalid');\n\n  if (entry.value.kind === 'string') {\n    input.value = entry.value.value ?? '';\n    return;\n  }\n\n  if (entry.value.kind === 'number') {\n    if (typeof entry.value.value === 'number' && Number.isFinite(entry.value.value)) {\n      input.value = String(entry.value.value);\n    } else if (entry.value.special) {\n      input.value = entry.value.special;\n    } else {\n      input.value = '';\n    }\n    return;\n  }\n\n  if (entry.value.kind === 'boolean') {\n    input.checked = Boolean(entry.value.value);\n  }\n}\n\nfunction updateLocalPrimitiveSnapshot(\n  data: PropsResponseData | null,\n  key: string,\n  value: string | number | boolean,\n): void {\n  if (!data?.props?.entries) return;\n  const entry = data.props.entries.find((e) => e.key === key);\n  if (!entry) return;\n\n  if (typeof value === 'string') {\n    entry.value = { kind: 'string', value };\n    entry.editable = true;\n    return;\n  }\n\n  if (typeof value === 'number') {\n    entry.value = { kind: 'number', value };\n    entry.editable = true;\n    return;\n  }\n\n  entry.value = { kind: 'boolean', value };\n  entry.editable = true;\n}\n\n// =============================================================================\n// Factory\n// =============================================================================\n\nexport function createPropsPanel(options: PropsPanelOptions): PropsPanel {\n  const { container, propsBridge } = options;\n  const disposer = new Disposer();\n\n  // ==========================================================================\n  // Tooltip - fixed position at shadow root level to avoid overflow clipping\n  // ==========================================================================\n\n  const tooltip = document.createElement('div');\n  tooltip.className = 'we-tooltip';\n  tooltip.hidden = true;\n\n  const rootNode = container.getRootNode();\n  if (rootNode instanceof ShadowRoot) {\n    rootNode.appendChild(tooltip);\n  } else {\n    document.body.appendChild(tooltip);\n  }\n  disposer.add(() => tooltip.remove());\n\n  function showTooltip(el: Element): void {\n    const text = el.getAttribute('data-tip');\n    if (!text) {\n      tooltip.hidden = true;\n      return;\n    }\n    const rect = el.getBoundingClientRect();\n    tooltip.textContent = text;\n    tooltip.style.left = `${rect.left + rect.width / 2}px`;\n    tooltip.style.top = `${rect.bottom + 4}px`;\n    tooltip.hidden = false;\n  }\n\n  function hideTooltip(): void {\n    tooltip.hidden = true;\n  }\n\n  // State\n  let currentTarget: Element | null = null;\n  let currentLocator: ElementLocator | null = null;\n  let isVisible = false;\n  let needsFetchOnVisible = false; // Deferred fetch when panel becomes visible\n  let loading = false;\n  let sessionId = 0;\n\n  let lastData: PropsResponseData | null = null;\n  let lastError: string | null = null;\n\n  type PendingWrite = { timeoutId: number; value: string | number | boolean };\n  const pendingWrites = new Map<string, PendingWrite>();\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  const root = document.createElement('div');\n  root.className = 'we-props-panel';\n\n  // Meta section\n  const meta = document.createElement('div');\n  meta.className = 'we-props-meta';\n\n  const metaTitleRow = document.createElement('div');\n  metaTitleRow.className = 'we-props-meta-title';\n\n  const titleLeft = document.createElement('div');\n  titleLeft.className = 'we-props-title-left';\n\n  const componentEl = document.createElement('div');\n  componentEl.className = 'we-props-component';\n  componentEl.textContent = 'Props';\n\n  const frameworkEl = document.createElement('span');\n  frameworkEl.className = 'we-props-badge';\n  frameworkEl.textContent = 'Unknown';\n\n  titleLeft.append(componentEl, frameworkEl);\n\n  // Action buttons in title row (icon style)\n  const titleActions = document.createElement('div');\n  titleActions.className = 'we-props-title-actions';\n\n  const refreshBtn = document.createElement('button');\n  refreshBtn.type = 'button';\n  refreshBtn.className = 'we-props-action-btn';\n  refreshBtn.dataset.tip = 'Refresh';\n  refreshBtn.setAttribute('aria-label', 'Refresh props');\n  // Refresh icon (circular arrow)\n  refreshBtn.innerHTML = `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M11.5 7C11.5 9.48528 9.48528 11.5 7 11.5C4.51472 11.5 2.5 9.48528 2.5 7C2.5 4.51472 4.51472 2.5 7 2.5C8.5 2.5 9.83 3.25 10.6 4.4M10.6 2V4.4H8.2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n  </svg>`;\n\n  const resetBtn = document.createElement('button');\n  resetBtn.type = 'button';\n  resetBtn.className = 'we-props-action-btn';\n  resetBtn.dataset.tip = 'Reset';\n  resetBtn.setAttribute('aria-label', 'Reset props changes');\n  // Reset icon (undo arrow)\n  resetBtn.innerHTML = `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M3 5.5H8.5C10.1569 5.5 11.5 6.84315 11.5 8.5C11.5 10.1569 10.1569 11.5 8.5 11.5H7M3 5.5L5.5 3M3 5.5L5.5 8\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n  </svg>`;\n\n  titleActions.append(refreshBtn, resetBtn);\n  metaTitleRow.append(titleLeft, titleActions);\n\n  const statusEl = document.createElement('div');\n  statusEl.className = 'we-props-status';\n\n  const warningEl = document.createElement('div');\n  warningEl.className = 'we-props-warning';\n  warningEl.hidden = true;\n\n  const errorEl = document.createElement('div');\n  errorEl.className = 'we-props-error';\n  errorEl.hidden = true;\n\n  // Source row - shows component source file location with \"Open in VSCode\" button\n  const sourceRow = document.createElement('div');\n  sourceRow.className = 'we-props-source';\n  sourceRow.hidden = true;\n\n  const sourceLabelEl = document.createElement('span');\n  sourceLabelEl.className = 'we-props-source-label';\n  sourceLabelEl.textContent = 'Source';\n\n  const sourcePathEl = document.createElement('span');\n  sourcePathEl.className = 'we-props-source-path';\n  sourcePathEl.title = ''; // Will be set to full path on render\n\n  const openSourceBtn = document.createElement('button');\n  openSourceBtn.type = 'button';\n  openSourceBtn.className = 'we-props-source-btn';\n  openSourceBtn.dataset.tip = 'Open in VSCode';\n  openSourceBtn.setAttribute('aria-label', 'Open in VSCode');\n  // Simple arrow pointing to top-right (external link style)\n  openSourceBtn.innerHTML = `<svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M3.5 2.5H9.5V8.5M9 3L3 9\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n  </svg>`;\n\n  sourceRow.append(sourceLabelEl, sourcePathEl, openSourceBtn);\n\n  meta.append(metaTitleRow, statusEl, warningEl, errorEl, sourceRow);\n\n  // List section\n  const list = document.createElement('div');\n  list.className = 'we-props-list';\n\n  const emptyState = document.createElement('div');\n  emptyState.className = 'we-props-empty';\n  emptyState.textContent = 'Select an element to view props.';\n\n  const rows = document.createElement('div');\n  rows.className = 'we-props-rows';\n\n  list.append(emptyState, rows);\n  root.append(meta, list);\n  container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Pending Writes Management\n  // ==========================================================================\n\n  function clearAllPendingWrites(): void {\n    for (const [, entry] of pendingWrites) {\n      clearTimeout(entry.timeoutId);\n    }\n    pendingWrites.clear();\n  }\n\n  /**\n   * Flush all pending writes to the current target before switching elements.\n   * This ensures user edits are not lost when selection changes quickly.\n   */\n  function flushAllPendingWrites(): void {\n    if (pendingWrites.size === 0) return;\n\n    const keys = [...pendingWrites.keys()];\n    for (const key of keys) {\n      const entry = pendingWrites.get(key);\n      if (!entry) continue;\n      clearTimeout(entry.timeoutId);\n      pendingWrites.delete(key);\n      void commitWrite(key, entry.value);\n    }\n  }\n\n  disposer.add(clearAllPendingWrites);\n\n  function cancelPendingWrite(key: string): void {\n    const existing = pendingWrites.get(key);\n    if (!existing) return;\n    clearTimeout(existing.timeoutId);\n    pendingWrites.delete(key);\n  }\n\n  function flushPendingWrite(key: string): void {\n    const existing = pendingWrites.get(key);\n    if (!existing) return;\n    clearTimeout(existing.timeoutId);\n    pendingWrites.delete(key);\n    void commitWrite(key, existing.value);\n  }\n\n  function scheduleWrite(key: string, value: string | number | boolean): void {\n    cancelPendingWrite(key);\n    const timeoutId = window.setTimeout(() => {\n      pendingWrites.delete(key);\n      void commitWrite(key, value);\n    }, WRITE_DEBOUNCE_MS);\n    pendingWrites.set(key, { timeoutId, value });\n  }\n\n  // ==========================================================================\n  // Render Functions\n  // ==========================================================================\n\n  function renderMeta(): void {\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n    const framework = lastData?.framework;\n    const frameworkVersion = lastData?.frameworkVersion;\n    const componentName = lastData?.componentName;\n\n    componentEl.textContent = componentName || 'Props';\n    frameworkEl.textContent = formatFramework(framework, frameworkVersion);\n\n    statusEl.textContent = hasTarget\n      ? buildStatusLine(loading, lastData, lastError)\n      : 'Select an element to view props.';\n\n    // Warning messages\n    warningEl.hidden = true;\n    warningEl.textContent = '';\n\n    if (hasTarget) {\n      if (lastData?.needsRefresh) {\n        warningEl.hidden = false;\n        warningEl.textContent = 'A page refresh is required for full props inspection/editing.';\n      } else if (lastData?.hookStatus === 'RENDERERS_NO_EDITING') {\n        warningEl.hidden = false;\n        warningEl.textContent =\n          'Editing is unavailable (likely a production build without overrideProps).';\n      } else if (lastData?.props?.truncated) {\n        warningEl.hidden = false;\n        warningEl.textContent = 'Props list is truncated.';\n      }\n    }\n\n    // Error display\n    errorEl.hidden = !lastError;\n    errorEl.textContent = lastError ?? '';\n\n    // Source display - show component file location with Open button\n    const sourceText = hasTarget ? formatDebugSource(lastData?.debugSource) : '';\n    sourceRow.hidden = !sourceText;\n    sourcePathEl.textContent = sourceText;\n    sourcePathEl.title = sourceText; // Show full path on hover\n    openSourceBtn.disabled = !sourceText || loading;\n\n    // Update refresh button state and tooltip\n    const hookStatus = lastData?.hookStatus;\n    const canBenefitFromEarlyInjection =\n      hookStatus === 'HOOK_MISSING' || hookStatus === 'HOOK_PRESENT_NO_RENDERERS';\n    const showEnableReload = lastData?.needsRefresh && canBenefitFromEarlyInjection;\n    refreshBtn.dataset.tip = showEnableReload ? 'Enable & Reload' : 'Refresh';\n    refreshBtn.disabled = !hasTarget || loading;\n    resetBtn.disabled = !hasTarget || loading || !getCanWrite(lastData);\n  }\n\n  function renderList(): void {\n    rows.innerHTML = '';\n\n    const hasTarget = Boolean(currentTarget && currentTarget.isConnected);\n    const data = lastData;\n\n    if (!hasTarget) {\n      emptyState.hidden = false;\n      emptyState.classList.remove('we-loading');\n      emptyState.textContent = 'Select an element to view props.';\n      return;\n    }\n\n    if (loading) {\n      emptyState.hidden = false;\n      emptyState.classList.add('we-loading');\n      // Spinner icon (thin stroke) + text\n      emptyState.innerHTML = `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" style=\"animation: we-spin 0.8s linear infinite;\">\n        <circle cx=\"7\" cy=\"7\" r=\"5.5\" stroke=\"currentColor\" stroke-width=\"1\" stroke-linecap=\"round\" stroke-dasharray=\"20 14\" />\n      </svg><span>Loading props…</span>`;\n      return;\n    }\n\n    // Remove loading class when not loading\n    emptyState.classList.remove('we-loading');\n\n    const canRead = getCanRead(data);\n    if (!canRead) {\n      emptyState.hidden = false;\n      const hook = data?.hookStatus;\n      if (data?.needsRefresh || hook === 'HOOK_MISSING' || hook === 'HOOK_PRESENT_NO_RENDERERS') {\n        emptyState.textContent =\n          'Props inspection is not ready. Refresh the page in development mode.';\n      } else if (hook === 'RENDERERS_NO_EDITING') {\n        emptyState.textContent = 'Props inspection/editing is unavailable in this build.';\n      } else {\n        emptyState.textContent = 'Props inspection is not available for this element.';\n      }\n      return;\n    }\n\n    const props = data?.props;\n    if (!props || !Array.isArray(props.entries) || props.entries.length === 0) {\n      emptyState.hidden = false;\n      emptyState.textContent =\n        data?.framework === 'vue' ? 'No props or attrs found.' : 'No props found.';\n      return;\n    }\n\n    emptyState.hidden = true;\n\n    const canWrite = getCanWrite(data);\n    const disableEdits = !canWrite || loading;\n\n    // Group entries by source for Vue (Props vs Attrs)\n    const isVue = data?.framework === 'vue';\n    const entries = props.entries;\n    const hasAttrs = isVue && entries.some((e) => e.source === 'attrs');\n\n    interface EntryGroup {\n      title: string;\n      entries: typeof entries;\n    }\n\n    const groups: EntryGroup[] = hasAttrs\n      ? [\n          { title: 'Props', entries: entries.filter((e) => e.source !== 'attrs') },\n          { title: 'Attrs', entries: entries.filter((e) => e.source === 'attrs') },\n        ].filter((g) => g.entries.length > 0)\n      : [{ title: '', entries }];\n\n    for (const group of groups) {\n      // Render group header for Vue\n      if (group.title) {\n        const groupHeader = document.createElement('div');\n        groupHeader.className = 'we-props-group';\n        groupHeader.textContent = group.title;\n        rows.append(groupHeader);\n      }\n\n      for (const entry of group.entries) {\n        const row = document.createElement('div');\n        row.className = 'we-props-row';\n\n        const keyEl = document.createElement('div');\n        keyEl.className = 'we-props-key';\n        keyEl.textContent = entry.key;\n\n        const valueEl = document.createElement('div');\n        valueEl.className = 'we-props-value';\n\n        const keyIsDangerous = isDangerousPropKey(entry.key);\n        const entryEditable = Boolean(entry.editable) && !keyIsDangerous;\n\n        // Check if this entry has enum values (for select rendering)\n        // Filter to valid string enum values first, then check if non-empty\n        const rawEnumValues = Array.isArray(entry.enumValues) ? entry.enumValues : [];\n        const filteredEnumValues = rawEnumValues.filter(\n          (v): v is string => typeof v === 'string' && v.trim().length > 0,\n        );\n        const hasEnumValues =\n          entryEditable && entry.value.kind === 'string' && filteredEnumValues.length > 0;\n\n        // Render editable controls for primitives\n        if (hasEnumValues) {\n          // Render Select for enum props\n          const select = document.createElement('select');\n          select.className = 'we-select we-props-input';\n          select.disabled = disableEdits;\n          select.dataset.propKey = entry.key;\n          select.dataset.propKind = 'enum';\n          select.setAttribute('aria-label', `Select prop ${entry.key}`);\n\n          const currentValue = entry.value.value ?? '';\n          const seen = new Set<string>();\n\n          // Add current value first if not in enum list\n          if (currentValue && !filteredEnumValues.includes(currentValue)) {\n            const opt = document.createElement('option');\n            opt.value = currentValue;\n            opt.textContent = `${currentValue} (current)`;\n            select.append(opt);\n            seen.add(currentValue);\n          }\n\n          // Add enum values\n          for (const v of filteredEnumValues) {\n            if (seen.has(v)) continue;\n            seen.add(v);\n            const opt = document.createElement('option');\n            opt.value = v;\n            opt.textContent = v;\n            select.append(opt);\n          }\n\n          // Set current value\n          if (currentValue && seen.has(currentValue)) {\n            select.value = currentValue;\n          }\n\n          valueEl.append(select);\n        } else if (entryEditable && entry.value.kind === 'boolean') {\n          const label = document.createElement('label');\n          label.className = 'we-props-bool';\n\n          const checkbox = document.createElement('input');\n          checkbox.type = 'checkbox';\n          checkbox.className = 'we-props-checkbox';\n          checkbox.checked = Boolean(entry.value.value);\n          checkbox.disabled = disableEdits;\n          checkbox.dataset.propKey = entry.key;\n          checkbox.dataset.propKind = 'boolean';\n          checkbox.setAttribute('aria-label', `Toggle prop ${entry.key}`);\n\n          const text = document.createElement('span');\n          text.textContent = checkbox.checked ? 'true' : 'false';\n          text.dataset.weBoolText = '1';\n\n          label.append(checkbox, text);\n          valueEl.append(label);\n        } else if (entryEditable && entry.value.kind === 'string') {\n          const input = document.createElement('input');\n          input.type = 'text';\n          input.className = 'we-input we-props-input';\n          input.autocomplete = 'off';\n          input.spellcheck = false;\n          input.value = entry.value.value ?? '';\n          input.disabled = disableEdits;\n          input.dataset.propKey = entry.key;\n          input.dataset.propKind = 'string';\n          input.setAttribute('aria-label', `Edit prop ${entry.key}`);\n          valueEl.append(input);\n        } else if (\n          entryEditable &&\n          entry.value.kind === 'number' &&\n          canRenderEditableNumber(entry.value)\n        ) {\n          const input = document.createElement('input');\n          input.type = 'text';\n          input.inputMode = 'decimal';\n          input.className = 'we-input we-props-input';\n          input.autocomplete = 'off';\n          input.spellcheck = false;\n          input.value = String(entry.value.value);\n          input.disabled = disableEdits;\n          input.dataset.propKey = entry.key;\n          input.dataset.propKind = 'number';\n          input.setAttribute('aria-label', `Edit prop ${entry.key}`);\n          valueEl.append(input);\n        } else {\n          // Read-only display\n          valueEl.classList.add('we-props-value--readonly');\n          valueEl.textContent = keyIsDangerous\n            ? `${formatSerializedValue(entry.value)} (blocked)`\n            : formatSerializedValue(entry.value);\n        }\n\n        row.append(keyEl, valueEl);\n        rows.append(row);\n      }\n    }\n  }\n\n  function renderAll(): void {\n    renderMeta();\n    renderList();\n  }\n\n  // ==========================================================================\n  // Data Fetching\n  // ==========================================================================\n\n  async function probeAndRead(): Promise<void> {\n    if (disposer.isDisposed) return;\n\n    if (!isVisible) {\n      needsFetchOnVisible = true;\n      return;\n    }\n\n    const target = currentTarget;\n    const locator = currentLocator;\n\n    if (!target || !target.isConnected || !locator) {\n      lastData = null;\n      lastError = null;\n      loading = false;\n      needsFetchOnVisible = false;\n      renderAll();\n      return;\n    }\n\n    const localSession = sessionId;\n    loading = true;\n    lastError = null;\n    renderAll();\n\n    try {\n      const probeResult = await propsBridge.probe(locator);\n      if (disposer.isDisposed || localSession !== sessionId) return;\n\n      lastData = mergeResponseData(lastData, probeResult.data);\n      if (!probeResult.ok) {\n        lastError = probeResult.error ?? 'Props probe failed';\n      }\n\n      const canRead = Boolean(probeResult.data?.capabilities?.canRead);\n      if (canRead) {\n        const readResult = await propsBridge.read(locator);\n        if (disposer.isDisposed || localSession !== sessionId) return;\n\n        lastData = mergeResponseData(lastData, readResult.data);\n        if (!readResult.ok) {\n          lastError = readResult.error ?? 'Props read failed';\n        }\n      }\n    } catch (err) {\n      if (disposer.isDisposed || localSession !== sessionId) return;\n      lastError = err instanceof Error ? err.message : String(err);\n    } finally {\n      if (!disposer.isDisposed && localSession === sessionId) {\n        loading = false;\n        needsFetchOnVisible = false;\n        renderAll();\n      }\n    }\n  }\n\n  async function commitWrite(key: string, value: string | number | boolean): Promise<void> {\n    if (disposer.isDisposed) return;\n\n    const target = currentTarget;\n    const locator = currentLocator;\n    if (!target || !target.isConnected || !locator) return;\n\n    if (isDangerousPropKey(key)) {\n      lastError = 'Blocked prop key (security)';\n      renderMeta();\n      return;\n    }\n\n    const localSession = sessionId;\n    const canWrite = getCanWrite(lastData);\n    if (!canWrite) {\n      lastError = 'Props editing is not available for this element.';\n      renderMeta();\n      return;\n    }\n\n    try {\n      const result = await propsBridge.write(locator, [key], value);\n      if (disposer.isDisposed || localSession !== sessionId) return;\n\n      lastData = mergeResponseData(lastData, result.data);\n\n      if (!result.ok) {\n        lastError = result.error ?? 'Props write failed';\n        renderMeta();\n        return;\n      }\n\n      lastError = null;\n      updateLocalPrimitiveSnapshot(lastData, key, value);\n      renderMeta();\n    } catch (err) {\n      if (disposer.isDisposed || localSession !== sessionId) return;\n      lastError = err instanceof Error ? err.message : String(err);\n      renderMeta();\n    }\n  }\n\n  async function resetOverrides(): Promise<void> {\n    if (disposer.isDisposed) return;\n\n    const target = currentTarget;\n    const locator = currentLocator;\n    if (!target || !target.isConnected || !locator) return;\n\n    const localSession = sessionId;\n    clearAllPendingWrites();\n    loading = true;\n    lastError = null;\n    renderAll();\n\n    try {\n      const result = await propsBridge.reset(locator);\n      if (disposer.isDisposed || localSession !== sessionId) return;\n\n      lastData = mergeResponseData(lastData, result.data);\n      if (!result.ok) {\n        lastError = result.error ?? 'Props reset failed';\n      }\n    } catch (err) {\n      if (disposer.isDisposed || localSession !== sessionId) return;\n      lastError = err instanceof Error ? err.message : String(err);\n    } finally {\n      if (!disposer.isDisposed && localSession === sessionId) {\n        loading = false;\n        renderMeta();\n        // Re-read to refresh displayed props after reset\n        void probeAndRead();\n      }\n    }\n  }\n\n  // ==========================================================================\n  // Early Injection (Phase 7.1.6)\n  // ==========================================================================\n\n  /**\n   * Register early injection and reload the page.\n   * This allows capturing React DevTools hook before React initializes.\n   */\n  async function registerEarlyInjectionAndReload(): Promise<void> {\n    if (disposer.isDisposed) return;\n\n    // Verify chrome runtime is available\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      lastError = 'Chrome runtime API not available';\n      renderMeta();\n      return;\n    }\n\n    // Confirm with user\n    const confirmed = window.confirm(\n      'Props editing requires early injection to capture React renderers before they initialize.\\n\\n' +\n        'This will:\\n' +\n        '• Register a content script for this site\\n' +\n        '• Reload the page immediately\\n\\n' +\n        'After reload, enable the editor again to access full Props functionality.\\n\\n' +\n        'Continue?',\n    );\n    if (!confirmed) return;\n\n    try {\n      const resp = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION,\n      });\n\n      if (!resp?.success) {\n        lastError = resp?.error ?? 'Failed to register early injection';\n        renderMeta();\n      }\n      // If successful, page will reload automatically\n    } catch (err) {\n      lastError = err instanceof Error ? err.message : String(err);\n      renderMeta();\n    }\n  }\n\n  /**\n   * Send message to background to open source file in VSCode.\n   */\n  async function openSourceInVSCode(): Promise<void> {\n    if (disposer.isDisposed) return;\n\n    const debugSource = lastData?.debugSource;\n    if (!debugSource || !formatDebugSource(debugSource)) return;\n\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      lastError = 'Chrome runtime API not available';\n      renderMeta();\n      return;\n    }\n\n    try {\n      const resp = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_OPEN_SOURCE,\n        payload: { debugSource },\n      });\n\n      if (resp?.success === false) {\n        lastError = resp?.error ?? 'Failed to open source in VSCode';\n        renderMeta();\n      }\n    } catch (err) {\n      lastError = err instanceof Error ? err.message : String(err);\n      renderMeta();\n    }\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  // Tooltip events - bind directly to elements with data-tip\n  const bindTooltip = (el: HTMLElement) => {\n    disposer.listen(el, 'mouseenter', () => showTooltip(el));\n    disposer.listen(el, 'mouseleave', hideTooltip);\n  };\n  bindTooltip(refreshBtn);\n  bindTooltip(resetBtn);\n  bindTooltip(openSourceBtn);\n\n  disposer.listen(refreshBtn, 'click', (e) => {\n    e.preventDefault();\n    clearAllPendingWrites();\n\n    // Only offer early injection for HOOK_MISSING or HOOK_PRESENT_NO_RENDERERS\n    // (not for RENDERERS_NO_EDITING which is a production build issue)\n    const hookStatus = lastData?.hookStatus;\n    const canBenefitFromEarlyInjection =\n      hookStatus === 'HOOK_MISSING' || hookStatus === 'HOOK_PRESENT_NO_RENDERERS';\n\n    if (lastData?.needsRefresh && canBenefitFromEarlyInjection) {\n      void registerEarlyInjectionAndReload();\n      return;\n    }\n\n    void probeAndRead();\n  });\n\n  disposer.listen(resetBtn, 'click', (e) => {\n    e.preventDefault();\n    void resetOverrides();\n  });\n\n  disposer.listen(openSourceBtn, 'click', (e) => {\n    e.preventDefault();\n    void openSourceInVSCode();\n  });\n\n  // Delegate input events within the list\n  disposer.listen(rows, 'input', (e: Event) => {\n    const target = e.target as HTMLElement | null;\n    if (!(target instanceof HTMLInputElement)) return;\n    if (target.disabled) return;\n    if (target.type === 'checkbox') return;\n\n    const key = target.dataset.propKey ?? '';\n    const kind = target.dataset.propKind ?? '';\n    if (!key || !kind) return;\n    if (isDangerousPropKey(key)) return;\n\n    // Avoid dispatch during IME composition\n    const ie = e as InputEvent;\n    if (ie.isComposing) return;\n\n    if (kind === 'string') {\n      scheduleWrite(key, target.value);\n      return;\n    }\n\n    if (kind === 'number') {\n      const parsed = parseNumberInput(target.value);\n      if (!target.value.trim()) {\n        cancelPendingWrite(key);\n        target.classList.remove('we-props-input--invalid');\n        return;\n      }\n\n      if (!parsed.ok) {\n        cancelPendingWrite(key);\n        target.classList.add('we-props-input--invalid');\n        return;\n      }\n\n      target.classList.remove('we-props-input--invalid');\n      scheduleWrite(key, parsed.value);\n    }\n  });\n\n  disposer.listen(rows, 'change', (e: Event) => {\n    const target = e.target as HTMLElement | null;\n    if (!target) return;\n\n    // Handle Select (enum) change\n    if (target instanceof HTMLSelectElement) {\n      if (target.disabled) return;\n      const key = target.dataset.propKey ?? '';\n      const kind = target.dataset.propKind ?? '';\n      if (!key || kind !== 'enum') return;\n      if (isDangerousPropKey(key)) return;\n      void commitWrite(key, target.value);\n      return;\n    }\n\n    // Handle checkbox change\n    if (!(target instanceof HTMLInputElement)) return;\n    if (target.disabled) return;\n    if (target.type !== 'checkbox') return;\n\n    const key = target.dataset.propKey ?? '';\n    const kind = target.dataset.propKind ?? '';\n    if (!key || kind !== 'boolean') return;\n    if (isDangerousPropKey(key)) return;\n\n    // Update the label text\n    const label = target.closest('.we-props-bool');\n    const text = label?.querySelector?.('span[data-we-bool-text=\"1\"]') as HTMLSpanElement | null;\n    if (text) text.textContent = target.checked ? 'true' : 'false';\n\n    void commitWrite(key, target.checked);\n  });\n\n  disposer.listen(rows, 'keydown', (e: KeyboardEvent) => {\n    const target = e.target as HTMLElement | null;\n    if (!(target instanceof HTMLInputElement)) return;\n    if (target.disabled) return;\n\n    const key = target.dataset.propKey ?? '';\n    const kind = target.dataset.propKind ?? '';\n    if (!key || !kind) return;\n\n    if (e.key === 'Enter') {\n      if (e.isComposing) return;\n      e.preventDefault();\n      flushPendingWrite(key);\n      try {\n        target.blur();\n      } catch {\n        // Best-effort\n      }\n      return;\n    }\n\n    if (e.key === 'Escape') {\n      e.preventDefault();\n      cancelPendingWrite(key);\n\n      const entry = findPropEntry(lastData, key);\n      if (!entry) return;\n      setInputFromEntry(entry, target);\n    }\n  });\n\n  disposer.listen(rows, 'focusout', (e: FocusEvent) => {\n    const target = e.target as HTMLElement | null;\n    if (!(target instanceof HTMLInputElement)) return;\n    if (target.disabled) return;\n    const key = target.dataset.propKey ?? '';\n    const kind = target.dataset.propKind ?? '';\n    if (!key) return;\n\n    // For number inputs, restore last valid value if current is empty/invalid\n    if (kind === 'number') {\n      const parsed = parseNumberInput(target.value);\n      if (!target.value.trim() || !parsed.ok) {\n        cancelPendingWrite(key);\n        target.classList.remove('we-props-input--invalid');\n        const entry = findPropEntry(lastData, key);\n        if (entry) setInputFromEntry(entry, target);\n        return;\n      }\n    }\n\n    flushPendingWrite(key);\n  });\n\n  // ==========================================================================\n  // Public API\n  // ==========================================================================\n\n  function setTarget(element: Element | null): void {\n    if (disposer.isDisposed) return;\n\n    // Flush pending writes to the previous target before switching\n    flushAllPendingWrites();\n\n    sessionId += 1;\n\n    currentTarget = element && element.isConnected ? element : null;\n    currentLocator = currentTarget ? createElementLocator(currentTarget) : null;\n\n    lastData = null;\n    lastError = null;\n    loading = false;\n    needsFetchOnVisible = false;\n\n    renderAll();\n\n    if (isVisible) {\n      void probeAndRead();\n    } else {\n      needsFetchOnVisible = true;\n    }\n  }\n\n  function refresh(): void {\n    if (disposer.isDisposed) return;\n    clearAllPendingWrites();\n    void probeAndRead();\n  }\n\n  function setVisible(visible: boolean): void {\n    if (disposer.isDisposed) return;\n    isVisible = visible;\n    if (visible && needsFetchOnVisible) {\n      void probeAndRead();\n    }\n  }\n\n  function dispose(): void {\n    currentTarget = null;\n    currentLocator = null;\n    lastData = null;\n    lastError = null;\n    loading = false;\n    needsFetchOnVisible = false;\n    disposer.dispose();\n  }\n\n  // Initial render\n  renderAll();\n\n  return {\n    setTarget,\n    refresh,\n    setVisible,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/property-panel/types.ts",
    "content": "/**\n * Property Panel Types\n *\n * Type definitions for the property panel component.\n * The panel displays Design controls and DOM tree for the selected element.\n */\n\nimport type { TransactionManager } from '../../core/transaction-manager';\nimport type { PropsBridge } from '../../core/props-bridge';\nimport type { DesignTokensService } from '../../core/design-tokens';\nimport type { FloatingPosition } from '../floating-drag';\n\n// =============================================================================\n// Tab Types\n// =============================================================================\n\n/** Property panel tab identifiers */\nexport type PropertyPanelTab = 'design' | 'css' | 'props' | 'dom';\n\n// =============================================================================\n// Options Types\n// =============================================================================\n\n/** Options for creating the property panel */\nexport interface PropertyPanelOptions {\n  /** Shadow UI container element (elements.uiRoot from shadow-host) */\n  container: HTMLElement;\n\n  /** Transaction manager for applying style changes with undo/redo support */\n  transactionManager: TransactionManager;\n\n  /** Bridge to the MAIN-world props agent (Phase 7) */\n  propsBridge: PropsBridge;\n\n  /**\n   * Callback when user selects an element from the Components tree (DOM tab).\n   * Used to update the editor's selection state.\n   */\n  onSelectElement: (element: Element) => void;\n\n  /**\n   * Optional callback to close the editor.\n   * If provided, a close button will be shown in the header.\n   */\n  onRequestClose?: () => void;\n\n  /**\n   * Initial floating position (viewport coordinates).\n   * When provided, the panel uses left/top positioning and becomes draggable.\n   */\n  initialPosition?: FloatingPosition | null;\n\n  /**\n   * Called whenever the floating position changes.\n   * Use null to indicate the panel is in its default anchored position.\n   */\n  onPositionChange?: (position: FloatingPosition | null) => void;\n\n  /** Initial tab to display (default: 'design') */\n  defaultTab?: PropertyPanelTab;\n\n  /** Optional: Design tokens service for TokenPill/TokenPicker integration (Phase 5.3) */\n  tokensService?: DesignTokensService;\n}\n\n// =============================================================================\n// Panel Interface\n// =============================================================================\n\n/** Property panel public interface */\nexport interface PropertyPanel {\n  /**\n   * Update the panel to display properties for the given element.\n   * Pass null to show empty state.\n   */\n  setTarget(element: Element | null): void;\n\n  /** Switch to a specific tab */\n  setTab(tab: PropertyPanelTab): void;\n\n  /** Get the currently active tab */\n  getTab(): PropertyPanelTab;\n\n  /** Force refresh the current controls (e.g., after external style change) */\n  refresh(): void;\n\n  /** Get current floating position (viewport coordinates), null when anchored */\n  getPosition(): FloatingPosition | null;\n\n  /** Set floating position (viewport coordinates), pass null to reset to anchored */\n  setPosition(position: FloatingPosition | null): void;\n\n  /** Cleanup and remove the panel */\n  dispose(): void;\n}\n\n// =============================================================================\n// Control Types\n// =============================================================================\n\n/** Common interface for design controls (Size, Spacing, Position, etc.) */\nexport interface DesignControl {\n  /** Update the control to display values for the given element */\n  setTarget(element: Element | null): void;\n\n  /** Refresh control values from current element styles */\n  refresh(): void;\n\n  /** Cleanup the control */\n  dispose(): void;\n}\n\n/** Factory function type for creating design controls */\nexport type DesignControlFactory = (options: {\n  container: HTMLElement;\n  transactionManager: TransactionManager;\n}) => DesignControl;\n\n// =============================================================================\n// Group Types\n// =============================================================================\n\n/** State for a collapsible control group */\nexport interface ControlGroupState {\n  /** Whether the group is collapsed */\n  collapsed: boolean;\n}\n\n/** Collapsible control group interface */\nexport interface ControlGroup {\n  /** The root element of the group */\n  root: HTMLElement;\n\n  /** The body container where controls are mounted */\n  body: HTMLElement;\n\n  /** Optional: Container for header action buttons (e.g., add button) */\n  headerActions?: HTMLElement;\n\n  /** Set collapsed state */\n  setCollapsed(collapsed: boolean): void;\n\n  /** Get current collapsed state */\n  isCollapsed(): boolean;\n\n  /** Toggle collapsed state */\n  toggle(): void;\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/shadow-host.ts",
    "content": "/**\n * Shadow DOM Host\n *\n * Creates an isolated container for the Web Editor UI using Shadow DOM.\n * Provides:\n * - Style isolation (no CSS bleed in/out)\n * - Event isolation (UI events don't bubble to page)\n * - Overlay container for Canvas/visual feedback\n * - UI container for panels/controls\n */\n\nimport {\n  WEB_EDITOR_V2_COLORS,\n  WEB_EDITOR_V2_HOST_ID,\n  WEB_EDITOR_V2_OVERLAY_ID,\n  WEB_EDITOR_V2_UI_ID,\n  WEB_EDITOR_V2_Z_INDEX,\n} from '../constants';\nimport { Disposer } from '../utils/disposables';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Elements exposed by the shadow host */\nexport interface ShadowHostElements {\n  /** The host element attached to the document */\n  host: HTMLDivElement;\n  /** The shadow root */\n  shadowRoot: ShadowRoot;\n  /** Container for overlay elements (Canvas, guides, etc.) */\n  overlayRoot: HTMLDivElement;\n  /** Container for UI elements (panels, toolbar, etc.) */\n  uiRoot: HTMLDivElement;\n}\n\n/** Options for mounting the shadow host (placeholder for future extension) */\nexport type ShadowHostOptions = Record<string, never>;\n\n/** Interface for the shadow host manager */\nexport interface ShadowHostManager {\n  /** Get the shadow host elements (null if not mounted) */\n  getElements(): ShadowHostElements | null;\n  /** Check if a node is part of the editor overlay */\n  isOverlayElement(node: unknown): boolean;\n  /** Check if an event originated from the editor UI */\n  isEventFromUi(event: Event): boolean;\n  /** Dispose and unmount the shadow host */\n  dispose(): void;\n}\n\n// =============================================================================\n// Styles\n// =============================================================================\n\nconst SHADOW_HOST_STYLES = /* css */ `\n  :host {\n    all: initial;\n\n    /* Design tokens aligned with attr-ui.html design spec */\n    /* Surface colors */\n    --we-surface-bg: #ffffff;\n    --we-surface-secondary: #fafafa;\n\n    /* Control colors - input containers use gray bg */\n    --we-control-bg: #f3f3f3;\n    --we-control-bg-hover: #e8e8e8;\n    --we-control-border-hover: #e0e0e0;\n    --we-control-bg-focus: #ffffff;\n    --we-control-border-focus: #3b82f6;\n\n    /* Border colors */\n    --we-border-subtle: #e5e5e5;\n    --we-border-strong: #d4d4d4;\n    --we-border-section: #f3f3f3;\n\n    /* Text colors */\n    --we-text-primary: #333333;\n    --we-text-secondary: #737373;\n    --we-text-muted: #a3a3a3;\n\n    /* Accent surfaces (used by CSS/Props panels) */\n    --we-accent-info-bg: rgba(59, 130, 246, 0.08);\n    --we-accent-brand-bg: rgba(99, 102, 241, 0.12);\n    --we-accent-brand-border: rgba(99, 102, 241, 0.25);\n    --we-accent-warning-bg: rgba(251, 191, 36, 0.14);\n    --we-accent-warning-border: rgba(251, 191, 36, 0.25);\n    --we-accent-danger-bg: rgba(248, 113, 113, 0.12);\n    --we-accent-danger-border: rgba(248, 113, 113, 0.25);\n\n    /* Shadows - Tailwind-like shadow-xl */\n    --we-shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);\n    --we-shadow-panel: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);\n    --we-shadow-tab: 0 1px 2px rgba(0, 0, 0, 0.05);\n\n    /* Radii */\n    --we-radius-panel: 8px;\n    --we-radius-control: 6px;\n    --we-radius-tab: 4px;\n\n    /* Sizes */\n    --we-icon-btn-size: 24px;\n\n    /* Focus ring - blue inset border style */\n    --we-focus-ring: #3b82f6;\n\n    /* Motion - bounce easing for toolbar animations */\n    --we-ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);\n  }\n\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n  }\n\n  /* Overlay container - for Canvas and visual feedback */\n  #${WEB_EDITOR_V2_OVERLAY_ID} {\n    position: fixed;\n    inset: 0;\n    pointer-events: none;\n    contain: layout style;\n  }\n\n  /* ==========================================================================\n   * Resize Handles (Phase 4.9)\n   * ========================================================================== */\n\n  /* Handles layer - covers viewport, pass-through by default */\n  .we-handles-layer {\n    position: absolute;\n    inset: 0;\n    pointer-events: none;\n    contain: layout style paint;\n  }\n\n  /* Selection frame - positioned by selection rect */\n  .we-selection-frame {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 0;\n    height: 0;\n    transform: translate3d(0, 0, 0);\n    pointer-events: none;\n    will-change: transform, width, height;\n  }\n\n  /* Individual resize handle */\n  .we-resize-handle {\n    position: absolute;\n    width: 8px;\n    height: 8px;\n    border-radius: 2px;\n    background: rgba(255, 255, 255, 0.98);\n    border: 1px solid ${WEB_EDITOR_V2_COLORS.selectionBorder};\n    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n    pointer-events: auto;\n    touch-action: none;\n    user-select: none;\n    transition: background-color 0.1s ease, border-color 0.1s ease, transform 0.1s ease;\n  }\n\n  .we-resize-handle:hover {\n    background: ${WEB_EDITOR_V2_COLORS.selectionBorder};\n    border-color: ${WEB_EDITOR_V2_COLORS.selectionBorder};\n    transform: translate(-50%, -50%) scale(1.15);\n  }\n\n  .we-resize-handle:active {\n    transform: translate(-50%, -50%) scale(1.0);\n  }\n\n  /* Handle positions - all use translate(-50%, -50%) as base */\n  .we-resize-handle[data-dir=\"n\"]  { left: 50%; top: 0; transform: translate(-50%, -50%); cursor: ns-resize; }\n  .we-resize-handle[data-dir=\"s\"]  { left: 50%; top: 100%; transform: translate(-50%, -50%); cursor: ns-resize; }\n  .we-resize-handle[data-dir=\"e\"]  { left: 100%; top: 50%; transform: translate(-50%, -50%); cursor: ew-resize; }\n  .we-resize-handle[data-dir=\"w\"]  { left: 0; top: 50%; transform: translate(-50%, -50%); cursor: ew-resize; }\n  .we-resize-handle[data-dir=\"nw\"] { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; }\n  .we-resize-handle[data-dir=\"ne\"] { left: 100%; top: 0; transform: translate(-50%, -50%); cursor: nesw-resize; }\n  .we-resize-handle[data-dir=\"sw\"] { left: 0; top: 100%; transform: translate(-50%, -50%); cursor: nesw-resize; }\n  .we-resize-handle[data-dir=\"se\"] { left: 100%; top: 100%; transform: translate(-50%, -50%); cursor: nwse-resize; }\n\n  /* Size HUD - shows W×H while resizing */\n  .we-size-hud {\n    position: absolute;\n    left: 50%;\n    top: 0;\n    transform: translate(-50%, calc(-100% - 8px));\n    padding: 3px 8px;\n    font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    font-size: 11px;\n    font-weight: 600;\n    line-height: 1.2;\n    color: rgba(255, 255, 255, 0.98);\n    background: rgba(15, 23, 42, 0.92);\n    border: 1px solid rgba(51, 65, 85, 0.5);\n    border-radius: 4px;\n    pointer-events: none;\n    user-select: none;\n    white-space: nowrap;\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n    backdrop-filter: blur(4px);\n    -webkit-backdrop-filter: blur(4px);\n  }\n\n  /* ==========================================================================\n   * Performance HUD (Phase 5.3)\n   * ========================================================================== */\n\n  .we-perf-hud {\n    position: fixed;\n    left: 12px;\n    bottom: 12px;\n    padding: 8px 10px;\n    border-radius: 10px;\n    background: rgba(15, 23, 42, 0.78);\n    border: 1px solid rgba(51, 65, 85, 0.45);\n    color: rgba(255, 255, 255, 0.96);\n    font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    font-size: 12px;\n    line-height: 1.25;\n    pointer-events: none;\n    user-select: none;\n    white-space: nowrap;\n    z-index: 10;\n    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);\n    backdrop-filter: blur(6px);\n    -webkit-backdrop-filter: blur(6px);\n    font-variant-numeric: tabular-nums;\n  }\n\n  .we-perf-hud-line + .we-perf-hud-line {\n    margin-top: 4px;\n  }\n\n  /* UI container - for panels and controls */\n  /* Position below toolbar: 16px (toolbar top) + 40px (toolbar height) + 8px (gap) = 64px */\n  #${WEB_EDITOR_V2_UI_ID} {\n    position: fixed;\n    top: 64px;\n    right: 16px;\n    pointer-events: auto;\n    /* Inter font with system fallbacks (aligned with design spec) */\n    font-family: \"Inter\", system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    font-size: 11px;\n    line-height: 1.4;\n    color: var(--we-text-primary);\n    -webkit-font-smoothing: antialiased;\n  }\n\n  /* Panel styles */\n  /* max-height: 100vh - 64px (top offset) - 16px (bottom margin) = 100vh - 80px */\n  .we-panel {\n    width: 280px;\n    max-width: calc(100vw - 32px);\n    max-height: calc(100vh - 80px);\n    background: var(--we-surface-bg);\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-panel);\n    box-shadow: var(--we-shadow-panel);\n    overflow: hidden;\n    contain: layout style paint;\n  }\n\n  .we-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 8px;\n    padding: 8px 12px;\n    background: var(--we-surface-bg);\n    border-bottom: 1px solid var(--we-border-subtle);\n    user-select: none;\n  }\n\n  .we-title {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    font-size: 11px;\n    font-weight: 600;\n    color: var(--we-text-primary);\n  }\n\n  .we-badge {\n    font-size: 10px;\n    font-weight: 500;\n    padding: 2px 6px;\n    background: linear-gradient(135deg, #6366f1, #8b5cf6);\n    color: white;\n    border-radius: 4px;\n  }\n\n  .we-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: 6px;\n    padding: 6px 12px;\n    font-size: 12px;\n    font-weight: 500;\n    color: #475569;\n    background: white;\n    border: 1px solid rgba(148, 163, 184, 0.5);\n    border-radius: 6px;\n    cursor: pointer;\n    transition: all 0.15s ease;\n  }\n\n  .we-btn:hover {\n    background: #f8fafc;\n    border-color: rgba(148, 163, 184, 0.7);\n  }\n\n  .we-btn:active {\n    background: #f1f5f9;\n  }\n\n  .we-btn:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-btn:disabled {\n    opacity: 0.55;\n    cursor: not-allowed;\n  }\n\n  .we-btn--primary {\n    background: linear-gradient(135deg, #0f172a, #1e293b);\n    color: #ffffff;\n    border-color: rgba(15, 23, 42, 0.5);\n  }\n\n  .we-btn--primary:hover:not(:disabled) {\n    background: linear-gradient(135deg, #1e293b, #334155);\n    border-color: rgba(15, 23, 42, 0.65);\n  }\n\n  .we-btn--danger {\n    color: #b91c1c;\n    border-color: rgba(248, 113, 113, 0.45);\n  }\n\n  .we-btn--danger:hover:not(:disabled) {\n    background: rgba(248, 113, 113, 0.08);\n    border-color: rgba(248, 113, 113, 0.6);\n  }\n\n  /* Icon button (28x28) - used for window controls (close/minimize, etc.) */\n  .we-icon-btn {\n    width: var(--we-icon-btn-size);\n    height: var(--we-icon-btn-size);\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 0;\n    background: var(--we-control-bg);\n    border: 0;\n    border-radius: var(--we-radius-control);\n    color: var(--we-text-secondary);\n    cursor: pointer;\n    transition: background 0.15s ease, box-shadow 0.15s ease;\n  }\n\n  .we-icon-btn:hover {\n    background: var(--we-control-bg-hover);\n    color: var(--we-text-primary);\n  }\n\n  .we-icon-btn:active {\n    background: var(--we-control-bg-hover);\n  }\n\n  .we-icon-btn:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-icon-btn svg {\n    width: 16px;\n    height: 16px;\n    display: block;\n  }\n\n  /* Drag handle (grip) - used for repositioning floating UI */\n  .we-drag-handle {\n    width: var(--we-icon-btn-size);\n    height: var(--we-icon-btn-size);\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n    padding: 0;\n    background: transparent;\n    border: 0;\n    border-radius: var(--we-radius-control);\n    color: var(--we-text-muted);\n    cursor: grab;\n    touch-action: none;\n    user-select: none;\n    transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;\n  }\n\n  .we-drag-handle:hover {\n    background: var(--we-control-bg);\n    color: var(--we-text-secondary);\n  }\n\n  .we-drag-handle:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-drag-handle:active,\n  .we-drag-handle[data-dragging=\"true\"] {\n    cursor: grabbing;\n    background: var(--we-control-bg-hover);\n    color: var(--we-text-primary);\n  }\n\n  .we-drag-handle svg {\n    width: 14px;\n    height: 14px;\n    display: block;\n  }\n\n  /* ==========================================================================\n   * Toolbar (Redesigned per toolbar-ui.html design spec)\n   * - Bounce easing animations\n   * - Collapsible pill (580x40 <-> 40x40)\n   * - Grip icon rotation on collapse\n   * ========================================================================== */\n\n  .we-toolbar {\n    position: fixed;\n    left: 50%;\n    top: 16px;\n    transform: translateX(-50%);\n    width: 580px;\n    height: 40px;\n    max-width: calc(100vw - 32px);\n    display: flex;\n    align-items: center;\n    background: #ffffff;\n    border-radius: 999px;\n    box-shadow: 0 -4px 10px -6px rgba(15, 23, 42, 0.18),\n      0 10px 15px -3px rgba(203, 213, 225, 0.5),\n      0 4px 6px -4px rgba(203, 213, 225, 0.5);\n    pointer-events: auto;\n    user-select: none;\n    font-family: 'Inter', system-ui, -apple-system, sans-serif;\n    font-size: 11px;\n    color: #475569;\n    transition: width 500ms var(--we-ease-bounce), height 500ms var(--we-ease-bounce);\n    overflow: visible;\n    will-change: width, height;\n  }\n\n  .we-toolbar[data-position=\"bottom\"] {\n    top: auto;\n    bottom: 16px;\n  }\n\n  /* Dragged toolbar: use left/top (inline styles) instead of docked centering */\n  .we-toolbar[data-dragged=\"true\"] {\n    left: auto;\n    right: auto;\n    top: auto;\n    bottom: auto;\n    transform: none;\n  }\n\n  /* Collapsed toolbar - 40x40 circle */\n  .we-toolbar[data-minimized=\"true\"] {\n    width: 40px;\n    height: 40px;\n  }\n\n  /* Toolbar content row (collapses with toolbar) */\n  .we-toolbar-content {\n    display: flex;\n    align-items: center;\n    flex: 1;\n    min-width: 0;\n    gap: 10px;\n    white-space: nowrap;\n    padding-right: 8px;\n    transition: opacity 350ms ease, transform 400ms var(--we-ease-bounce);\n    will-change: opacity, transform;\n  }\n\n  .we-toolbar[data-minimized=\"true\"] .we-toolbar-content {\n    opacity: 0;\n    transform: translateX(-16px) scale(0.95);\n    pointer-events: none;\n  }\n\n  .we-toolbar[data-minimized=\"false\"] .we-toolbar-content {\n    opacity: 1;\n    transform: translateX(0) scale(1);\n    pointer-events: auto;\n  }\n\n  /* Grip toggle button (40x40, hover slate-50, active scale-90) */\n  .we-toolbar .we-drag-handle {\n    width: 40px;\n    height: 40px;\n    flex-shrink: 0;\n    border-radius: 999px;\n    cursor: pointer;\n    transition: background-color 150ms ease, transform 150ms ease;\n  }\n\n  .we-toolbar .we-drag-handle:hover {\n    background: #f8fafc;\n  }\n\n  .we-toolbar .we-drag-handle:active {\n    transform: scale(0.9);\n  }\n\n  /* Grip icon rotation (collapsed 90deg -> expanded 0deg) */\n  .we-toolbar .we-drag-handle svg {\n    width: 16px;\n    height: 16px;\n    color: #94a3b8;\n    transition: transform 500ms var(--we-ease-bounce);\n    transform: rotate(0deg);\n  }\n\n  .we-toolbar[data-minimized=\"true\"] .we-drag-handle svg {\n    transform: rotate(90deg);\n  }\n\n  /* Status indicator: green dot + \"Editor\" label */\n  .we-toolbar-indicator {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    padding: 4px 10px;\n    background: #f1f5f9;\n    border-radius: 999px;\n  }\n\n  .we-toolbar-indicator-dot {\n    width: 6px;\n    height: 6px;\n    border-radius: 999px;\n    background: #10b981;\n  }\n\n  .we-toolbar-indicator-label {\n    font-size: 11px;\n    font-weight: 700;\n    color: #334155;\n    letter-spacing: 0.04em;\n  }\n\n  /* Status-driven dot color + pulse */\n  .we-toolbar[data-status=\"progress\"] .we-toolbar-indicator-dot {\n    background: #6366f1;\n    animation: we-toolbar-dot-pulse 1.5s ease-in-out infinite;\n  }\n\n  .we-toolbar[data-status=\"success\"] .we-toolbar-indicator-dot {\n    background: #10b981;\n  }\n\n  .we-toolbar[data-status=\"error\"] .we-toolbar-indicator-dot {\n    background: #ef4444;\n  }\n\n  @keyframes we-toolbar-dot-pulse {\n    0%, 100% { opacity: 1; }\n    50% { opacity: 0.55; }\n  }\n\n  /* Undo/Redo counts */\n  .we-toolbar-history {\n    display: flex;\n    gap: 10px;\n    font-size: 10px;\n    font-weight: 500;\n    color: #94a3b8;\n    font-variant-numeric: tabular-nums;\n  }\n\n  .we-toolbar-history-value {\n    color: #475569;\n    font-weight: 700;\n  }\n\n  /* Divider */\n  .we-toolbar-divider {\n    width: 1px;\n    height: 16px;\n    background: #e2e8f0;\n  }\n\n  /* Structure group (Structure button + divider + Undo/Redo icons) */\n  .we-toolbar-structure-group {\n    display: inline-flex;\n    align-items: center;\n    background: #f1f5f9;\n    border-radius: 999px;\n    padding: 2px;\n  }\n\n  .we-toolbar-structure-btn {\n    display: inline-flex;\n    align-items: center;\n    gap: 4px;\n    padding: 4px 10px;\n    font-size: 11px;\n    font-weight: 500;\n    color: #64748b;\n    background: transparent;\n    border: 0;\n    border-radius: 999px;\n    cursor: pointer;\n    transition: color 150ms ease, background-color 150ms ease;\n  }\n\n  .we-toolbar-structure-btn:hover:not(:disabled) {\n    color: #1e293b;\n    background: #ffffff;\n  }\n\n  .we-toolbar-structure-btn:disabled {\n    opacity: 0.55;\n    cursor: not-allowed;\n  }\n\n  .we-toolbar-structure-btn svg {\n    width: 10px;\n    height: 10px;\n    opacity: 0.5;\n    display: block;\n  }\n\n  .we-toolbar-structure-separator {\n    width: 1px;\n    height: 12px;\n    background: #e2e8f0;\n  }\n\n  .we-toolbar-group-icon-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 4px;\n    background: transparent;\n    border: 0;\n    border-radius: 999px;\n    color: #94a3b8;\n    cursor: pointer;\n    transition: color 150ms ease, background-color 150ms ease;\n  }\n\n  .we-toolbar-group-icon-btn:hover:not(:disabled) {\n    color: #334155;\n    background: #ffffff;\n  }\n\n  .we-toolbar-group-icon-btn:disabled {\n    opacity: 0.45;\n    cursor: not-allowed;\n  }\n\n  .we-toolbar-group-icon-btn svg {\n    width: 14px;\n    height: 14px;\n    display: block;\n  }\n\n  /* End actions container: pushes apply + close to far right */\n  .we-toolbar-end-actions {\n    margin-left: auto;\n    display: inline-flex;\n    align-items: center;\n    gap: 10px;\n  }\n\n  /* Apply button (indigo-500) */\n  .we-toolbar-apply-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 6px 16px;\n    font-size: 11px;\n    font-weight: 700;\n    color: #ffffff;\n    background: #6366f1;\n    border: 0;\n    border-radius: 999px;\n    cursor: pointer;\n    transition: background-color 150ms ease, transform 150ms ease, box-shadow 150ms ease;\n    box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.25),\n      0 2px 4px -2px rgba(99, 102, 241, 0.25);\n  }\n\n  .we-toolbar-apply-btn:hover:not(:disabled) {\n    background: #4f46e5;\n  }\n\n  .we-toolbar-apply-btn:active:not(:disabled) {\n    transform: scale(0.95);\n  }\n\n  .we-toolbar-apply-btn:disabled {\n    opacity: 0.55;\n    cursor: not-allowed;\n    box-shadow: none;\n  }\n\n  /* Close button (red hover) */\n  .we-toolbar-close-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 6px;\n    background: transparent;\n    border: 0;\n    border-radius: 999px;\n    color: #94a3b8;\n    cursor: pointer;\n    transition: color 150ms ease, background-color 150ms ease;\n  }\n\n  .we-toolbar-close-btn:hover:not(:disabled) {\n    color: #ef4444;\n    background: #fef2f2;\n  }\n\n  .we-toolbar-close-btn:disabled {\n    opacity: 0.45;\n    cursor: not-allowed;\n  }\n\n  .we-toolbar-close-btn svg {\n    width: 14px;\n    height: 14px;\n    display: block;\n  }\n\n  /* Screen-reader-only utility */\n  .we-sr-only {\n    position: absolute;\n    width: 1px;\n    height: 1px;\n    padding: 0;\n    margin: -1px;\n    overflow: hidden;\n    clip: rect(0, 0, 0, 0);\n    white-space: nowrap;\n    border: 0;\n  }\n\n  /* Respect reduced motion preference */\n  @media (prefers-reduced-motion: reduce) {\n    .we-toolbar,\n    .we-toolbar-content,\n    .we-toolbar .we-drag-handle,\n    .we-toolbar .we-drag-handle svg,\n    .we-toolbar-structure-btn,\n    .we-toolbar-group-icon-btn,\n    .we-toolbar-apply-btn,\n    .we-toolbar-close-btn {\n      transition: none;\n    }\n  }\n\n  /* ==========================================================================\n     Breadcrumbs (Phase 2.2) - Anchored to selection element\n     ========================================================================== */\n  .we-breadcrumbs {\n    position: fixed;\n    /* left/top set dynamically via JS based on selection rect */\n    left: 16px;\n    top: 72px;\n    width: auto;\n    max-width: min(600px, calc(100vw - 400px));\n    display: flex;\n    align-items: center;\n    gap: 2px;\n    padding: 6px 12px;\n    background: #5494D7;\n    border: none;\n    border-radius: 0;\n    box-shadow: 0 2px 8px rgba(84, 148, 215, 0.3);\n    pointer-events: auto;\n    user-select: none;\n    overflow-x: auto;\n    white-space: nowrap;\n    scrollbar-width: none;\n    z-index: 5;\n  }\n\n  .we-breadcrumbs[data-hidden=\"true\"] {\n    display: none;\n  }\n\n  .we-breadcrumbs[data-position=\"bottom\"] {\n    top: auto;\n    bottom: 72px;\n  }\n\n  .we-breadcrumbs::-webkit-scrollbar {\n    display: none;\n  }\n\n  .we-crumb {\n    display: inline-flex;\n    align-items: center;\n    max-width: 220px;\n    padding: 2px 6px;\n    border-radius: 3px;\n    border: none;\n    background: transparent;\n    color: #ffffff;\n    font-size: 12px;\n    font-weight: 500;\n    line-height: 1.2;\n    cursor: pointer;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    transition: background 0.15s ease;\n  }\n\n  .we-crumb:hover {\n    background: rgba(255, 255, 255, 0.15);\n  }\n\n  .we-crumb:active {\n    background: rgba(255, 255, 255, 0.25);\n  }\n\n  .we-crumb:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);\n  }\n\n  .we-crumb--current {\n    background: rgba(255, 255, 255, 0.2);\n  }\n\n  .we-crumb-sep {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    width: 14px;\n    flex: 0 0 auto;\n    color: rgba(255, 255, 255, 0.7);\n    font-size: 12px;\n  }\n\n  .we-crumb-sep--shadow {\n    color: rgba(255, 255, 255, 0.9);\n  }\n\n  .we-body {\n    padding: 14px;\n    color: #475569;\n    font-size: 12px;\n  }\n\n  .we-status {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 8px 12px;\n    background: rgba(34, 197, 94, 0.1);\n    border-radius: 6px;\n    color: #15803d;\n    font-size: 12px;\n  }\n\n  .we-status-dot {\n    width: 8px;\n    height: 8px;\n    background: #22c55e;\n    border-radius: 50%;\n    animation: pulse 2s ease-in-out infinite;\n  }\n\n  @keyframes pulse {\n    0%, 100% { opacity: 1; }\n    50% { opacity: 0.5; }\n  }\n\n  /* ==========================================================================\n     Property Panel (Phase 3)\n     ========================================================================== */\n\n  .we-prop-panel {\n    display: flex;\n    flex-direction: column;\n    max-height: calc(100vh - 80px);\n  }\n\n  /* Dragged property panel: becomes a floating fixed panel positioned via left/top (inline styles) */\n  .we-prop-panel[data-dragged=\"true\"][data-minimized=\"false\"] {\n    position: fixed;\n    left: auto;\n    right: auto;\n    top: auto;\n    bottom: auto;\n  }\n\n  /* Minimized property panel - becomes a small icon button fixed at top-right */\n  .we-prop-panel[data-minimized=\"true\"] {\n    position: fixed;\n    top: 16px;\n    right: 16px;\n    width: auto;\n    max-height: none;\n    background: transparent;\n    border: 0;\n    box-shadow: none;\n    overflow: visible;\n    z-index: 10;\n  }\n\n  .we-prop-panel[data-minimized=\"true\"] .we-header {\n    padding: 0;\n    background: transparent;\n    border-bottom: 0;\n  }\n\n  /* Symmetric header layout: drag (left) | tabs (center) | minimize (right) */\n  .we-prop-panel .we-header {\n    padding: 8px;\n    gap: 4px;\n  }\n\n  .we-prop-panel .we-header .we-prop-tabs {\n    flex: 1;\n    justify-content: center;\n  }\n\n  /* Minimize button chevron rotation */\n  .we-minimize-btn svg {\n    transition: transform 200ms ease;\n  }\n\n  .we-prop-panel[data-minimized=\"true\"] .we-minimize-btn svg {\n    transform: rotate(180deg);\n  }\n\n  /* Header tooltips: show below to avoid being clipped by panel overflow */\n  .we-prop-panel .we-header [data-tooltip]::after {\n    bottom: auto;\n    top: calc(100% + 6px);\n  }\n\n  .we-prop-panel .we-header [data-tooltip]::before {\n    bottom: auto;\n    top: calc(100% + 2px);\n    border-top-color: transparent;\n    border-bottom-color: var(--we-text-primary);\n  }\n\n  /* Tab container with pill/segmented style (aligned with design spec) */\n  .we-prop-tabs {\n    display: inline-flex;\n    align-items: center;\n    gap: 2px;\n    padding: 2px;\n    background: var(--we-control-bg);\n    border-radius: var(--we-radius-tab);\n  }\n\n  .we-tab {\n    border: 0;\n    background: transparent;\n    color: var(--we-text-secondary);\n    padding: 4px 10px;\n    border-radius: var(--we-radius-tab);\n    cursor: pointer;\n    font-size: 12px;\n    font-weight: 500;\n    transition: all 0.1s ease;\n  }\n\n  .we-tab:hover {\n    color: var(--we-text-primary);\n  }\n\n  .we-tab:focus-visible {\n    outline: none;\n    box-shadow: inset 0 0 0 2px var(--we-focus-ring);\n  }\n\n  /* Active tab: white background with subtle shadow */\n  .we-tab[aria-selected=\"true\"] {\n    background: var(--we-surface-bg);\n    color: var(--we-text-primary);\n    box-shadow: var(--we-shadow-tab);\n  }\n\n  .we-prop-body {\n    flex: 1;\n    overflow-y: auto;\n    overflow-x: hidden;\n    padding: 12px;\n    padding-bottom: 80px; /* Extra space for scrolling (design spec: pb-20) */\n    display: flex;\n    flex-direction: column;\n    gap: 12px;\n    scrollbar-width: none; /* Firefox */\n    -ms-overflow-style: none; /* IE 10+ */\n  }\n\n  /* Hide scrollbar for webkit browsers */\n  .we-prop-body::-webkit-scrollbar {\n    width: 0;\n    height: 0;\n  }\n\n  /* Force hidden state for property panel sections during minimization */\n  .we-prop-body[hidden],\n  .we-prop-tabs[hidden] {\n    display: none;\n  }\n\n  .we-prop-tab-content {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  .we-prop-tab-content[hidden] {\n    display: none;\n  }\n\n  .we-prop-empty {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 24px 12px;\n    color: #64748b;\n    font-size: 12px;\n    text-align: center;\n  }\n\n  .we-prop-empty[hidden] {\n    display: none;\n  }\n\n  .we-prop-panel[data-empty=\"true\"] .we-prop-tab-content {\n    display: none;\n  }\n\n  /* ==========================================================================\n     Components Tree (Phase 3.2)\n     ========================================================================== */\n\n  .we-tree {\n    font-size: 12px;\n    line-height: 1.4;\n  }\n\n  .we-tree-empty {\n    padding: 24px 12px;\n    color: #64748b;\n    text-align: center;\n  }\n\n  .we-tree-empty[hidden] {\n    display: none;\n  }\n\n  .we-tree-list {\n    display: flex;\n    flex-direction: column;\n  }\n\n  .we-tree-list[hidden] {\n    display: none;\n  }\n\n  .we-tree-item {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    padding: 6px 8px;\n    cursor: pointer;\n    border-radius: 4px;\n    transition: background 0.12s;\n    color: #475569;\n  }\n\n  .we-tree-item:hover {\n    background: rgba(59, 130, 246, 0.08);\n  }\n\n  .we-tree-item--selected {\n    background: rgba(59, 130, 246, 0.12);\n    color: #1d4ed8;\n    font-weight: 500;\n  }\n\n  .we-tree-item--selected:hover {\n    background: rgba(59, 130, 246, 0.16);\n  }\n\n  .we-tree-item--ancestor {\n    color: #64748b;\n  }\n\n  .we-tree-item--child {\n    color: #64748b;\n    font-size: 11px;\n  }\n\n  .we-tree-indent {\n    color: #94a3b8;\n    font-family: monospace;\n    user-select: none;\n  }\n\n  .we-tree-icon {\n    flex-shrink: 0;\n    color: #94a3b8;\n    font-size: 10px;\n  }\n\n  .we-tree-item--selected .we-tree-icon {\n    color: #3b82f6;\n  }\n\n  .we-tree-label {\n    flex: 1;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n  }\n\n  /* ==========================================================================\n     Control Groups (Section style - aligned with design spec)\n     Uses separator lines instead of card borders\n     ========================================================================== */\n\n  .we-group {\n    /* No card-style border, use separator lines between sections */\n    border: 0;\n    border-radius: 0;\n    overflow: visible;\n    background: transparent;\n  }\n\n  /* Section separator - top border for non-first groups */\n  .we-group + .we-group {\n    border-top: 1px solid var(--we-border-section);\n    padding-top: 12px;\n    margin-top: 4px;\n  }\n\n  .we-group-header {\n    width: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 6px;\n    padding: 0 0 8px 0;\n    background: transparent;\n  }\n\n  .we-group-toggle {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 6px;\n    padding: 0;\n    background: transparent;\n    border: 0;\n    cursor: pointer;\n    color: #333333;\n    font-size: 11px;\n    font-weight: 600;\n    text-align: left;\n    transition: color 0.1s ease;\n  }\n\n  .we-group-toggle:hover {\n    color: var(--we-text-primary);\n  }\n\n  .we-group-toggle:focus-visible {\n    outline: none;\n    box-shadow: inset 0 0 0 2px var(--we-focus-ring);\n    border-radius: 2px;\n  }\n\n  .we-group-toggle--static {\n    cursor: default;\n    pointer-events: none;\n  }\n\n  .we-group-header-actions {\n    display: flex;\n    align-items: center;\n    gap: 2px;\n    flex: 0 0 auto;\n  }\n\n  .we-group-body {\n    padding: 0;\n    background: transparent;\n    border-top: 0;\n  }\n\n  .we-group[data-collapsed=\"true\"] .we-group-body {\n    display: none;\n  }\n\n  .we-chevron {\n    width: 12px;\n    height: 12px;\n    flex: 0 0 auto;\n    color: var(--we-text-muted);\n    transition: transform 0.1s ease;\n  }\n\n  .we-group[data-collapsed=\"true\"] .we-chevron {\n    transform: rotate(-90deg);\n  }\n\n  /* ==========================================================================\n     Form Controls (for Design controls)\n     ========================================================================== */\n\n  /* Field row: vertical stack (label on top, control below) */\n  .we-field {\n    display: flex;\n    flex-direction: column;\n    align-items: stretch;\n    gap: 4px;\n  }\n\n  /* Horizontal field variant (label left, control right) */\n  .we-field--horizontal {\n    flex-direction: row;\n    align-items: center;\n    gap: 8px;\n  }\n\n  .we-field-label {\n    flex: 0 0 auto;\n    width: auto;\n    font-size: 10px;\n    font-weight: 500;\n    color: var(--we-text-secondary);\n  }\n\n  /* Fixed width label for horizontal layout */\n  .we-field--horizontal .we-field-label {\n    width: 48px;\n  }\n\n  .we-field-label--short {\n    width: 20px;\n  }\n\n  /* Hint text (small label above icon groups for H/V distinction) */\n  .we-field-hint {\n    font-size: 9px;\n    color: var(--we-text-muted);\n    text-align: center;\n    line-height: 1;\n  }\n\n  /* Content container for complex controls (icon groups, grids, etc.) */\n  .we-field-content {\n    width: 100%;\n    min-width: 0;\n  }\n\n  /* Input styling aligned with design spec:\n   * - Gray background by default\n   * - Inset border on hover\n   * - White background + blue inset border on focus\n   */\n  .we-input {\n    flex: 1 1 auto;\n    flex-shrink: 0; /* Prevent height shrinking in column flex containers */\n    min-width: 0;\n    height: 28px; /* Design spec: h-[28px] */\n    padding: 0 8px;\n    font-size: 11px;\n    line-height: 26px; /* Ensure vertical centering: 28px - 2px border */\n    font-family: inherit;\n    color: var(--we-text-primary);\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    outline: none;\n    transition: background 0.1s ease, border-color 0.1s ease, box-shadow 0.1s ease;\n  }\n\n  .we-input::placeholder {\n    color: var(--we-text-muted);\n  }\n\n  .we-input:hover:not(:focus) {\n    border-color: var(--we-control-border-hover);\n  }\n\n  .we-input:focus {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  /* ==========================================================================\n   * Input Container (Phase 2.1)\n   *\n   * A wrapper for inputs with prefix/suffix support.\n   * Container handles hover/focus-within styling instead of input itself.\n   * ========================================================================== */\n  .we-input-container {\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    height: 28px; /* Design spec: h-[28px] - must be explicit, not flex-controlled */\n    flex-shrink: 0; /* Prevent height shrinking in column flex containers */\n    padding: 0 8px;\n    gap: 4px;\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    transition: background 0.1s ease, border-color 0.1s ease, box-shadow 0.1s ease;\n  }\n\n  /* In row flex containers, allow input-container to grow horizontally */\n  .we-field-row > .we-input-container,\n  .we-radius-control .we-field-row > .we-input-container {\n    flex: 1 1 0;\n  }\n\n  .we-input-container:hover:not(:focus-within) {\n    border-color: var(--we-control-border-hover);\n  }\n\n  .we-input-container:focus-within {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  .we-input-container__input {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    padding: 0;\n    font-size: 11px;\n    line-height: 26px; /* Ensure vertical centering within 28px container */\n    font-family: inherit;\n    color: var(--we-text-primary);\n    background: transparent;\n    border: none;\n    outline: none;\n  }\n\n  .we-input-container__input::placeholder {\n    color: var(--we-text-muted);\n  }\n\n  /* Number inputs: right-aligned text in containers */\n  .we-input-container__input[inputmode=\"decimal\"],\n  .we-input-container__input[inputmode=\"numeric\"] {\n    text-align: right;\n  }\n\n  /* Prefix and suffix elements */\n  .we-input-container__prefix,\n  .we-input-container__suffix {\n    flex: 0 0 auto;\n    font-size: 10px;\n    color: var(--we-text-muted);\n    user-select: none;\n    pointer-events: none;\n  }\n\n  .we-input-container__prefix {\n    margin-right: 2px;\n  }\n\n  .we-input-container__suffix {\n    margin-left: 2px;\n  }\n\n  /* Icon in prefix/suffix */\n  .we-input-container__prefix svg,\n  .we-input-container__suffix svg {\n    width: 12px;\n    height: 12px;\n    display: block;\n  }\n\n  /* ==========================================================================\n   * Slider Input (Opacity and other numeric ranges)\n   * ========================================================================== */\n  .we-slider-input {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    width: 100%;\n    min-width: 0;\n  }\n\n  .we-slider-input__slider {\n    flex: 1 1 auto;\n    min-width: 0;\n    height: 28px;\n    margin: 0;\n    padding: 0;\n    background: transparent;\n    -webkit-appearance: none;\n    appearance: none;\n    cursor: pointer;\n  }\n\n  .we-slider-input__slider:disabled {\n    opacity: 0.55;\n    cursor: not-allowed;\n  }\n\n  .we-slider-input__slider::-webkit-slider-runnable-track {\n    height: 4px;\n    background: linear-gradient(\n      to right,\n      var(--we-control-border-focus) 0%,\n      var(--we-control-border-focus) var(--progress, 0%),\n      var(--we-control-bg) var(--progress, 0%),\n      var(--we-control-bg) 100%\n    );\n    border: 1px solid var(--we-control-border-hover);\n    border-radius: 999px;\n  }\n\n  .we-slider-input__slider::-webkit-slider-thumb {\n    -webkit-appearance: none;\n    appearance: none;\n    width: 12px;\n    height: 12px;\n    margin-top: -5px; /* (12px thumb - 4px track) / 2 + border */\n    border-radius: 999px;\n    background: var(--we-control-bg-focus);\n    border: 1px solid var(--we-border-strong);\n    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);\n  }\n\n  .we-slider-input__slider:focus-visible {\n    outline: none;\n  }\n\n  .we-slider-input__slider:focus-visible::-webkit-slider-thumb {\n    border-color: var(--we-control-border-focus);\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);\n  }\n\n  .we-slider-input__slider::-moz-range-track {\n    height: 4px;\n    background: linear-gradient(\n      to right,\n      var(--we-control-border-focus) 0%,\n      var(--we-control-border-focus) var(--progress, 0%),\n      var(--we-control-bg) var(--progress, 0%),\n      var(--we-control-bg) 100%\n    );\n    border: 1px solid var(--we-control-border-hover);\n    border-radius: 999px;\n  }\n\n  .we-slider-input__slider::-moz-range-thumb {\n    width: 12px;\n    height: 12px;\n    border-radius: 999px;\n    background: var(--we-control-bg-focus);\n    border: 1px solid var(--we-border-strong);\n    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);\n  }\n\n  .we-slider-input__slider:focus-visible::-moz-range-thumb {\n    border-color: var(--we-control-border-focus);\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);\n  }\n\n  .we-slider-input__number {\n    flex: 0 0 auto;\n  }\n\n  /* ==========================================================================\n   * Icon Button Group (Phase 4.1)\n   *\n   * A single-select grid of icon buttons (e.g. flex-direction control).\n   * ========================================================================== */\n  .we-icon-button-group {\n    display: grid;\n    gap: 4px;\n  }\n\n  .we-icon-button-group__btn {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    height: 28px; /* Design spec: h-[28px] */\n    padding: 4px;\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    cursor: pointer;\n    transition: background-color 0.1s ease, border-color 0.1s ease;\n  }\n\n  .we-icon-button-group__btn:hover:not(:disabled) {\n    background: var(--we-control-bg-hover);\n  }\n\n  .we-icon-button-group__btn:focus-visible {\n    outline: none;\n    border-color: var(--we-control-border-focus);\n    box-shadow: inset 0 0 0 2px var(--we-control-border-focus); /* Design spec: 2px inset */\n  }\n\n  .we-icon-button-group__btn[data-selected=\"true\"] {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  .we-icon-button-group__btn:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .we-icon-button-group__btn svg {\n    width: 14px;\n    height: 14px;\n    color: var(--we-text-secondary);\n  }\n\n  .we-icon-button-group__btn[data-selected=\"true\"] svg {\n    color: var(--we-control-border-focus);\n  }\n\n  /* ==========================================================================\n   * Toggle Button\n   *\n   * A pressable toggle button (e.g. flip X/Y controls).\n   * ========================================================================== */\n  .we-toggle-btn {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 28px;\n    height: 28px;\n    padding: 4px;\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    cursor: pointer;\n    transition: background-color 0.1s ease, border-color 0.1s ease;\n  }\n\n  .we-toggle-btn:hover:not(:disabled) {\n    background: var(--we-control-bg-hover);\n  }\n\n  .we-toggle-btn[aria-pressed=\"true\"] {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  .we-toggle-btn:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .we-toggle-btn svg {\n    width: 14px;\n    height: 14px;\n    color: var(--we-text-secondary);\n  }\n\n  .we-toggle-btn[aria-pressed=\"true\"] svg {\n    color: var(--we-control-border-focus);\n  }\n\n  /* ==========================================================================\n   * Alignment Grid (Phase 4.2)\n   *\n   * 3×3 single-select grid for justify-content + align-items.\n   * ========================================================================== */\n  .we-alignment-grid {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: 12px;\n    padding: 8px;\n    min-height: 90px;\n    background: #f9f9f9;\n    border: 1px solid #f0f0f0;\n    border-radius: var(--we-radius-control);\n    place-items: center;\n  }\n\n  .we-alignment-grid__cell {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 20px;\n    height: 20px;\n    padding: 0;\n    background: transparent;\n    border: none;\n    border-radius: 2px;\n    cursor: pointer;\n    transition: background-color 0.1s ease;\n  }\n\n  .we-alignment-grid__cell:hover:not(:disabled) {\n    background: rgba(0, 0, 0, 0.05);\n  }\n\n  .we-alignment-grid__cell:focus-visible {\n    outline: 2px solid var(--we-control-border-focus);\n    outline-offset: 1px;\n  }\n\n  .we-alignment-grid__cell:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  /* Inactive dot */\n  .we-alignment-grid__dot {\n    width: 2px;\n    height: 2px;\n    background: var(--we-text-muted);\n    border-radius: 50%;\n  }\n\n  /* Active marker (3 bars showing alignment) */\n  .we-alignment-grid__marker {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: space-between;\n    width: 12px;\n    height: 12px;\n  }\n\n  .we-alignment-grid__bar {\n    height: 2px;\n    background: var(--we-control-border-focus);\n    border-radius: 1px;\n  }\n\n  .we-alignment-grid__bar--1 { width: 8px; }\n  .we-alignment-grid__bar--2 { width: 12px; }\n  .we-alignment-grid__bar--3 { width: 4px; }\n\n  /* ==========================================================================\n   * Grid + Gap Two Column Layout (Layout Control)\n   * ========================================================================== */\n\n  .we-grid-gap-row {\n    display: flex;\n    gap: 8px;\n  }\n\n  .we-grid-gap-col {\n    flex: 1;\n    min-width: 0;\n  }\n\n  /* Keep Grid label space for alignment; hide text only when both columns are visible (grid mode) */\n  .we-grid-gap-col--grid:not([hidden]):has(+ .we-grid-gap-col--gap:not([hidden])) .we-field-label {\n    visibility: hidden;\n  }\n\n  .we-grid-gap-col .we-field-content {\n    width: 100%;\n    overflow: visible;\n  }\n\n  /* Gap inputs vertical layout */\n  .we-grid-gap-inputs {\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n  }\n\n  /* ==========================================================================\n   * Grid Dimensions Picker (Layout Control)\n   * ========================================================================== */\n\n  .we-grid-dimensions-preview {\n    width: 100%;\n    height: 64px; /* Match two rows of gap inputs: 28px + 8px gap + 28px */\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 4px;\n    font-size: 12px;\n    font-family: inherit;\n    color: var(--we-text-primary);\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    cursor: pointer;\n    transition: background-color 0.1s ease, border-color 0.1s ease;\n  }\n\n  .we-grid-dimensions-preview:hover:not(:disabled) {\n    background: var(--we-control-bg-hover);\n  }\n\n  .we-grid-dimensions-preview:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .we-grid-dimensions-popover {\n    position: absolute;\n    top: calc(100% + 4px);\n    left: 0;\n    min-width: 220px;\n    padding: 10px;\n    background: var(--we-surface-bg);\n    border: 1px solid var(--we-border-subtle);\n    border-radius: 8px;\n    box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n    z-index: 60;\n  }\n\n  .we-grid-dimensions-popover[hidden] {\n    display: none;\n  }\n\n  .we-grid-dimensions-inputs {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 8px;\n    margin-bottom: 10px;\n  }\n\n  .we-grid-dimensions-times {\n    font-size: 12px;\n    color: var(--we-text-muted);\n    user-select: none;\n  }\n\n  .we-grid-dimensions-matrix {\n    display: grid;\n    grid-template-columns: repeat(12, 1fr);\n    gap: 3px;\n    padding: 6px;\n    background: var(--we-surface-secondary);\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-control);\n  }\n\n  .we-grid-dimensions-cell {\n    width: 100%;\n    aspect-ratio: 1 / 1;\n    background: transparent;\n    border: 1px solid rgba(0, 0, 0, 0.10);\n    border-radius: 2px;\n    padding: 0;\n    cursor: pointer;\n    transition: background-color 0.08s ease, border-color 0.08s ease;\n  }\n\n  .we-grid-dimensions-cell[data-active=\"true\"] {\n    border-color: rgba(59, 130, 246, 0.65);\n    background: rgba(59, 130, 246, 0.10);\n  }\n\n  .we-grid-dimensions-cell[data-selected=\"true\"] {\n    border-color: rgba(59, 130, 246, 0.9);\n    background: rgba(59, 130, 246, 0.16);\n  }\n\n  .we-grid-dimensions-tooltip {\n    margin-top: 8px;\n    text-align: center;\n    font-size: 11px;\n    color: var(--we-text-secondary);\n  }\n\n  .we-grid-dimensions-tooltip[hidden] {\n    display: none;\n  }\n\n  .we-input--short {\n    width: 56px;\n    flex: 0 0 auto;\n  }\n\n  /* Number inputs: right-aligned text */\n  .we-input[type=\"text\"][inputmode=\"decimal\"],\n  .we-input[type=\"number\"] {\n    text-align: right;\n  }\n\n  .we-select {\n    flex: 1 1 auto;\n    flex-shrink: 0; /* Prevent height shrinking in column flex containers */\n    min-width: 0;\n    height: 28px; /* Design spec: h-[28px] */\n    padding: 0 24px 0 8px;\n    font-size: 11px;\n    line-height: 26px; /* Ensure vertical centering: 28px - 2px border */\n    font-family: inherit;\n    color: var(--we-text-primary);\n    background: var(--we-control-bg) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23737373' d='M2.5 3.5l2.5 3 2.5-3'/%3E%3C/svg%3E\") no-repeat right 8px center;\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    outline: none;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n    appearance: none;\n    cursor: pointer;\n    transition: background-color 0.1s ease, border-color 0.1s ease, box-shadow 0.1s ease;\n  }\n\n  .we-select:hover:not(:focus) {\n    border-color: var(--we-control-border-hover);\n  }\n\n  .we-select:focus {\n    background-color: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  /* Field row for multiple inputs side by side */\n  .we-field-row {\n    display: flex;\n    align-items: stretch;\n    gap: 8px;\n  }\n\n  /* Size field with mode select + input stacked vertically */\n  .we-size-field {\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n    flex: 1;\n    min-width: 0;\n  }\n\n  .we-size-mode-select {\n    width: 100%;\n  }\n\n  .we-field-group {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n  }\n\n  /* ==========================================================================\n     Effects (Box Shadow List)\n     ========================================================================== */\n\n  .we-effects-toolbar {\n    display: flex;\n    justify-content: flex-end;\n    margin-bottom: 6px;\n  }\n\n  .we-effects-list {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n  }\n\n  .we-effects-item-wrap {\n    position: relative;\n  }\n\n  .we-effects-item {\n    height: 28px;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 0 6px;\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    transition: background-color 0.1s ease, border-color 0.1s ease, opacity 0.1s ease;\n  }\n\n  .we-effects-item:hover {\n    background: var(--we-control-bg-hover);\n  }\n\n  .we-effects-item[data-open=\"true\"] {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  .we-effects-item[data-enabled=\"false\"] {\n    opacity: 0.55;\n  }\n\n  .we-effects-name {\n    flex: 1;\n    min-width: 0;\n    padding: 0;\n    border: 0;\n    background: transparent;\n    text-align: left;\n    font-size: 11px;\n    color: var(--we-text-primary);\n    cursor: pointer;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  .we-effects-name:focus-visible {\n    outline: none;\n    box-shadow: inset 0 0 0 2px var(--we-focus-ring);\n    border-radius: 4px;\n  }\n\n  .we-effects-icon-btn {\n    width: 24px;\n    height: 24px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: transparent;\n    border: 0;\n    border-radius: var(--we-radius-control);\n    color: var(--we-text-secondary);\n    cursor: pointer;\n    padding: 0;\n    transition: background-color 0.1s ease, color 0.1s ease;\n  }\n\n  .we-effects-icon-btn:hover:not(:disabled) {\n    background: rgba(0, 0, 0, 0.06);\n    color: var(--we-text-primary);\n  }\n\n  .we-effects-icon-btn:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-effects-icon-btn:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .we-effects-icon-btn svg {\n    width: 14px;\n    height: 14px;\n  }\n\n  .we-effects-popover {\n    position: absolute;\n    top: calc(100% + 6px);\n    left: 0;\n    width: 220px;\n    max-width: 220px;\n    padding: 10px;\n    background: var(--we-surface-bg);\n    border: 1px solid var(--we-border-subtle);\n    border-radius: 8px;\n    box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n    z-index: 60;\n  }\n\n  .we-effects-popover[hidden] {\n    display: none;\n  }\n\n  .we-effects-popover-content {\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n  }\n\n  /* ==========================================================================\n     Gradient Preview Bar (Phase 4B)\n     ========================================================================== */\n\n  .we-gradient-bar-row {\n    width: 100%;\n    padding: 4px 0 8px;\n  }\n\n  .we-gradient-bar {\n    position: relative;\n    width: 100%;\n    height: 60px;\n    border-radius: 14px;\n    border: 1px solid var(--we-border-subtle);\n    background-color: var(--we-control-bg);\n    background-image: none; /* set inline by GradientControl */\n    box-shadow:\n      inset 0 1px 2px rgba(0, 0, 0, 0.08),\n      inset 0 0 0 1px rgba(255, 255, 255, 0.5);\n    overflow: hidden;\n  }\n\n  /* Gradient thumbs container */\n  .we-gradient-bar-thumbs {\n    position: absolute;\n    inset: 0;\n    pointer-events: none; /* thumbs enable pointer events individually */\n  }\n\n  /* Gradient thumb (color stop marker) */\n  .we-gradient-thumb {\n    pointer-events: auto;\n    position: absolute;\n    top: 50%;\n    left: 0;\n    transform: translate(-50%, -50%);\n    z-index: 1;\n    width: 32px;\n    height: 32px;\n    border-radius: 6px;\n    border: 2px solid rgba(255, 255, 255, 0.98);\n    background-color: transparent; /* set inline */\n    cursor: pointer;\n    padding: 0;\n    box-sizing: border-box;\n    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n    touch-action: none;\n    user-select: none;\n    transition: box-shadow 0.15s ease, z-index 0s;\n  }\n\n  .we-gradient-thumb:hover {\n    box-shadow:\n      0 0 0 2px rgba(59, 130, 246, 0.25),\n      0 1px 3px rgba(0, 0, 0, 0.2);\n  }\n\n  .we-gradient-thumb:focus-visible {\n    outline: none;\n    box-shadow:\n      0 0 0 3px rgba(59, 130, 246, 0.4),\n      0 1px 3px rgba(0, 0, 0, 0.2);\n  }\n\n  /* Selected thumb state - raise above unselected thumbs */\n  .we-gradient-thumb--active {\n    z-index: 2;\n    box-shadow:\n      0 0 0 3px rgba(59, 130, 246, 0.4),\n      0 1px 3px rgba(0, 0, 0, 0.2);\n  }\n\n  /* Dragging thumb - always on top when overlapping at same position */\n  .we-gradient-thumb--dragging {\n    z-index: 3;\n  }\n\n  /* Dragging state - cursor feedback on entire bar */\n  .we-gradient-bar--dragging {\n    cursor: grabbing;\n  }\n\n  .we-gradient-bar--dragging .we-gradient-thumb {\n    cursor: grabbing;\n  }\n\n  /* ==========================================================================\n     Gradient Stops List (Phase 4D)\n     ========================================================================== */\n\n  .we-gradient-stops-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 6px 0 4px;\n  }\n\n  .we-gradient-stops-title {\n    font-size: 10px;\n    font-weight: 600;\n    color: var(--we-text-secondary);\n    text-transform: uppercase;\n    letter-spacing: 0.5px;\n  }\n\n  .we-gradient-stops-add,\n  .we-gradient-stop-remove {\n    font-size: 14px;\n    font-weight: 500;\n    line-height: 1;\n  }\n\n  .we-gradient-stops-add:disabled,\n  .we-gradient-stop-remove:disabled {\n    opacity: 0.4;\n    cursor: not-allowed;\n  }\n\n  .we-gradient-stops-list {\n    border: 1px solid var(--we-border-subtle);\n    border-radius: 12px;\n    background: rgba(255, 255, 255, 0.6);\n    padding: 6px;\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n    max-height: 180px;\n    overflow-y: auto;\n    overscroll-behavior: contain;\n  }\n\n  .we-gradient-stop-row {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 5px 6px;\n    border-radius: 8px;\n    border: 1px solid transparent;\n    background: rgba(255, 255, 255, 0.85);\n    cursor: pointer;\n    user-select: none;\n    transition: border-color 0.15s ease, background 0.15s ease;\n  }\n\n  .we-gradient-stop-row:hover {\n    background: rgba(59, 130, 246, 0.06);\n  }\n\n  .we-gradient-stop-row:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);\n  }\n\n  .we-gradient-stop-row--active {\n    border-color: rgba(59, 130, 246, 0.6);\n    box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);\n  }\n\n  .we-gradient-stop-pos {\n    flex: 0 0 auto;\n    min-width: 44px;\n    display: flex;\n    align-items: center;\n    justify-content: flex-end;\n    text-align: right;\n    font-variant-numeric: tabular-nums;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    color: var(--we-text-secondary);\n    padding: 3px 6px;\n    border-radius: 6px;\n    background: var(--we-control-bg);\n    cursor: pointer;\n    transition: box-shadow 0.15s ease;\n  }\n\n  .we-gradient-stop-pos:hover {\n    background: var(--we-control-bg-hover, var(--we-control-bg));\n  }\n\n  .we-gradient-stop-pos:focus-within {\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);\n  }\n\n  /* Static position display (visible when row is not selected) */\n  .we-gradient-stop-pos-static {\n    display: block;\n    width: 100%;\n    text-align: right;\n  }\n\n  /* Position editor slot (visible when row is selected) */\n  .we-gradient-stop-pos-editor {\n    display: none;\n    width: 100%;\n  }\n\n  /* Show editor and hide static in active row */\n  .we-gradient-stop-row--active .we-gradient-stop-pos-static {\n    display: none;\n  }\n\n  .we-gradient-stop-row--active .we-gradient-stop-pos-editor {\n    display: block;\n  }\n\n  /* Position input styling */\n  .we-gradient-stop-pos-input {\n    width: 100%;\n    border: 0;\n    padding: 0;\n    margin: 0;\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    text-align: right;\n    outline: none;\n    cursor: text;\n  }\n\n  .we-gradient-stop-pos-input::placeholder {\n    color: var(--we-text-muted);\n  }\n\n  .we-gradient-stop-color {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 3px 8px;\n    border-radius: 6px;\n    background: var(--we-control-bg);\n  }\n\n  /* Static color display (visible when row is not selected) */\n  .we-gradient-stop-color-static {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 0;\n    border: 0;\n    background: transparent;\n    color: inherit;\n    cursor: pointer;\n    text-align: left;\n    font-family: inherit;\n  }\n\n  .we-gradient-stop-color-static:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);\n    border-radius: 4px;\n  }\n\n  /* Color editor slot (visible when row is selected) */\n  .we-gradient-stop-color-editor {\n    flex: 1;\n    min-width: 0;\n    display: none;\n  }\n\n  /* When row is active: hide static, show editor */\n  .we-gradient-stop-row--active .we-gradient-stop-color {\n    padding: 0;\n    background: transparent;\n  }\n\n  .we-gradient-stop-row--active .we-gradient-stop-color-static {\n    display: none;\n  }\n\n  .we-gradient-stop-row--active .we-gradient-stop-color-editor {\n    display: block;\n  }\n\n  .we-gradient-stop-swatch {\n    flex: 0 0 auto;\n    width: 14px;\n    height: 14px;\n    border-radius: 3px;\n    border: 1px solid rgba(0, 0, 0, 0.12);\n    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);\n    background: transparent;\n  }\n\n  .we-gradient-stop-color-text {\n    flex: 1;\n    min-width: 0;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    color: var(--we-text-primary);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  /* Spacing section (Padding / Margin) */\n  .we-spacing-section {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n  }\n\n  .we-spacing-section + .we-spacing-section {\n    margin-top: 10px;\n  }\n\n  .we-spacing-header {\n    font-size: 10px;\n    font-weight: 600;\n    color: #6b7280;\n  }\n\n  /* Spacing 2x2 grid layout */\n  .we-spacing-grid {\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    gap: 8px;\n  }\n\n  /* ==========================================================================\n   * Border Radius Control\n   * ========================================================================== */\n\n  .we-radius-control {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n  }\n\n  .we-radius-corners-grid {\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    gap: 8px;\n  }\n\n  /* ==========================================================================\n     CSS Panel (Phase 4.6)\n     ========================================================================== */\n\n  .we-css-panel {\n    font-size: 11px;\n    line-height: 1.5;\n  }\n\n  /* Code-semantic elements use monospace font */\n  .we-css-rule-selector,\n  .we-css-decl-name,\n  .we-css-decl-value,\n  .we-css-decl-colon,\n  .we-css-decl-semi,\n  .we-css-decl-important,\n  .we-css-rule-source,\n  .we-css-rule-spec {\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n  }\n\n  /* ==========================================================================\n     Class Editor (Phase 4.7)\n     ========================================================================== */\n\n  .we-css-class-editor-mount {\n    margin-bottom: 12px;\n  }\n\n  .we-class-editor {\n    position: relative;\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: 6px;\n    padding: 8px 10px;\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-panel);\n    background: var(--we-surface-bg);\n    font-family: system-ui, -apple-system, sans-serif;\n  }\n\n  .we-class-chips {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px;\n    flex: 0 1 auto;\n  }\n\n  .we-class-chip {\n    display: inline-flex;\n    align-items: center;\n    gap: 4px;\n    padding: 3px 8px;\n    border-radius: 999px;\n    background: var(--we-accent-brand-bg);\n    border: 1px solid var(--we-accent-brand-border);\n    color: #4338ca;\n    font-size: 11px;\n    line-height: 1.2;\n  }\n\n  .we-class-chip-text {\n    word-break: break-all;\n  }\n\n  .we-class-chip-remove {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    width: 16px;\n    height: 16px;\n    padding: 0;\n    border: none;\n    border-radius: 999px;\n    background: transparent;\n    color: rgba(67, 56, 202, 0.8);\n    cursor: pointer;\n    font-size: 14px;\n    line-height: 1;\n    transition: background-color 0.15s ease;\n  }\n\n  .we-class-chip-remove:hover {\n    background: rgba(99, 102, 241, 0.15);\n    color: #4338ca;\n  }\n\n  .we-class-input {\n    flex: 1 1 100px;\n    min-width: 80px;\n    padding: 5px 8px;\n    font-size: 12px;\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-control);\n    background: var(--we-control-bg-focus);\n    outline: none;\n    transition: border-color 0.15s ease, box-shadow 0.15s ease;\n  }\n\n  .we-class-input:focus {\n    border-color: var(--we-control-border-focus);\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-class-input:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .we-class-input::placeholder {\n    color: #94a3b8;\n  }\n\n  .we-class-suggestions {\n    position: absolute;\n    top: calc(100% + 4px);\n    left: 0;\n    right: 0;\n    background: var(--we-surface-bg);\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-panel);\n    box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n    overflow: hidden;\n    z-index: 20;\n  }\n\n  .we-class-suggestions[hidden] {\n    display: none;\n  }\n\n  .we-class-suggestion {\n    display: block;\n    width: 100%;\n    text-align: left;\n    padding: 8px 10px;\n    border: none;\n    background: transparent;\n    cursor: pointer;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    color: #0f172a;\n    transition: background-color 0.1s ease;\n  }\n\n  .we-class-suggestion:hover {\n    background: rgba(99, 102, 241, 0.08);\n  }\n\n  .we-class-suggestion:focus {\n    outline: none;\n    background: rgba(99, 102, 241, 0.12);\n  }\n\n  .we-css-info {\n    padding: 8px 10px;\n    background: var(--we-accent-info-bg);\n    border-radius: var(--we-radius-control);\n    color: var(--we-text-secondary);\n    font-size: 10px;\n    margin-bottom: 8px;\n  }\n\n  .we-css-info[hidden] {\n    display: none;\n  }\n\n  .we-css-warnings {\n    margin-bottom: 8px;\n  }\n\n  .we-css-warnings[hidden] {\n    display: none;\n  }\n\n  .we-css-warning {\n    padding: 6px 10px;\n    background: var(--we-accent-warning-bg);\n    border: 1px solid var(--we-accent-warning-border);\n    border-radius: var(--we-radius-control);\n    color: #92400e;\n    font-size: 10px;\n    margin-bottom: 4px;\n  }\n\n  .we-css-warning-more {\n    padding: 4px 10px;\n    color: #92400e;\n    font-size: 10px;\n    font-style: italic;\n  }\n\n  .we-css-empty {\n    padding: 24px 12px;\n    color: #64748b;\n    text-align: center;\n    font-family: system-ui, sans-serif;\n    font-size: 12px;\n  }\n\n  .we-css-empty[hidden] {\n    display: none;\n  }\n\n  .we-css-sections {\n    display: flex;\n    flex-direction: column;\n    gap: 0;\n  }\n\n  .we-css-section {\n    border: 0;\n    border-radius: 0;\n    overflow: visible;\n    background: transparent;\n  }\n\n  .we-css-section + .we-css-section {\n    border-top: 1px solid var(--we-border-section);\n    padding-top: 12px;\n    margin-top: 4px;\n  }\n\n  .we-css-section[data-kind=\"inherited\"] {\n    background: transparent;\n  }\n\n  .we-css-section-header {\n    padding: 0 0 8px 0;\n    background: transparent;\n    border-bottom: 0;\n    font-weight: 600;\n    color: var(--we-text-primary);\n    font-size: 11px;\n    text-transform: none;\n    letter-spacing: normal;\n  }\n\n  .we-css-section-rules {\n    padding: 0;\n  }\n\n  /* Flat list style (computed-like view) */\n  .we-css-rule {\n    margin: 0;\n    padding: 0;\n    background: transparent;\n    border: 0;\n    border-radius: 0;\n  }\n\n  .we-css-rule + .we-css-rule {\n    border-top: 1px solid var(--we-border-section);\n    padding-top: 10px;\n    margin-top: 10px;\n  }\n\n  .we-css-rule[data-origin=\"inline\"] {\n    background: transparent;\n  }\n\n  .we-css-rule-header {\n    display: flex;\n    align-items: baseline;\n    gap: 8px;\n    flex-wrap: nowrap;\n    margin-bottom: 8px;\n    padding-bottom: 0;\n    border-bottom: 0;\n  }\n\n  .we-css-rule-selector {\n    flex: 1 1 auto;\n    min-width: 0;\n    font-weight: 500;\n    color: var(--we-text-secondary);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-css-rule[data-origin=\"inline\"] .we-css-rule-selector {\n    color: #92400e;\n    font-style: italic;\n  }\n\n  .we-css-rule-source {\n    flex-shrink: 0;\n    color: var(--we-text-muted);\n    font-size: 10px;\n    margin-left: auto;\n    max-width: 45%;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-css-rule-spec {\n    flex-shrink: 0;\n    color: var(--we-text-muted);\n    font-size: 9px;\n    padding: 1px 4px;\n    background: var(--we-control-bg);\n    border-radius: 3px;\n  }\n\n  .we-css-decls {\n    padding-left: 0;\n  }\n\n  /* Two-column grid layout for declarations */\n  .we-css-decl {\n    display: grid;\n    grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);\n    column-gap: 12px;\n    align-items: baseline;\n    padding: 5px 0;\n    color: var(--we-text-primary);\n  }\n\n\n  .we-css-decl[data-status=\"overridden\"] {\n    text-decoration: line-through;\n    color: var(--we-text-muted);\n  }\n\n  .we-css-decl-name {\n    color: var(--we-text-secondary);\n    overflow-wrap: anywhere;\n  }\n\n  /* Hide punctuation for computed-like view */\n  .we-css-decl-colon,\n  .we-css-decl-semi {\n    display: none;\n  }\n\n  /* Value container for flex layout with !important */\n  .we-css-decl-value-container {\n    display: flex;\n    align-items: baseline;\n    gap: 4px;\n    min-width: 0;\n  }\n\n  .we-css-decl-value {\n    color: var(--we-text-primary);\n    margin-left: 0;\n    min-width: 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-css-decl[data-status=\"overridden\"] .we-css-decl-name,\n  .we-css-decl[data-status=\"overridden\"] .we-css-decl-value {\n    color: var(--we-text-muted);\n  }\n\n  .we-css-decl-important {\n    flex-shrink: 0;\n    color: #dc2626;\n    font-weight: 600;\n    font-size: 10px;\n  }\n\n  .we-css-decl[data-status=\"overridden\"] .we-css-decl-important {\n    color: #b8c4d0;\n  }\n\n  /* ==========================================================================\n   * Token Picker (Phase 5.4)\n   * ========================================================================== */\n\n  .we-token-picker {\n    position: absolute;\n    top: calc(100% + 4px);\n    left: 0;\n    right: 0;\n    background: white;\n    border: 1px solid rgba(226, 232, 240, 0.95);\n    border-radius: 8px;\n    box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n    overflow: hidden;\n    z-index: 30;\n  }\n\n  .we-token-picker[hidden] {\n    display: none;\n  }\n\n  .we-token-filter {\n    width: 100%;\n    padding: 8px 10px;\n    border: none;\n    border-bottom: 1px solid rgba(226, 232, 240, 0.8);\n    background: transparent;\n    font-family: inherit;\n    font-size: 11px;\n    color: #0f172a;\n    outline: none;\n  }\n\n  .we-token-filter::placeholder {\n    color: #94a3b8;\n  }\n\n  .we-token-toggle-row {\n    padding: 6px 10px;\n    border-bottom: 1px solid rgba(226, 232, 240, 0.6);\n    background: rgba(248, 250, 252, 0.5);\n  }\n\n  .we-token-toggle-label {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    font-size: 10px;\n    color: #64748b;\n    cursor: pointer;\n  }\n\n  .we-token-toggle-checkbox {\n    width: 12px;\n    height: 12px;\n    margin: 0;\n    cursor: pointer;\n  }\n\n  .we-token-list {\n    overflow-y: auto;\n    overscroll-behavior: contain;\n  }\n\n  .we-token-list[hidden] {\n    display: none;\n  }\n\n  .we-token-item {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    width: 100%;\n    text-align: left;\n    padding: 8px 10px;\n    border: none;\n    background: transparent;\n    cursor: pointer;\n    transition: background-color 0.1s ease;\n  }\n\n  .we-token-item:hover {\n    background: rgba(99, 102, 241, 0.06);\n  }\n\n  .we-token-item--selected,\n  .we-token-item:focus {\n    outline: none;\n    background: rgba(99, 102, 241, 0.1);\n  }\n\n  .we-token-swatch {\n    flex-shrink: 0;\n    width: 14px;\n    height: 14px;\n    border-radius: 3px;\n    border: 1px solid rgba(0, 0, 0, 0.1);\n    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);\n  }\n\n  .we-token-name {\n    flex: 1;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    color: #0f172a;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-token-value {\n    flex-shrink: 0;\n    max-width: 80px;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 10px;\n    color: #64748b;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-token-empty {\n    padding: 16px 10px;\n    text-align: center;\n    color: #94a3b8;\n    font-size: 11px;\n  }\n\n  .we-token-empty[hidden] {\n    display: none;\n  }\n\n  /* Token button for input fields */\n  .we-token-btn {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 20px;\n    height: 20px;\n    padding: 0;\n    border: none;\n    border-radius: 4px;\n    background: rgba(99, 102, 241, 0.08);\n    color: #6366f1;\n    cursor: pointer;\n    transition: background-color 0.15s ease;\n  }\n\n  .we-token-btn:hover {\n    background: rgba(99, 102, 241, 0.15);\n  }\n\n  .we-token-btn:focus {\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);\n  }\n\n  .we-token-btn-icon {\n    width: 12px;\n    height: 12px;\n  }\n\n  /* ==========================================================================\n   * Token Pill (Phase 5.3)\n   *\n   * Compact pill UI for displaying a CSS var() reference in input fields.\n   * Used when ColorField value is a standalone var(--token) expression.\n   * ========================================================================== */\n\n  .we-token-pill {\n    flex: 1;\n    min-width: 0;\n    height: 28px;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 0 6px 0 4px;\n    background: var(--we-control-bg);\n    border: 1px solid transparent;\n    border-radius: var(--we-radius-control);\n    transition: background 0.1s ease, border-color 0.1s ease;\n  }\n\n  .we-token-pill:hover:not([data-disabled=\"true\"]) {\n    border-color: var(--we-control-border-hover);\n  }\n\n  .we-token-pill:focus-within {\n    background: var(--we-control-bg-focus);\n    border-color: var(--we-control-border-focus);\n  }\n\n  .we-token-pill[data-disabled=\"true\"] {\n    opacity: 0.5;\n    pointer-events: none;\n  }\n\n  .we-token-pill[hidden] {\n    display: none;\n  }\n\n  /* Leading slot: holds external element (ColorField swatch) or internal swatch */\n  .we-token-pill__leading {\n    display: flex;\n    align-items: center;\n    flex: 0 0 auto;\n  }\n\n  /* Internal swatch (used when no external leading element provided) */\n  .we-token-pill__swatch {\n    width: 16px;\n    height: 16px;\n    border-radius: 4px;\n    border: 1px solid rgba(0, 0, 0, 0.1);\n    background: transparent;\n  }\n\n  /* Main clickable area */\n  .we-token-pill__main {\n    flex: 1;\n    min-width: 0;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 8px;\n    padding: 0;\n    border: none;\n    background: transparent;\n    color: var(--we-text-primary);\n    cursor: pointer;\n    font-size: 11px;\n    text-align: left;\n  }\n\n  .we-token-pill__main:focus {\n    outline: none;\n  }\n\n  .we-token-pill__main:disabled {\n    cursor: default;\n  }\n\n  /* Token name with ellipsis */\n  .we-token-pill__name {\n    min-width: 0;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n  }\n\n  /* Link icon (rotated 45° to indicate variable binding) */\n  .we-token-pill__icon {\n    width: 14px;\n    height: 14px;\n    flex: 0 0 auto;\n    color: var(--we-text-muted);\n    transform: rotate(45deg);\n  }\n\n  /* Clear button (hover to reveal) */\n  .we-token-pill__clear {\n    width: 18px;\n    height: 18px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 0;\n    border: none;\n    border-radius: 4px;\n    background: transparent;\n    color: var(--we-text-muted);\n    font-size: 14px;\n    line-height: 1;\n    cursor: pointer;\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.12s ease, background 0.12s ease, color 0.12s ease;\n  }\n\n  .we-token-pill:hover .we-token-pill__clear {\n    opacity: 1;\n    pointer-events: auto;\n  }\n\n  .we-token-pill__clear:hover {\n    background: rgba(15, 23, 42, 0.06);\n    color: var(--we-text-primary);\n  }\n\n  .we-token-pill__clear:focus {\n    outline: none;\n    opacity: 1;\n    pointer-events: auto;\n  }\n\n  .we-token-pill__clear:disabled {\n    cursor: default;\n  }\n\n  /* ==========================================================================\n     Props Panel (Phase 7.3)\n     ========================================================================== */\n\n  .we-props-panel {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  .we-props-meta {\n    padding: 0 0 8px 0;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n  }\n\n  .we-props-meta-title {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 10px;\n    color: var(--we-text-primary);\n    font-size: 11px;\n    font-weight: 600;\n  }\n\n  .we-props-component {\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  .we-props-badge {\n    flex: 0 0 auto;\n    font-size: 10px;\n    font-weight: 600;\n    padding: 2px 6px;\n    border-radius: 999px;\n    background: var(--we-accent-brand-bg);\n    color: #1d4ed8;\n  }\n\n  .we-props-status {\n    font-size: 11px;\n    color: #64748b;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n  }\n\n  .we-props-warning {\n    font-size: 11px;\n    color: #92400e;\n    background: var(--we-accent-warning-bg);\n    border: 1px solid var(--we-accent-warning-border);\n    border-radius: var(--we-radius-control);\n    padding: 6px 8px;\n  }\n\n  .we-props-error {\n    font-size: 11px;\n    color: #b91c1c;\n    background: var(--we-accent-danger-bg);\n    border: 1px solid var(--we-accent-danger-border);\n    border-radius: var(--we-radius-control);\n    padding: 6px 8px;\n  }\n\n  .we-props-source {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 11px;\n    padding: 4px 0;\n  }\n\n  .we-props-source[hidden] {\n    display: none;\n  }\n\n  .we-props-source-label {\n    flex: 0 0 auto;\n    color: #64748b;\n    font-weight: 500;\n  }\n\n  .we-props-source-path {\n    flex: 1 1 auto;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    color: var(--we-text-primary);\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n  }\n\n  .we-btn-small {\n    padding: 2px 8px;\n    font-size: 11px;\n  }\n\n  /* Source open button - minimal link style */\n  .we-props-source-btn {\n    flex: 0 0 auto;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 2px;\n    margin-left: 2px;\n    border: none;\n    background: none;\n    color: #64748b;\n    cursor: pointer;\n    transition: color 0.12s ease;\n  }\n\n  .we-props-source-btn:hover:not(:disabled) {\n    color: #3b82f6;\n  }\n\n  .we-props-source-btn:disabled {\n    opacity: 0.35;\n    cursor: not-allowed;\n  }\n\n  .we-props-source-btn svg {\n    display: block;\n  }\n\n  /* Tooltip - fixed position, mounted at shadow root level */\n  .we-tooltip {\n    position: fixed;\n    transform: translateX(-50%);\n    padding: 4px 8px;\n    font-size: 11px;\n    font-weight: 500;\n    line-height: 1.2;\n    color: #fff;\n    background: rgba(15, 23, 42, 0.92);\n    border-radius: 4px;\n    white-space: nowrap;\n    pointer-events: none;\n    z-index: 10000;\n  }\n\n  .we-tooltip[hidden] {\n    display: none;\n  }\n\n  .we-props-title-left {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    min-width: 0;\n  }\n\n  .we-props-title-actions {\n    display: flex;\n    align-items: center;\n    gap: 2px;\n    margin-left: auto;\n  }\n\n  /* Action button - minimal icon style for title bar */\n  .we-props-action-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    padding: 4px;\n    border: none;\n    background: none;\n    color: var(--we-text-secondary);\n    cursor: pointer;\n    transition: color 0.12s ease;\n  }\n\n  .we-props-action-btn:hover:not(:disabled) {\n    color: var(--we-text-primary);\n  }\n\n  .we-props-action-btn:disabled {\n    opacity: 0.35;\n    cursor: not-allowed;\n  }\n\n  .we-props-action-btn svg {\n    display: block;\n  }\n\n  .we-props-list {\n    overflow: hidden;\n  }\n\n  .we-props-empty {\n    padding: 16px 12px;\n    color: #64748b;\n    font-size: 12px;\n    text-align: center;\n  }\n\n  .we-props-empty[hidden] {\n    display: none;\n  }\n\n  /* Loading animations */\n  @keyframes we-shimmer {\n    to {\n      background-position: 200% center;\n    }\n  }\n\n  @keyframes we-spin {\n    to {\n      transform: rotate(360deg);\n    }\n  }\n\n  .we-props-empty.we-loading {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 8px;\n  }\n\n  .we-props-empty.we-loading svg {\n    flex-shrink: 0;\n    color: #94a3b8;\n  }\n\n  .we-props-empty.we-loading span {\n    background: linear-gradient(\n      90deg,\n      #64748b 0%,\n      #94a3b8 50%,\n      #64748b 100%\n    );\n    background-size: 200% auto;\n    color: transparent;\n    -webkit-background-clip: text;\n    background-clip: text;\n    animation: we-shimmer 2s linear infinite;\n  }\n\n  .we-props-group {\n    padding: 0 0 8px 0;\n    margin-top: 4px;\n    color: var(--we-text-primary);\n    font-size: 11px;\n    font-weight: 600;\n    border-top: 1px solid var(--we-border-section);\n    padding-top: 12px;\n  }\n\n  .we-props-group:first-child {\n    border-top: 0;\n    margin-top: 0;\n    padding-top: 0;\n  }\n\n  .we-props-group + .we-props-row {\n    border-top: 0;\n  }\n\n  .we-props-rows {\n    display: flex;\n    flex-direction: column;\n  }\n\n  .we-props-row {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    padding: 8px 0;\n    border-top: 1px solid var(--we-border-section);\n  }\n\n  .we-props-row:first-child {\n    border-top: 0;\n  }\n\n  .we-props-key {\n    flex: 0 0 110px;\n    min-width: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    color: #334155;\n  }\n\n  .we-props-value {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    justify-content: flex-end;\n    gap: 8px;\n  }\n\n  .we-props-value--readonly {\n    justify-content: flex-start;\n    color: #475569;\n    font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n    font-size: 11px;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .we-props-input {\n    width: 140px;\n    max-width: 100%;\n  }\n\n  .we-props-input--invalid {\n    border-color: rgba(248, 113, 113, 0.85);\n  }\n\n  .we-props-bool {\n    display: inline-flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 11px;\n    color: #475569;\n    cursor: pointer;\n  }\n\n  .we-props-checkbox {\n    width: 14px;\n    height: 14px;\n    accent-color: #6366f1;\n    cursor: pointer;\n  }\n\n  .we-props-checkbox:disabled {\n    cursor: not-allowed;\n    opacity: 0.5;\n  }\n\n  /* ==========================================================================\n   * Color Field (Phase 4.4)\n   * ========================================================================== */\n\n  .we-color-field {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    min-width: 0;\n  }\n\n  .we-color-swatch {\n    flex: 0 0 auto;\n    width: 24px;\n    height: 24px;\n    padding: 0;\n    position: relative;\n    border: 1px solid var(--we-border-subtle);\n    border-radius: var(--we-radius-control);\n    background: var(--we-control-bg);\n    cursor: pointer;\n    transition: border-color 0.15s ease, box-shadow 0.15s ease;\n    overflow: hidden;\n  }\n\n  .we-color-swatch:hover {\n    border-color: var(--we-border-strong);\n  }\n\n  .we-color-swatch:focus-visible,\n  .we-color-swatch:focus-within {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--we-focus-ring);\n  }\n\n  .we-color-swatch:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  /* Native color input overlays the swatch for direct click interaction */\n  .we-color-native-input {\n    position: absolute;\n    inset: 0;\n    width: 100%;\n    height: 100%;\n    opacity: 0;\n    cursor: pointer;\n    border: none;\n    padding: 0;\n    margin: 0;\n  }\n\n  .we-color-text {\n    flex: 1;\n    min-width: 0;\n  }\n\n  /* ==========================================================================\n   * Tooltip (data-tooltip)\n   *\n   * CSS-only tooltips using the data-tooltip attribute.\n   * Shows on hover/focus with minimal delay.\n   * ========================================================================== */\n\n  [data-tooltip] {\n    position: relative;\n  }\n\n  [data-tooltip]::after {\n    content: attr(data-tooltip);\n    position: absolute;\n    bottom: calc(100% + 6px);\n    left: 50%;\n    transform: translateX(-50%);\n    padding: 4px 8px;\n    font-size: 11px;\n    font-family: inherit;\n    font-weight: 400;\n    line-height: 1.3;\n    white-space: nowrap;\n    color: var(--we-surface-bg);\n    background-color: var(--we-text-primary);\n    border-radius: var(--we-radius-control);\n    opacity: 0;\n    visibility: hidden;\n    transition:\n      opacity 100ms ease,\n      visibility 100ms ease;\n    pointer-events: none;\n    z-index: 99999;\n  }\n\n  [data-tooltip]::before {\n    content: '';\n    position: absolute;\n    bottom: calc(100% + 2px);\n    left: 50%;\n    transform: translateX(-50%);\n    border: 4px solid transparent;\n    border-top-color: var(--we-text-primary);\n    opacity: 0;\n    visibility: hidden;\n    transition:\n      opacity 100ms ease,\n      visibility 100ms ease;\n    pointer-events: none;\n    z-index: 99999;\n  }\n\n  [data-tooltip]:hover::after,\n  [data-tooltip]:focus-visible::after,\n  [data-tooltip]:focus-within::after {\n    opacity: 1;\n    visibility: visible;\n  }\n\n  [data-tooltip]:hover::before,\n  [data-tooltip]:focus-visible::before,\n  [data-tooltip]:focus-within::before {\n    opacity: 1;\n    visibility: visible;\n  }\n\n  /* ==========================================================================\n   * Global Hidden Rule\n   * Ensures [hidden] attribute always hides elements, even when they have\n   * explicit display values (flex, inline-flex, etc.)\n   * ========================================================================== */\n  [hidden] {\n    display: none !important;\n  }\n`;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Set a CSS property with !important flag\n */\nfunction setImportantStyle(element: HTMLElement, property: string, value: string): void {\n  element.style.setProperty(property, value, 'important');\n}\n\n// Note: The legacy createPanelContent has been replaced by createPropertyPanel (Phase 3)\n\n/**\n * Mount the Shadow DOM host and return a manager interface\n */\nexport function mountShadowHost(options: ShadowHostOptions = {}): ShadowHostManager {\n  const disposer = new Disposer();\n  let elements: ShadowHostElements | null = null;\n\n  // Clean up any existing host (from crash/reload)\n  const existing = document.getElementById(WEB_EDITOR_V2_HOST_ID);\n  if (existing) {\n    try {\n      existing.remove();\n    } catch {\n      // Best-effort cleanup\n    }\n  }\n\n  // Create host element\n  const host = document.createElement('div');\n  host.id = WEB_EDITOR_V2_HOST_ID;\n  host.setAttribute('data-mcp-web-editor', 'v2');\n\n  // Apply host styles with !important to resist page CSS\n  setImportantStyle(host, 'position', 'fixed');\n  setImportantStyle(host, 'inset', '0');\n  setImportantStyle(host, 'z-index', String(WEB_EDITOR_V2_Z_INDEX));\n  setImportantStyle(host, 'pointer-events', 'none');\n  setImportantStyle(host, 'contain', 'layout style paint');\n  setImportantStyle(host, 'isolation', 'isolate');\n\n  // Create shadow root\n  const shadowRoot = host.attachShadow({ mode: 'open' });\n\n  // Add styles\n  const styleEl = document.createElement('style');\n  styleEl.textContent = SHADOW_HOST_STYLES;\n  shadowRoot.append(styleEl);\n\n  // Create overlay container (for Canvas)\n  const overlayRoot = document.createElement('div');\n  overlayRoot.id = WEB_EDITOR_V2_OVERLAY_ID;\n\n  // Create UI container (for panels)\n  // Note: Property Panel is now created separately by editor.ts (Phase 3)\n  const uiRoot = document.createElement('div');\n  uiRoot.id = WEB_EDITOR_V2_UI_ID;\n\n  shadowRoot.append(overlayRoot, uiRoot);\n\n  // Mount to document\n  const mountPoint = document.documentElement ?? document.body;\n  mountPoint.append(host);\n  disposer.add(() => host.remove());\n\n  elements = { host, shadowRoot, overlayRoot, uiRoot };\n\n  // Event isolation: prevent UI events from bubbling to page\n  const blockedEvents = [\n    'pointerdown',\n    'pointerup',\n    'pointermove',\n    'pointerenter',\n    'pointerleave',\n    'mousedown',\n    'mouseup',\n    'mousemove',\n    'mouseenter',\n    'mouseleave',\n    'click',\n    'dblclick',\n    'contextmenu',\n    'keydown',\n    'keyup',\n    'keypress',\n    'wheel',\n    'touchstart',\n    'touchmove',\n    'touchend',\n    'touchcancel',\n    'focus',\n    'blur',\n    'input',\n    'change',\n  ];\n\n  const stopPropagation = (event: Event) => {\n    event.stopPropagation();\n  };\n\n  for (const eventType of blockedEvents) {\n    disposer.listen(uiRoot, eventType, stopPropagation);\n    // Also block overlay interactions (handles, guides) from bubbling to page\n    // Note: capture-phase listeners on the page cannot be fully prevented\n    disposer.listen(overlayRoot, eventType, stopPropagation);\n  }\n\n  // Helper: check if a node is part of the editor\n  const isOverlayElement = (node: unknown): boolean => {\n    if (!(node instanceof Node)) return false;\n    if (node === host) return true;\n\n    const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;\n    return root instanceof ShadowRoot && root.host === host;\n  };\n\n  // Helper: check if an event came from the editor UI\n  const isEventFromUi = (event: Event): boolean => {\n    try {\n      if (typeof event.composedPath === 'function') {\n        return event.composedPath().some((el) => isOverlayElement(el));\n      }\n    } catch {\n      // Fallback to target\n    }\n    return isOverlayElement(event.target);\n  };\n\n  return {\n    getElements: () => elements,\n    isOverlayElement,\n    isEventFromUi,\n    dispose: () => {\n      elements = null;\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/ui/toolbar.ts",
    "content": "/**\n * Toolbar UI (Phase 1.10, extended in Phase 5.5)\n *\n * Shadow DOM toolbar with Apply / Structure / Undo / Redo / Close buttons.\n * Displays transaction counts and operation status.\n *\n * Design:\n * - Fixed position at top of viewport\n * - Uses CSS classes defined in shadow-host.ts\n * - Disposer pattern for cleanup\n *\n * Phase 5.5 additions:\n * - Structure dropdown menu (Group/Stack/Ungroup/Delete/Duplicate)\n */\n\nimport type { StructureOperationData } from '@/common/web-editor-types';\nimport { Disposer } from '../utils/disposables';\nimport { installFloatingDrag, type FloatingPosition } from './floating-drag';\nimport {\n  createChevronDownSmallIcon,\n  createCloseIcon,\n  createGripIcon,\n  createRedoIcon,\n  createUndoIcon,\n} from './icons';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Toolbar position */\nexport type ToolbarDock = 'top' | 'bottom';\n\n/** Operation status */\nexport type ToolbarStatus =\n  | 'idle'\n  | 'applying'\n  | 'success'\n  | 'error'\n  | 'running'\n  | 'starting'\n  | 'locating'\n  | 'completed'\n  | 'failed'\n  | 'timeout'\n  | 'cancelled'\n  // Phase 4.8: HMR consistency verification statuses\n  | 'verifying'\n  | 'verified'\n  | 'mismatch'\n  | 'lost'\n  | 'uncertain';\n\n/** Result from apply operation */\nexport interface ApplyResult {\n  requestId?: string;\n  sessionId?: string;\n}\n\n/** Toolbar creation options */\nexport interface ToolbarOptions {\n  /** Container element in Shadow DOM */\n  container: HTMLElement;\n  /** Position (default: top) */\n  dock?: ToolbarDock;\n  /**\n   * Initial floating position (viewport coordinates).\n   * When provided, the toolbar uses left/top positioning and becomes draggable.\n   */\n  initialPosition?: FloatingPosition | null;\n  /**\n   * Called whenever the floating position changes.\n   * Use null to indicate the toolbar is in its default docked position.\n   */\n  onPositionChange?: (position: FloatingPosition | null) => void;\n  /** Called when Apply button is clicked */\n  onApply?: () => void | ApplyResult | Promise<void | ApplyResult>;\n  /**\n   * Pre-flight check to block Apply.\n   * Return a non-empty string to disable the Apply button and show as tooltip.\n   * Called during render to update button state.\n   */\n  getApplyBlockReason?: () => string | undefined;\n  /**\n   * Get the currently selected element (Phase 5.5).\n   * Used to enable/disable Structure actions.\n   */\n  getSelectedElement?: () => Element | null;\n  /**\n   * Called when a Structure action is requested (Phase 5.5).\n   */\n  onStructure?: (data: StructureOperationData) => void;\n  /** Called when Undo button is clicked */\n  onUndo?: () => void;\n  /** Called when Redo button is clicked */\n  onRedo?: () => void;\n  /** Called when Close button is clicked */\n  onRequestClose?: () => void;\n}\n\n/** Toolbar public interface */\nexport interface Toolbar {\n  /** Update undo/redo counts */\n  setHistory(undoCount: number, redoCount: number): void;\n  /** Update status display */\n  setStatus(status: ToolbarStatus, message?: string): void;\n  /** Get current floating position (viewport coordinates), null when docked */\n  getPosition(): FloatingPosition | null;\n  /** Set floating position (viewport coordinates), pass null to reset to docked */\n  setPosition(position: FloatingPosition | null): void;\n  /** Cleanup */\n  dispose(): void;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\n/**\n * Check if value is Promise-like\n */\nfunction isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n  return (\n    !!value &&\n    (typeof value === 'object' || typeof value === 'function') &&\n    typeof (value as { then?: unknown }).then === 'function'\n  );\n}\n\n/**\n * Check if value is ApplyResult\n */\nfunction isApplyResult(value: unknown): value is ApplyResult {\n  if (!value || typeof value !== 'object') return false;\n  const req = (value as { requestId?: unknown }).requestId;\n  return req === undefined || typeof req === 'string';\n}\n\n/**\n * Format status message with optional request ID\n */\nfunction formatStatusMessage(base: string, result?: ApplyResult): string {\n  const req = result?.requestId ? `requestId=${result.requestId}` : '';\n  return req ? `${base} (${req})` : base;\n}\n\n// =============================================================================\n// Status Reset Timer\n// =============================================================================\n\nconst STATUS_RESET_DELAY_MS = 2400;\n\n// Status categories for UI styling\nconst SUCCESS_STATUSES: ToolbarStatus[] = ['success', 'completed', 'verified'];\nconst ERROR_STATUSES: ToolbarStatus[] = [\n  'error',\n  'failed',\n  'timeout',\n  'cancelled',\n  'mismatch',\n  'lost',\n  'uncertain',\n];\nconst PROGRESS_STATUSES: ToolbarStatus[] = [\n  'applying',\n  'running',\n  'starting',\n  'locating',\n  'verifying',\n];\n\nfunction getStatusCategory(status: ToolbarStatus): 'idle' | 'progress' | 'success' | 'error' {\n  if (SUCCESS_STATUSES.includes(status)) return 'success';\n  if (ERROR_STATUSES.includes(status)) return 'error';\n  if (PROGRESS_STATUSES.includes(status)) return 'progress';\n  return 'idle';\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Create a Toolbar UI component\n */\nexport function createToolbar(options: ToolbarOptions): Toolbar {\n  const disposer = new Disposer();\n  const dock = options.dock ?? 'top';\n\n  // State\n  let undoCount = 0;\n  let redoCount = 0;\n  let status: ToolbarStatus = 'idle';\n  let statusMessage = '';\n  let applying = false;\n  let resetTimer: number | null = null;\n  let minimized = false;\n  let floatingPosition: FloatingPosition | null = options.initialPosition ?? null;\n\n  // ==========================================================================\n  // DOM Structure\n  // ==========================================================================\n\n  // Root container\n  const root = document.createElement('div');\n  root.className = 'we-toolbar';\n  root.dataset.position = dock;\n  root.dataset.status = status;\n  root.dataset.minimized = 'false';\n  root.dataset.dragged = floatingPosition ? 'true' : 'false';\n  root.dataset.structureOpen = 'false';\n  root.setAttribute('role', 'toolbar');\n  root.setAttribute('aria-label', 'Web Editor Toolbar');\n\n  // ==========================================================================\n  // Grip Toggle Button (unified toggle + drag handle)\n  // ==========================================================================\n\n  const dragHandle = document.createElement('button');\n  dragHandle.type = 'button';\n  dragHandle.className = 'we-drag-handle';\n  dragHandle.setAttribute('aria-label', 'Collapse toolbar');\n  dragHandle.dataset.tooltip = 'Collapse';\n  dragHandle.append(createGripIcon());\n\n  // ==========================================================================\n  // Content Row (collapses with toolbar)\n  // ==========================================================================\n\n  const content = document.createElement('div');\n  content.className = 'we-toolbar-content';\n\n  // Status indicator: green dot + \"Editor\" label\n  const indicator = document.createElement('div');\n  indicator.className = 'we-toolbar-indicator';\n\n  const indicatorDot = document.createElement('span');\n  indicatorDot.className = 'we-toolbar-indicator-dot';\n\n  const indicatorLabel = document.createElement('span');\n  indicatorLabel.className = 'we-toolbar-indicator-label';\n  indicatorLabel.textContent = 'Editor';\n\n  indicator.append(indicatorDot, indicatorLabel);\n\n  // Undo/Redo counts\n  const historyEl = document.createElement('div');\n  historyEl.className = 'we-toolbar-history';\n\n  const undoCountLabel = document.createElement('span');\n  const undoCountValue = document.createElement('b');\n  undoCountValue.className = 'we-toolbar-history-value';\n  undoCountLabel.append('Undo: ', undoCountValue);\n\n  const redoCountLabel = document.createElement('span');\n  const redoCountValue = document.createElement('b');\n  redoCountValue.className = 'we-toolbar-history-value';\n  redoCountLabel.append('Redo: ', redoCountValue);\n\n  historyEl.append(undoCountLabel, redoCountLabel);\n\n  // Divider\n  const divider = document.createElement('div');\n  divider.className = 'we-toolbar-divider';\n\n  // Structure group container\n  const structureGroup = document.createElement('div');\n  structureGroup.className = 'we-toolbar-structure-group';\n\n  // Group separator (between Structure button and Undo/Redo icons)\n  const structureGroupSeparator = document.createElement('div');\n  structureGroupSeparator.className = 'we-toolbar-structure-separator';\n\n  // Apply button\n  const applyBtn = document.createElement('button');\n  applyBtn.type = 'button';\n  applyBtn.className = 'we-toolbar-apply-btn';\n  applyBtn.textContent = 'Apply';\n  applyBtn.setAttribute('aria-label', 'Apply changes to code');\n\n  // Undo button (inside structure group)\n  const undoBtn = document.createElement('button');\n  undoBtn.type = 'button';\n  undoBtn.className = 'we-toolbar-group-icon-btn';\n  undoBtn.setAttribute('aria-label', 'Undo last change');\n  undoBtn.dataset.tooltip = 'Undo';\n  undoBtn.append(createUndoIcon());\n\n  // Redo button (inside structure group)\n  const redoBtn = document.createElement('button');\n  redoBtn.type = 'button';\n  redoBtn.className = 'we-toolbar-group-icon-btn';\n  redoBtn.setAttribute('aria-label', 'Redo last undone change');\n  redoBtn.dataset.tooltip = 'Redo';\n  redoBtn.append(createRedoIcon());\n\n  // Close button\n  const closeBtn = document.createElement('button');\n  closeBtn.type = 'button';\n  closeBtn.className = 'we-toolbar-close-btn';\n  closeBtn.setAttribute('aria-label', 'Close Web Editor');\n  closeBtn.dataset.tooltip = 'Exit Editor';\n  closeBtn.append(createCloseIcon());\n\n  // Hidden status live region (for screen readers)\n  const statusEl = document.createElement('span');\n  statusEl.className = 'we-sr-only';\n  statusEl.setAttribute('aria-live', 'polite');\n\n  // ==========================================================================\n  // Structure Dropdown (Phase 5.5)\n  // ==========================================================================\n\n  type StructureMenuAction = 'group' | 'stack' | 'ungroup' | 'duplicate' | 'delete';\n\n  // Tags that cannot be structure operation targets\n  const DISALLOWED_TARGET_TAGS = new Set(['HTML', 'BODY', 'HEAD']);\n  // Tags that cannot be parent containers for structure operations (BODY is allowed)\n  const DISALLOWED_CONTAINER_TAGS = new Set(['HTML', 'HEAD']);\n  const DEFAULT_STACK_GAP = '10px';\n\n  // Structure dropdown wrapper\n  const structureWrap = document.createElement('div');\n  structureWrap.className = 'we-structure-wrap';\n  structureWrap.style.position = 'relative';\n  structureWrap.style.display = 'inline-flex';\n\n  // Structure trigger button\n  const structureBtn = document.createElement('button');\n  structureBtn.type = 'button';\n  structureBtn.className = 'we-toolbar-structure-btn';\n  structureBtn.setAttribute('aria-label', 'Structure operations');\n  structureBtn.setAttribute('aria-haspopup', 'menu');\n  structureBtn.setAttribute('aria-expanded', 'false');\n  structureBtn.append(document.createTextNode('Structure'), createChevronDownSmallIcon());\n\n  // Structure dropdown menu\n  const structureMenu = document.createElement('div');\n  structureMenu.className = 'we-structure-menu';\n  structureMenu.setAttribute('role', 'menu');\n  structureMenu.setAttribute('aria-label', 'Structure actions');\n  Object.assign(structureMenu.style, {\n    position: 'absolute',\n    top: 'calc(100% + 8px)',\n    right: '0',\n    minWidth: '160px',\n    padding: '6px',\n    background: 'rgba(255, 255, 255, 0.98)',\n    border: '1px solid rgba(148, 163, 184, 0.45)',\n    borderRadius: '10px',\n    boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 10px 20px -5px rgba(0, 0, 0, 0.12)',\n    backdropFilter: 'blur(8px)',\n    display: 'none',\n    flexDirection: 'column',\n    gap: '4px',\n    zIndex: '10001',\n  });\n\n  structureWrap.append(structureBtn, structureMenu);\n\n  // Build StructureOperationData from menu action\n  function buildStructureData(action: StructureMenuAction): StructureOperationData {\n    switch (action) {\n      case 'group':\n        return { action: 'wrap', wrapperTag: 'div' };\n      case 'stack':\n        return {\n          action: 'wrap',\n          wrapperTag: 'div',\n          wrapperStyles: {\n            display: 'flex',\n            'flex-direction': 'column',\n            gap: DEFAULT_STACK_GAP,\n          },\n        };\n      case 'ungroup':\n        return { action: 'unwrap' };\n      case 'duplicate':\n        return { action: 'duplicate' };\n      case 'delete':\n        return { action: 'delete' };\n    }\n  }\n\n  // Create menu item button\n  function createStructureMenuItem(action: StructureMenuAction, label: string): HTMLButtonElement {\n    const btn = document.createElement('button');\n    btn.type = 'button';\n    btn.className = action === 'delete' ? 'we-btn we-btn--danger' : 'we-btn';\n    btn.textContent = label;\n    btn.setAttribute('role', 'menuitem');\n    btn.dataset.action = action;\n    Object.assign(btn.style, {\n      width: '100%',\n      justifyContent: 'flex-start',\n      padding: '6px 10px',\n    });\n    return btn;\n  }\n\n  // Menu items\n  const structureItems: Array<{\n    action: StructureMenuAction;\n    label: string;\n    el: HTMLButtonElement;\n  }> = [\n    { action: 'group', label: 'Group', el: createStructureMenuItem('group', 'Group') },\n    { action: 'stack', label: 'Stack', el: createStructureMenuItem('stack', 'Stack') },\n    { action: 'ungroup', label: 'Ungroup', el: createStructureMenuItem('ungroup', 'Ungroup') },\n    {\n      action: 'duplicate',\n      label: 'Duplicate',\n      el: createStructureMenuItem('duplicate', 'Duplicate'),\n    },\n    { action: 'delete', label: 'Delete', el: createStructureMenuItem('delete', 'Delete') },\n  ];\n\n  for (const item of structureItems) {\n    structureMenu.append(item.el);\n  }\n\n  // Structure menu state\n  let structureOpen = false;\n\n  function setStructureOpen(open: boolean): void {\n    structureOpen = open;\n    structureMenu.style.display = open ? 'flex' : 'none';\n    structureBtn.setAttribute('aria-expanded', open ? 'true' : 'false');\n    // Toggle overflow on toolbar (CSS: overflow: visible when open + expanded)\n    root.dataset.structureOpen = open ? 'true' : 'false';\n  }\n\n  function getSelectedElement(): Element | null {\n    const el = options.getSelectedElement?.() ?? null;\n    return el?.isConnected ? el : null;\n  }\n\n  function isDisallowedTarget(el: Element): boolean {\n    const tag = el.tagName?.toUpperCase();\n    return DISALLOWED_TARGET_TAGS.has(tag);\n  }\n\n  function isDisallowedContainer(el: Element): boolean {\n    const tag = el.tagName?.toUpperCase();\n    return DISALLOWED_CONTAINER_TAGS.has(tag);\n  }\n\n  function getStructureActionBlockReason(\n    action: StructureMenuAction,\n    target: Element | null,\n  ): string | null {\n    if (applying) return 'Operation in progress';\n    if (!options.onStructure) return 'Not configured';\n    if (!target) return 'Select an element first';\n    if (isDisallowedTarget(target)) return 'Cannot edit <html>, <body>, or <head>';\n\n    const parent = target.parentElement;\n\n    switch (action) {\n      case 'group':\n      case 'stack':\n        if (!parent) return 'Element has no parent';\n        if (isDisallowedContainer(parent)) return 'Cannot wrap under <html> or <head>';\n        return null;\n      case 'ungroup':\n        if (!parent) return 'Element has no parent';\n        if (isDisallowedContainer(parent)) return 'Cannot unwrap under <html> or <head>';\n        if (target.childElementCount !== 1) return 'Ungroup requires exactly one child';\n        return null;\n      case 'duplicate':\n      case 'delete':\n        if (!parent) return 'Element has no parent';\n        if (isDisallowedContainer(parent)) return 'Cannot modify under <html> or <head>';\n        return null;\n    }\n  }\n\n  function renderStructureControls(): void {\n    const target = getSelectedElement();\n\n    let anyEnabled = false;\n    for (const item of structureItems) {\n      const reason = getStructureActionBlockReason(item.action, target);\n      const disabled = !!reason;\n      item.el.disabled = disabled;\n      item.el.title = reason ?? '';\n      anyEnabled = anyEnabled || !disabled;\n    }\n\n    structureBtn.disabled = !anyEnabled;\n    structureBtn.title = !anyEnabled\n      ? (getStructureActionBlockReason('group', target) ?? 'Unavailable')\n      : '';\n\n    if (structureBtn.disabled && structureOpen) {\n      setStructureOpen(false);\n    }\n  }\n\n  // Assemble structure group: Structure dropdown + separator + Undo/Redo icons\n  structureGroup.append(structureWrap, structureGroupSeparator, undoBtn, redoBtn);\n\n  // End actions: Apply + Close (pushed to right via margin-left: auto)\n  const endActions = document.createElement('div');\n  endActions.className = 'we-toolbar-end-actions';\n  endActions.append(applyBtn, closeBtn);\n\n  // Assemble content row\n  content.append(indicator, historyEl, divider, structureGroup, endActions);\n\n  // Assemble root: grip + content + hidden status\n  root.append(dragHandle, content, statusEl);\n  options.container.append(root);\n  disposer.add(() => root.remove());\n\n  // ==========================================================================\n  // Floating Drag (Toolbar Position)\n  // ==========================================================================\n\n  const CLAMP_MARGIN_PX = 16;\n\n  function clampToViewport(position: FloatingPosition): FloatingPosition {\n    const rect = root.getBoundingClientRect();\n    const viewportW = window.innerWidth;\n    const viewportH = window.innerHeight;\n\n    const margin = CLAMP_MARGIN_PX;\n    const maxLeft = Math.max(margin, viewportW - margin - rect.width);\n    const maxTop = Math.max(margin, viewportH - margin - rect.height);\n\n    const left = Number.isFinite(position.left) ? position.left : 0;\n    const top = Number.isFinite(position.top) ? position.top : 0;\n\n    return {\n      left: Math.round(Math.min(maxLeft, Math.max(margin, left))),\n      top: Math.round(Math.min(maxTop, Math.max(margin, top))),\n    };\n  }\n\n  function syncFloatingPositionStyles(): void {\n    root.dataset.dragged = floatingPosition ? 'true' : 'false';\n\n    // No floating position: use CSS-defined positioning (centered)\n    if (!floatingPosition) {\n      root.style.left = '';\n      root.style.top = '';\n      root.style.right = '';\n      root.style.bottom = '';\n      root.style.transform = '';\n      return;\n    }\n\n    // Apply floating position (works for both collapsed and expanded states)\n    root.style.left = `${floatingPosition.left}px`;\n    root.style.top = `${floatingPosition.top}px`;\n    root.style.right = 'auto';\n    root.style.bottom = 'auto';\n    root.style.transform = 'none';\n  }\n\n  function setPosition(position: FloatingPosition | null): void {\n    floatingPosition = position ? clampToViewport(position) : null;\n    syncFloatingPositionStyles();\n    options.onPositionChange?.(floatingPosition);\n  }\n\n  function getPosition(): FloatingPosition | null {\n    return floatingPosition;\n  }\n\n  // Install drag behavior with delayed activation (supports short click + long press drag)\n  disposer.add(\n    installFloatingDrag({\n      handleEl: dragHandle,\n      targetEl: root,\n      clampMargin: CLAMP_MARGIN_PX,\n      onPositionChange: (pos) => setPosition(pos),\n      // Delayed activation: short clicks pass through, long press/move activates drag\n      clickThresholdMs: 200,\n      moveThresholdPx: 5,\n    }),\n  );\n\n  // Apply initial position (if provided)\n  if (floatingPosition !== null) {\n    setPosition(floatingPosition);\n  } else {\n    syncFloatingPositionStyles();\n  }\n\n  // ==========================================================================\n  // Timer Management\n  // ==========================================================================\n\n  function clearResetTimer(): void {\n    if (resetTimer !== null) {\n      window.clearTimeout(resetTimer);\n      resetTimer = null;\n    }\n  }\n  disposer.add(clearResetTimer);\n\n  // ==========================================================================\n  // Minimize State\n  // ==========================================================================\n\n  /**\n   * Toggle minimized (collapsed) state of toolbar\n   * Design: toolbar collapses in-place from pill (580x44) to circle (44x44)\n   */\n  function setMinimized(value: boolean): void {\n    const wasMinimized = minimized;\n    minimized = value;\n    root.dataset.minimized = minimized ? 'true' : 'false';\n\n    // Close dropdown before collapsing\n    if (minimized) {\n      setStructureOpen(false);\n    }\n\n    // Re-clamp position on expand to prevent toolbar from overflowing viewport\n    // (user may have dragged collapsed toolbar to edge, expand would cause overflow)\n    if (wasMinimized && !minimized && floatingPosition) {\n      // Immediate clamp for reduced-motion users (no transition)\n      setPosition(floatingPosition);\n\n      // For normal motion, clamp again after transition ends (when size is final)\n      // Use { once: true } to auto-remove listener and prevent leaks\n      root.addEventListener(\n        'transitionend',\n        (event: TransitionEvent) => {\n          if (event.target !== root) return;\n          if (event.propertyName !== 'width' && event.propertyName !== 'height') return;\n          if (!minimized && floatingPosition) {\n            setPosition(floatingPosition);\n          }\n        },\n        { once: true },\n      );\n    }\n\n    // Update grip button label and tooltip (icon rotates via CSS)\n    dragHandle.setAttribute('aria-label', minimized ? 'Expand toolbar' : 'Collapse toolbar');\n    dragHandle.dataset.tooltip = minimized ? 'Expand' : 'Collapse';\n  }\n\n  // ==========================================================================\n  // Render Functions\n  // ==========================================================================\n\n  function renderCounts(): void {\n    undoCountValue.textContent = String(undoCount);\n    redoCountValue.textContent = String(redoCount);\n  }\n\n  function renderButtons(): void {\n    undoBtn.disabled = applying || undoCount <= 0;\n    redoBtn.disabled = applying || redoCount <= 0;\n\n    // Check for apply block reason (e.g., move transaction not supported)\n    const blockReason = options.getApplyBlockReason?.();\n    const isBlocked = !!blockReason;\n\n    applyBtn.disabled = applying || undoCount <= 0 || !options.onApply || isBlocked;\n    applyBtn.textContent = applying ? 'Applying…' : 'Apply';\n    applyBtn.title = isBlocked ? blockReason : '';\n\n    // Update structure menu controls\n    renderStructureControls();\n  }\n\n  function renderStatus(): void {\n    const category = getStatusCategory(status);\n    root.dataset.status = category;\n    root.dataset.statusDetail = status;\n    statusEl.textContent = status === 'idle' ? '' : statusMessage;\n  }\n\n  function scheduleStatusReset(): void {\n    clearResetTimer();\n    resetTimer = window.setTimeout(() => setStatus('idle'), STATUS_RESET_DELAY_MS);\n  }\n\n  // ==========================================================================\n  // Public Methods\n  // ==========================================================================\n\n  function setHistory(nextUndo: number, nextRedo: number): void {\n    undoCount = Math.max(0, Math.floor(nextUndo));\n    redoCount = Math.max(0, Math.floor(nextRedo));\n    renderCounts();\n    renderButtons();\n  }\n\n  function setStatus(nextStatus: ToolbarStatus, message?: string): void {\n    status = nextStatus;\n    statusMessage = (message ?? '').trim();\n    renderStatus();\n\n    const category = getStatusCategory(status);\n    if (category === 'success' || category === 'error') {\n      scheduleStatusReset();\n    } else {\n      clearResetTimer();\n    }\n  }\n\n  // ==========================================================================\n  // Event Handlers\n  // ==========================================================================\n\n  async function handleApply(): Promise<void> {\n    if (applyBtn.disabled) return;\n    if (!options.onApply) return;\n\n    applying = true;\n    renderButtons();\n    setStatus('applying', 'Sending…');\n\n    try {\n      const resultOrPromise = options.onApply();\n      const result = isPromiseLike(resultOrPromise) ? await resultOrPromise : resultOrPromise;\n      const applyResult = isApplyResult(result) ? result : undefined;\n      setStatus('success', formatStatusMessage('Sent', applyResult));\n    } catch (error) {\n      const msg = error instanceof Error ? error.message : String(error);\n      setStatus('error', msg || 'Failed');\n    } finally {\n      applying = false;\n      renderButtons();\n    }\n  }\n\n  // Apply button\n  disposer.listen(applyBtn, 'click', (event) => {\n    event.preventDefault();\n    void handleApply();\n  });\n\n  // Grip click toggles collapsed state (drag uses delayed activation, so short clicks pass through)\n  disposer.listen(dragHandle, 'click', (event) => {\n    event.preventDefault();\n    setMinimized(!minimized);\n  });\n\n  // Structure button - toggle dropdown\n  disposer.listen(structureBtn, 'click', (event) => {\n    event.preventDefault();\n    if (structureBtn.disabled) return;\n    setStructureOpen(!structureOpen);\n  });\n\n  // Structure menu items\n  for (const item of structureItems) {\n    disposer.listen(item.el, 'click', (event) => {\n      event.preventDefault();\n      if (item.el.disabled) return;\n      if (!options.onStructure) return;\n\n      options.onStructure(buildStructureData(item.action));\n      setStructureOpen(false);\n    });\n  }\n\n  // Close structure menu on outside click\n  disposer.listen(\n    window,\n    'pointerdown',\n    (event: PointerEvent) => {\n      if (!structureOpen) return;\n\n      // Check if click is inside the structure wrapper\n      try {\n        if (typeof event.composedPath === 'function') {\n          const inside = event.composedPath().some((n) => n === structureWrap);\n          if (inside) return;\n        }\n      } catch {\n        // fallback\n      }\n\n      const target = event.target;\n      if (target instanceof Node && structureWrap.contains(target)) return;\n\n      setStructureOpen(false);\n    },\n    { capture: true },\n  );\n\n  // Close structure menu on Escape\n  disposer.listen(\n    window,\n    'keydown',\n    (event: KeyboardEvent) => {\n      if (!structureOpen) return;\n      if (event.key !== 'Escape') return;\n      event.preventDefault();\n      event.stopPropagation();\n      setStructureOpen(false);\n    },\n    { capture: true },\n  );\n\n  // Undo button\n  disposer.listen(undoBtn, 'click', (event) => {\n    event.preventDefault();\n    if (undoBtn.disabled) return;\n    options.onUndo?.();\n  });\n\n  // Redo button\n  disposer.listen(redoBtn, 'click', (event) => {\n    event.preventDefault();\n    if (redoBtn.disabled) return;\n    options.onRedo?.();\n  });\n\n  // Close button\n  disposer.listen(closeBtn, 'click', (event) => {\n    event.preventDefault();\n    options.onRequestClose?.();\n  });\n\n  // ==========================================================================\n  // Selection Polling (Phase 5.5)\n  // ==========================================================================\n  // Poll selection changes to keep Structure enable/disable state in sync.\n  // Selection is owned by the editor core; polling avoids expanding the\n  // toolbar public API while keeping UI state accurate.\n\n  const SELECTION_POLL_INTERVAL_MS = 140;\n  let lastSelection: Element | null = null;\n  let selectionPollTimer: number | null = null;\n\n  function scheduleSelectionPoll(): void {\n    if (disposer.isDisposed) return;\n    selectionPollTimer = window.setTimeout(() => {\n      selectionPollTimer = null;\n      const current = getSelectedElement();\n      if (current !== lastSelection) {\n        lastSelection = current;\n        setStructureOpen(false);\n        renderButtons();\n      }\n      scheduleSelectionPoll();\n    }, SELECTION_POLL_INTERVAL_MS);\n  }\n\n  if (options.getSelectedElement) {\n    lastSelection = getSelectedElement();\n    scheduleSelectionPoll();\n    disposer.add(() => {\n      if (selectionPollTimer !== null) {\n        window.clearTimeout(selectionPollTimer);\n        selectionPollTimer = null;\n      }\n    });\n  }\n\n  // Initial render\n  renderCounts();\n  renderButtons();\n  renderStatus();\n\n  // ==========================================================================\n  // Return API\n  // ==========================================================================\n\n  return {\n    setHistory,\n    setStatus,\n    getPosition,\n    setPosition,\n    dispose: () => disposer.dispose(),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2/utils/disposables.ts",
    "content": "/**\n * Disposables Utility\n *\n * Provides deterministic cleanup for event listeners, observers, and other resources.\n * Ensures proper cleanup order (LIFO) and prevents memory leaks.\n */\n\n/** Function that performs cleanup */\nexport type DisposeFn = () => void;\n\n/**\n * Manages a collection of disposable resources.\n * Resources are disposed in reverse order (LIFO).\n */\nexport class Disposer {\n  private disposed = false;\n  private readonly disposers: DisposeFn[] = [];\n\n  /** Whether this disposer has already been disposed */\n  get isDisposed(): boolean {\n    return this.disposed;\n  }\n\n  /**\n   * Add a dispose function to be called during cleanup.\n   * If already disposed, the function is called immediately.\n   */\n  add(dispose: DisposeFn): void {\n    if (this.disposed) {\n      try {\n        dispose();\n      } catch {\n        // Best-effort cleanup for late additions\n      }\n      return;\n    }\n    this.disposers.push(dispose);\n  }\n\n  /**\n   * Add an event listener and automatically remove it on dispose.\n   */\n  listen<K extends keyof WindowEventMap>(\n    target: Window,\n    type: K,\n    listener: (ev: WindowEventMap[K]) => void,\n    options?: boolean | AddEventListenerOptions,\n  ): void;\n  listen<K extends keyof DocumentEventMap>(\n    target: Document,\n    type: K,\n    listener: (ev: DocumentEventMap[K]) => void,\n    options?: boolean | AddEventListenerOptions,\n  ): void;\n  listen<K extends keyof HTMLElementEventMap>(\n    target: HTMLElement,\n    type: K,\n    listener: (ev: HTMLElementEventMap[K]) => void,\n    options?: boolean | AddEventListenerOptions,\n  ): void;\n  listen(\n    target: EventTarget,\n    type: string,\n    listener: EventListenerOrEventListenerObject,\n    options?: boolean | AddEventListenerOptions,\n  ): void;\n  listen(\n    target: EventTarget,\n    type: string,\n    listener: EventListenerOrEventListenerObject,\n    options?: boolean | AddEventListenerOptions,\n  ): void {\n    target.addEventListener(type, listener, options);\n    this.add(() => target.removeEventListener(type, listener, options));\n  }\n\n  /**\n   * Add a ResizeObserver and automatically disconnect it on dispose.\n   */\n  observeResize(\n    target: Element,\n    callback: ResizeObserverCallback,\n    options?: ResizeObserverOptions,\n  ): ResizeObserver {\n    const observer = new ResizeObserver(callback);\n    observer.observe(target, options);\n    this.add(() => observer.disconnect());\n    return observer;\n  }\n\n  /**\n   * Add a MutationObserver and automatically disconnect it on dispose.\n   */\n  observeMutation(\n    target: Node,\n    callback: MutationCallback,\n    options?: MutationObserverInit,\n  ): MutationObserver {\n    const observer = new MutationObserver(callback);\n    observer.observe(target, options);\n    this.add(() => observer.disconnect());\n    return observer;\n  }\n\n  /**\n   * Add a requestAnimationFrame and automatically cancel it on dispose.\n   * Returns a function to manually cancel the frame.\n   */\n  requestAnimationFrame(callback: FrameRequestCallback): () => void {\n    const id = requestAnimationFrame(callback);\n    let cancelled = false;\n\n    const cancel = () => {\n      if (cancelled) return;\n      cancelled = true;\n      cancelAnimationFrame(id);\n    };\n\n    this.add(cancel);\n    return cancel;\n  }\n\n  /**\n   * Dispose all registered resources in reverse order.\n   * Safe to call multiple times.\n   */\n  dispose(): void {\n    if (this.disposed) return;\n    this.disposed = true;\n\n    // Dispose in reverse order (LIFO)\n    for (let i = this.disposers.length - 1; i >= 0; i--) {\n      try {\n        this.disposers[i]();\n      } catch {\n        // Best-effort cleanup, continue with remaining disposers\n      }\n    }\n\n    this.disposers.length = 0;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/web-editor-v2.ts",
    "content": "/**\n * Web Editor V2 - Inject Script Entry Point\n *\n * This is the main entry point for the visual editor, injected into web pages\n * via chrome.scripting.executeScript from the background script.\n *\n * Architecture:\n * - Uses WXT's defineUnlistedScript for TypeScript compilation\n * - Exposes API on window.__MCP_WEB_EDITOR_V2__\n * - Communicates with background via chrome.runtime.onMessage\n *\n * Module structure:\n * - web-editor-v2/constants.ts - Configuration values\n * - web-editor-v2/utils/disposables.ts - Resource cleanup\n * - web-editor-v2/ui/shadow-host.ts - Shadow DOM isolation\n * - web-editor-v2/core/editor.ts - Main orchestrator\n * - web-editor-v2/core/message-listener.ts - Background communication\n *\n * Build output: .output/chrome-mv3/web-editor-v2.js\n */\n\nimport { WEB_EDITOR_V2_LOG_PREFIX } from './web-editor-v2/constants';\nimport { createWebEditorV2 } from './web-editor-v2/core/editor';\nimport { installMessageListener } from './web-editor-v2/core/message-listener';\n\nexport default defineUnlistedScript(() => {\n  // Phase 1: Only support top frame\n  // Phase 4 will add iframe support via content injection\n  if (window !== window.top) {\n    return;\n  }\n\n  // Singleton guard: prevent multiple instances\n  if (window.__MCP_WEB_EDITOR_V2__) {\n    console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Already installed, skipping initialization`);\n    return;\n  }\n\n  // Create and expose the API\n  const api = createWebEditorV2();\n  window.__MCP_WEB_EDITOR_V2__ = api;\n\n  // Install message listener for background communication\n  installMessageListener(api);\n\n  console.log(`${WEB_EDITOR_V2_LOG_PREFIX} Installed successfully`);\n});\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/welcome/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { LINKS, NATIVE_HOST } from '@/common/constants';\n\nimport '../sidepanel/styles/agent-chat.css';\n\nconst COMMANDS = {\n  npmInstall: 'npm install -g mcp-chrome-bridge',\n  pnpmInstall: 'pnpm add -g mcp-chrome-bridge',\n  yarnInstall: 'yarn global add mcp-chrome-bridge',\n  mcpUrl: 'http://127.0.0.1:' + NATIVE_HOST.DEFAULT_PORT + '/mcp',\n  doctor: 'mcp-chrome-bridge doctor',\n  fix: 'mcp-chrome-bridge doctor --fix',\n  report: 'mcp-chrome-bridge report --copy',\n} as const;\n\ntype CommandKey = keyof typeof COMMANDS;\n\nconst copiedKey = ref<CommandKey | null>(null);\n\nconst ALT_INSTALL = [\n  { label: 'pnpm', key: 'pnpmInstall' },\n  { label: 'yarn', key: 'yarnInstall' },\n] as const satisfies ReadonlyArray<{ label: string; key: CommandKey }>;\n\nconst DIAGNOSTICS = [\n  { label: 'Doctor', key: 'doctor' },\n  { label: 'Auto-fix', key: 'fix' },\n] as const satisfies ReadonlyArray<{ label: string; key: CommandKey }>;\n\nfunction copyLabel(key: CommandKey): string {\n  return copiedKey.value === key ? 'Copied' : 'Copy';\n}\n\nfunction copyColor(key: CommandKey): string {\n  return copiedKey.value === key ? 'var(--ac-success)' : 'var(--ac-text-muted)';\n}\n\nasync function copyCommand(key: CommandKey): Promise<void> {\n  try {\n    await navigator.clipboard.writeText(COMMANDS[key]);\n    copiedKey.value = key;\n    window.setTimeout(() => {\n      if (copiedKey.value === key) copiedKey.value = null;\n    }, 2000);\n  } catch (err) {\n    console.error('Failed to copy:', err);\n    copiedKey.value = null;\n  }\n}\n\nasync function openDocs(): Promise<void> {\n  try {\n    await chrome.tabs.create({ url: LINKS.TROUBLESHOOTING });\n  } catch {\n    window.open(LINKS.TROUBLESHOOTING, '_blank', 'noopener,noreferrer');\n  }\n}\n</script>\n\n<template>\n  <div class=\"agent-theme welcome-root\">\n    <div class=\"min-h-screen flex flex-col\">\n      <header class=\"welcome-header flex-none px-6 py-5\">\n        <div class=\"max-w-3xl mx-auto flex items-center justify-between gap-4\">\n          <div class=\"flex items-center gap-3 min-w-0\">\n            <div\n              class=\"welcome-icon w-10 h-10 flex items-center justify-center flex-shrink-0\"\n              aria-hidden=\"true\"\n            >\n              <svg\n                class=\"w-6 h-6\"\n                :style=\"{ color: 'var(--ac-accent)' }\"\n                fill=\"none\"\n                viewBox=\"0 0 24 24\"\n                stroke=\"currentColor\"\n              >\n                <path\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  stroke-width=\"2\"\n                  d=\"M13 10V3L4 14h7v7l9-11h-7z\"\n                />\n              </svg>\n            </div>\n            <div class=\"min-w-0\">\n              <h1 class=\"welcome-title text-lg font-medium tracking-tight truncate\">\n                Chrome MCP Server\n              </h1>\n              <p class=\"welcome-muted text-sm truncate\">\n                After the extension is installed, this is the only required step.\n              </p>\n            </div>\n          </div>\n\n          <button\n            class=\"welcome-button px-3 py-2 text-xs font-medium ac-btn flex-shrink-0\"\n            @click=\"openDocs\"\n          >\n            Troubleshooting Docs\n          </button>\n        </div>\n      </header>\n\n      <main class=\"flex-1 px-6 py-8\">\n        <div class=\"max-w-3xl mx-auto space-y-6\">\n          <section class=\"welcome-card welcome-card--primary p-6\">\n            <h2 class=\"welcome-title text-xl font-medium\">\n              Install <code class=\"welcome-code\">mcp-chrome-bridge</code>\n            </h2>\n            <p class=\"welcome-muted text-sm mt-2\">\n              The Chrome extension uses this local bridge to expose MCP tools to your client.\n            </p>\n\n            <div class=\"mt-4 space-y-3\">\n              <div class=\"welcome-command-row flex items-center justify-between gap-3 px-4 py-3\">\n                <code class=\"welcome-code text-sm break-all\">{{ COMMANDS.npmInstall }}</code>\n                <button\n                  class=\"welcome-mono px-2 py-1 text-xs font-medium ac-btn flex-shrink-0\"\n                  :style=\"{ color: copyColor('npmInstall') }\"\n                  @click=\"copyCommand('npmInstall')\"\n                >\n                  {{ copyLabel('npmInstall') }}\n                </button>\n              </div>\n\n              <div class=\"grid sm:grid-cols-2 gap-3\">\n                <div\n                  v-for=\"item in ALT_INSTALL\"\n                  :key=\"item.key\"\n                  class=\"welcome-alt-row flex items-center justify-between gap-3 px-4 py-3\"\n                >\n                  <div class=\"min-w-0\">\n                    <div\n                      class=\"welcome-mono welcome-subtle text-[10px] uppercase tracking-widest font-medium\"\n                    >\n                      {{ item.label }}\n                    </div>\n                    <code class=\"welcome-code text-xs break-all\">{{ COMMANDS[item.key] }}</code>\n                  </div>\n                  <button\n                    class=\"welcome-mono px-2 py-1 text-xs font-medium ac-btn flex-shrink-0\"\n                    :style=\"{ color: copyColor(item.key) }\"\n                    @click=\"copyCommand(item.key)\"\n                  >\n                    {{ copyLabel(item.key) }}\n                  </button>\n                </div>\n              </div>\n\n              <div class=\"welcome-alt-row welcome-muted px-4 py-3 text-xs\">\n                Requires Node.js 20+. Check your version with\n                <code class=\"welcome-code welcome-code-inline px-1 py-0.5\">node -v</code>.\n              </div>\n            </div>\n\n            <div\n              class=\"mt-6 pt-5\"\n              :style=\"{ borderTop: 'var(--ac-border-width) solid var(--ac-border)' }\"\n            >\n              <h3 class=\"welcome-title text-sm font-medium\">MCP client URL (streamable HTTP)</h3>\n              <p class=\"welcome-muted text-sm mt-1\">\n                Use this URL in your MCP client (e.g., Claude Desktop, CherryStudio).\n              </p>\n\n              <div\n                class=\"welcome-command-row mt-3 flex items-center justify-between gap-3 px-4 py-3\"\n              >\n                <code class=\"welcome-code text-sm break-all\">{{ COMMANDS.mcpUrl }}</code>\n                <button\n                  class=\"welcome-mono px-2 py-1 text-xs font-medium ac-btn flex-shrink-0\"\n                  :style=\"{ color: copyColor('mcpUrl') }\"\n                  @click=\"copyCommand('mcpUrl')\"\n                >\n                  {{ copyLabel('mcpUrl') }}\n                </button>\n              </div>\n\n              <p class=\"welcome-subtle text-xs mt-3\">\n                Tip: You can also open the extension popup and click \"Connect\" to copy a full client\n                config snippet.\n              </p>\n            </div>\n          </section>\n\n          <details class=\"welcome-card overflow-hidden\">\n            <summary\n              class=\"px-6 py-4 cursor-pointer select-none flex items-center justify-between gap-4\"\n            >\n              <div class=\"min-w-0\">\n                <div class=\"welcome-title text-sm font-medium\">Troubleshooting</div>\n                <div class=\"welcome-muted text-xs truncate\">\n                  Use these only if the bridge fails to register or connect.\n                </div>\n              </div>\n              <span class=\"welcome-mono welcome-subtle text-xs flex-shrink-0\">doctor · report</span>\n            </summary>\n\n            <div class=\"px-6 pb-6 space-y-4\">\n              <div class=\"welcome-alt-row p-4\">\n                <div class=\"text-sm font-medium\">Diagnostics</div>\n                <p class=\"welcome-muted text-sm mt-1\">\n                  Run <code class=\"welcome-code\">doctor</code> to check installation status. If it\n                  reports an error, run the auto-fix command.\n                </p>\n\n                <div class=\"mt-3 space-y-2\">\n                  <div\n                    v-for=\"item in DIAGNOSTICS\"\n                    :key=\"item.key\"\n                    class=\"welcome-command-row flex items-center justify-between gap-3 px-3 py-2\"\n                  >\n                    <div class=\"min-w-0\">\n                      <div\n                        class=\"welcome-mono welcome-subtle text-[10px] uppercase tracking-widest font-medium\"\n                      >\n                        {{ item.label }}\n                      </div>\n                      <code class=\"welcome-code text-xs break-all\">{{ COMMANDS[item.key] }}</code>\n                    </div>\n                    <button\n                      class=\"welcome-mono px-2 py-1 text-xs font-medium ac-btn flex-shrink-0\"\n                      :style=\"{ color: copyColor(item.key) }\"\n                      @click=\"copyCommand(item.key)\"\n                    >\n                      {{ copyLabel(item.key) }}\n                    </button>\n                  </div>\n                </div>\n              </div>\n\n              <div class=\"welcome-report-card p-4\">\n                <div class=\"text-sm font-medium\" :style=\"{ color: 'var(--ac-danger)' }\">\n                  Report an issue\n                </div>\n                <p class=\"welcome-muted text-sm mt-1\">\n                  Generate a diagnostic report and paste it into a GitHub issue.\n                </p>\n\n                <div\n                  class=\"welcome-command-row mt-3 flex items-center justify-between gap-3 px-3 py-2\"\n                >\n                  <code class=\"welcome-code text-xs break-all\">{{ COMMANDS.report }}</code>\n                  <button\n                    class=\"welcome-mono px-2 py-1 text-xs font-medium ac-btn flex-shrink-0\"\n                    :style=\"{ color: copyColor('report') }\"\n                    @click=\"copyCommand('report')\"\n                  >\n                    {{ copyLabel('report') }}\n                  </button>\n                </div>\n\n                <p class=\"welcome-subtle text-xs mt-2\">\n                  This copies the report to your clipboard (sensitive info is automatically\n                  redacted).\n                </p>\n              </div>\n\n              <div class=\"flex\">\n                <button\n                  class=\"welcome-button px-3 py-2 text-xs font-medium ac-btn\"\n                  @click=\"openDocs\"\n                >\n                  Open troubleshooting docs\n                </button>\n              </div>\n            </div>\n          </details>\n        </div>\n      </main>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.welcome-root {\n  min-height: 100%;\n  background: var(--ac-bg);\n  background-image: var(--ac-bg-pattern);\n  background-size: var(--ac-bg-pattern-size);\n  color: var(--ac-text);\n  font-family: var(--ac-font-body);\n}\n\n.welcome-header {\n  background: var(--ac-header-bg);\n  border-bottom: var(--ac-border-width) solid var(--ac-header-border);\n  backdrop-filter: blur(8px);\n}\n\n.welcome-card {\n  background: var(--ac-surface);\n  border: var(--ac-border-width) solid var(--ac-border);\n  border-radius: var(--ac-radius-card);\n  box-shadow: var(--ac-shadow-card);\n}\n\n.welcome-card--primary {\n  box-shadow: var(--ac-shadow-float);\n}\n\n.welcome-icon {\n  background: var(--ac-surface);\n  border: var(--ac-border-width) solid var(--ac-border);\n  border-radius: var(--ac-radius-card);\n  box-shadow: var(--ac-shadow-card);\n}\n\n.welcome-title {\n  font-family: var(--ac-font-heading);\n  color: var(--ac-text);\n}\n\n.welcome-muted {\n  color: var(--ac-text-muted);\n}\n\n.welcome-subtle {\n  color: var(--ac-text-subtle);\n}\n\n.welcome-mono {\n  font-family: var(--ac-font-mono);\n}\n\n.welcome-code {\n  font-family: var(--ac-font-code);\n}\n\n.welcome-button {\n  font-family: var(--ac-font-mono);\n  color: var(--ac-text-muted);\n  background: var(--ac-surface);\n  border: var(--ac-border-width) solid var(--ac-border);\n  border-radius: var(--ac-radius-button);\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.welcome-button:hover {\n  background: var(--ac-hover-bg-subtle);\n}\n\n.welcome-command-row {\n  background: var(--ac-code-bg);\n  border: var(--ac-border-width) solid var(--ac-code-border);\n  border-radius: var(--ac-radius-inner);\n}\n\n.welcome-alt-row {\n  background: var(--ac-surface-muted);\n  border: var(--ac-border-width) solid var(--ac-border);\n  border-radius: var(--ac-radius-inner);\n}\n\n.welcome-report-card {\n  background: var(--ac-diff-del-bg);\n  border: var(--ac-border-width) solid var(--ac-diff-del-border);\n  border-radius: var(--ac-radius-inner);\n}\n\n.welcome-code-inline {\n  background: var(--ac-hover-bg-subtle);\n  border: var(--ac-border-width) solid var(--ac-border);\n  border-radius: 6px;\n}\n\n.ac-btn {\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.ac-btn:hover {\n  opacity: 0.8;\n}\n\nsummary {\n  list-style: none;\n}\n\nsummary::-webkit-details-marker {\n  display: none;\n}\n</style>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/welcome/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Welcome to Chrome MCP Server</title>\n    <meta name=\"manifest.type\" content=\"unlisted_page\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"./main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/chrome-extension/entrypoints/welcome/main.ts",
    "content": "import { createApp } from 'vue';\nimport App from './App.vue';\n\n// Tailwind first, then custom tokens\nimport '../styles/tailwind.css';\n\ncreateApp(App).mount('#app');\n"
  },
  {
    "path": "app/chrome-extension/env.d.ts",
    "content": "/// <reference types=\"unplugin-icons/types/vue\" />\ndeclare module '*.vue' {\n  import type { DefineComponent } from 'vue';\n  type Props = Record<string, never>;\n  type RawBindings = Record<string, never>;\n  const component: DefineComponent<Props, RawBindings, any>;\n  export default component;\n}\n"
  },
  {
    "path": "app/chrome-extension/eslint.config.js",
    "content": "import js from '@eslint/js';\nimport globals from 'globals';\nimport tseslint from 'typescript-eslint';\nimport pluginVue from 'eslint-plugin-vue';\nimport { defineConfig } from 'eslint/config';\nimport prettierConfig from 'eslint-config-prettier';\n\nexport default defineConfig([\n  // Global ignores - these apply to all configurations\n  {\n    ignores: [\n      'dist/**',\n      '.output/**',\n      '.wxt/**',\n      'node_modules/**',\n      'logs/**',\n      '*.log',\n      '.cache/**',\n      '.temp/**',\n      '.vscode/**',\n      '!.vscode/extensions.json',\n      '.idea/**',\n      '.DS_Store',\n      'Thumbs.db',\n      '*.zip',\n      '*.tar.gz',\n      'stats.html',\n      'stats-*.json',\n      'libs/**',\n      'workers/**',\n      'public/libs/**',\n    ],\n  },\n  js.configs.recommended,\n  {\n    files: ['**/*.{js,mjs,cjs,ts,vue}'],\n    languageOptions: {\n      globals: {\n        ...globals.browser,\n        chrome: 'readonly',\n      },\n    },\n  },\n  ...tseslint.configs.recommended,\n  {\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-unused-vars': 'off',\n      'no-empty': 'off',\n    },\n  },\n  pluginVue.configs['flat/essential'],\n  { files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser } } },\n  // Prettier configuration - must be placed last to override previous rules\n  prettierConfig,\n]);\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/accessibility-tree-helper.js",
    "content": "/* eslint-disable */\n// accessibility-tree-helper.js\n// Injected script to generate an accessibility-like tree of the visible page\n// Elements receive stable refs (ref_*) via WeakRef mapping for later reference.\n\n(function () {\n  if (window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__) return;\n  window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__ = true;\n\n  // Traversal and output limits to ensure stability on very large/complex pages\n  const MAX_DEPTH = 30; // maximum DOM depth to traverse\n  const MAX_NODES = 4000; // hard limit to avoid long blocking on huge DOMs\n  const MAX_LINE_LABEL = 100; // max characters for a single label in output\n  const REF_MAP_LIMIT = 1000; // limit size of the ref map to keep payload small\n\n  // Keep a weak map from ref id to elements\n  if (!window.__claudeElementMap) window.__claudeElementMap = {};\n  if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n\n  /**\n   * Infer ARIA-like role from element\n   * @param {Element} el\n   * @returns {string}\n   */\n  function inferRole(el) {\n    const role = el.getAttribute('role');\n    if (role) return role;\n    const tag = el.tagName.toLowerCase();\n    const type = el.getAttribute('type') || '';\n    const map = {\n      a: 'link',\n      button: 'button',\n      input:\n        type === 'submit' || type === 'button'\n          ? 'button'\n          : type === 'checkbox'\n            ? 'checkbox'\n            : type === 'radio'\n              ? 'radio'\n              : type === 'file'\n                ? 'button'\n                : 'textbox',\n      select: 'combobox',\n      textarea: 'textbox',\n      h1: 'heading',\n      h2: 'heading',\n      h3: 'heading',\n      h4: 'heading',\n      h5: 'heading',\n      h6: 'heading',\n      img: 'image',\n      nav: 'navigation',\n      main: 'main',\n      header: 'banner',\n      footer: 'contentinfo',\n      section: 'region',\n      article: 'article',\n      aside: 'complementary',\n      form: 'form',\n      table: 'table',\n      ul: 'list',\n      ol: 'list',\n      li: 'listitem',\n      label: 'label',\n    };\n    return map[tag] || 'generic';\n  }\n\n  /**\n   * Derive readable label for element\n   * @param {Element} el\n   * @returns {string}\n   */\n  function inferLabel(el) {\n    const tag = el.tagName.toLowerCase();\n    if (tag === 'select') {\n      const sel = /** @type {HTMLSelectElement} */ (el);\n      const opt = sel.querySelector('option[selected]') || sel.options[sel.selectedIndex];\n      if (opt && opt.textContent) return opt.textContent.trim();\n    }\n    const aria = el.getAttribute('aria-label');\n    if (aria && aria.trim()) return aria.trim();\n    const placeholder = el.getAttribute('placeholder');\n    if (placeholder && placeholder.trim()) return placeholder.trim();\n    const title = el.getAttribute('title');\n    if (title && title.trim()) return title.trim();\n    const alt = el.getAttribute('alt');\n    if (alt && alt.trim()) return alt.trim();\n    if (/** @type {HTMLElement} */ (el).id) {\n      const lab = document.querySelector(`label[for=\"${/** @type {HTMLElement} */ (el).id}\"]`);\n      if (lab && lab.textContent && lab.textContent.trim()) return lab.textContent.trim();\n    }\n    if (tag === 'input') {\n      const input = /** @type {HTMLInputElement} */ (el);\n      const type = input.getAttribute('type') || '';\n      const val = input.getAttribute('value');\n      if (type === 'submit' && val && val.trim()) return val.trim();\n      if (input.value && input.value.length < 50 && input.value.trim()) return input.value.trim();\n    }\n    if (['button', 'a', 'summary'].includes(tag)) {\n      let text = '';\n      for (let i = 0; i < el.childNodes.length; i++) {\n        const n = el.childNodes[i];\n        if (n.nodeType === Node.TEXT_NODE) text += n.textContent || '';\n      }\n      if (text.trim()) return text.trim();\n    }\n    if (/^h[1-6]$/.test(tag)) {\n      const t = el.textContent;\n      if (t && t.trim()) return t.trim().substring(0, MAX_LINE_LABEL);\n    }\n    if (tag === 'img') {\n      const src = el.getAttribute('src');\n      if (src) {\n        const file = src.split('/').pop()?.split('?')[0];\n        return `Image: ${file}`;\n      }\n    }\n    let agg = '';\n    for (let i = 0; i < el.childNodes.length; i++) {\n      const n = el.childNodes[i];\n      if (n.nodeType === Node.TEXT_NODE) agg += n.textContent || '';\n    }\n    if (agg && agg.trim() && agg.trim().length >= 3) {\n      const v = agg.trim();\n      return v.length > 50 ? v.substring(0, 50) + '...' : v;\n    }\n    return '';\n  }\n\n  /**\n   * Check if element is visible in DOM\n   * @param {Element} el\n   */\n  function isVisible(el) {\n    const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el));\n    if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;\n    const he = /** @type {HTMLElement} */ (el);\n    return he.offsetWidth > 0 && he.offsetHeight > 0;\n  }\n\n  /**\n   * Whether the element is interactive\n   * @param {Element} el\n   */\n  function isInteractive(el) {\n    // Native interactive tags\n    const tag = el.tagName.toLowerCase();\n    if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary'].includes(tag))\n      return true;\n\n    // Generic interactive hints\n    if (el.getAttribute('onclick') != null) return true;\n    if (\n      el.getAttribute('tabindex') != null &&\n      String(el.getAttribute('tabindex')).trim() !== '' &&\n      !String(el.getAttribute('tabindex')).trim().startsWith('-')\n    )\n      return true;\n    if (el.getAttribute('contenteditable') === 'true') return true;\n\n    // ARIA roles commonly used by custom elements\n    const role = (el.getAttribute && el.getAttribute('role')) || '';\n    const interactiveRoles = new Set([\n      'button',\n      'link',\n      'checkbox',\n      'radio',\n      'switch',\n      'slider',\n      'option',\n      'menuitem',\n      'textbox',\n      'searchbox',\n      'combobox',\n      'spinbutton',\n      'tab',\n      'treeitem',\n    ]);\n    if (role && interactiveRoles.has(role.toLowerCase())) return true;\n\n    // Shadow host case: treat host as interactive if its open shadow root contains\n    // an interactive control (textarea/input/select/button/a or contenteditable).\n    try {\n      const anyEl = /** @type {any} */ (el);\n      const sr = anyEl && anyEl.shadowRoot ? anyEl.shadowRoot : null;\n      if (sr) {\n        const inner = sr.querySelector(\n          'input, textarea, select, button, a[href], [contenteditable=\"true\"], [role=\"button\"], [role=\"link\"], [role=\"textbox\"], [role=\"combobox\"], [role=\"searchbox\"], [role=\"menuitem\"], [role=\"option\"], [role=\"switch\"], [role=\"radio\"], [role=\"checkbox\"], [role=\"tab\"], [role=\"slider\"]',\n        );\n        if (inner) return true;\n      }\n    } catch (_) {\n      /* ignore */\n    }\n    return false;\n  }\n\n  /**\n   * Structural containers useful to include\n   * @param {Element} el\n   */\n  function isStructural(el) {\n    const tag = el.tagName.toLowerCase();\n    if (\n      [\n        'h1',\n        'h2',\n        'h3',\n        'h4',\n        'h5',\n        'h6',\n        'nav',\n        'main',\n        'header',\n        'footer',\n        'section',\n        'article',\n        'aside',\n      ].includes(tag)\n    )\n      return true;\n    return el.getAttribute('role') != null;\n  }\n\n  /**\n   * Form-ish containers to keep\n   * @param {Element} el\n   */\n  function isFormishContainer(el) {\n    const tag = el.tagName.toLowerCase();\n    const role = (el.getAttribute && el.getAttribute('role')) || '';\n    const id = /** @type {HTMLElement} */ (el).id || '';\n    // Normalize className for HTML/SVG elements\n    let cls = '';\n    try {\n      const attr = el.getAttribute && el.getAttribute('class');\n      if (typeof attr === 'string') cls = attr;\n      else {\n        const cn = /** @type {any} */ (el).className;\n        if (typeof cn === 'string') cls = cn;\n        else if (cn && typeof cn.baseVal === 'string') cls = cn.baseVal;\n      }\n    } catch (e) {\n      /* ignore */\n    }\n    return (\n      role === 'search' ||\n      role === 'form' ||\n      role === 'group' ||\n      role === 'toolbar' ||\n      role === 'navigation' ||\n      tag === 'form' ||\n      tag === 'fieldset' ||\n      tag === 'nav' ||\n      tag === 'legend' ||\n      id.includes('search') ||\n      cls.includes('search') ||\n      id.includes('form') ||\n      cls.includes('form') ||\n      id.includes('menu') ||\n      cls.includes('menu') ||\n      id.includes('nav') ||\n      cls.includes('nav')\n    );\n  }\n\n  // Utility: query CSS across open shadow roots (best-effort)\n  function querySelectorDeepFirst(selector) {\n    try {\n      // Fast path\n      const direct = document.querySelector(selector);\n      if (direct) return direct;\n    } catch (_) {}\n    const visited = new Set();\n    const stack = [document.documentElement];\n    while (stack.length) {\n      const node = stack.pop();\n      if (!node || visited.has(node)) continue;\n      visited.add(node);\n      try {\n        const root = /** @type {any} */ (node).shadowRoot || (node.nodeType === 9 ? node : null);\n        if (root) {\n          try {\n            const hit = root.querySelector(selector);\n            if (hit) return hit;\n          } catch (_) {}\n        }\n      } catch (_) {}\n      // Traverse DOM and shadow roots\n      try {\n        const children = /** @type {Element} */ (node).children || [];\n        for (let i = 0; i < children.length; i++) stack.push(children[i]);\n        const sr = /** @type {any} */ (node).shadowRoot;\n        if (sr && sr.children) {\n          for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);\n        }\n      } catch (_) {}\n    }\n    return null;\n  }\n\n  /**\n   * Query CSS selector and return match info including uniqueness check.\n   * @param {string} selector - CSS selector to query\n   * @param {boolean} allowMultiple - If true, skip uniqueness check and return first match\n   * @returns {{element: Element | null, matchCount: number, error?: string}}\n   * Note: matchCount is capped at 2 (where 2 means \"2 or more\") for performance\n   */\n  function querySelectorWithUniquenessCheck(selector, allowMultiple = false) {\n    const seen = new Set();\n    let firstMatch = null;\n    let matchCount = 0;\n\n    const recordMatch = (el) => {\n      if (!(el instanceof Element) || seen.has(el)) return false;\n      seen.add(el);\n      matchCount++;\n      if (!firstMatch) firstMatch = el;\n      // Short-circuit if:\n      // - allowMultiple is true and we found first match (no need to continue)\n      // - allowMultiple is false and we found multiple matches\n      if (allowMultiple && firstMatch) return true;\n      if (!allowMultiple && matchCount >= 2) return true;\n      return false;\n    };\n\n    // Query in main document\n    let selectorError = null;\n    try {\n      const directMatches = document.querySelectorAll(selector);\n      for (let i = 0; i < directMatches.length; i++) {\n        if (recordMatch(directMatches[i])) {\n          // Early exit: either found first match (allowMultiple) or found multiple (not allowed)\n          return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 };\n        }\n      }\n    } catch (e) {\n      selectorError = e;\n    }\n\n    if (selectorError) {\n      return {\n        element: null,\n        matchCount: 0,\n        error: `Invalid CSS selector \"${selector}\": ${selectorError.message || selectorError}`,\n      };\n    }\n\n    // If allowMultiple and we already have a match, return immediately\n    if (allowMultiple && firstMatch) {\n      return { element: firstMatch, matchCount: 1 };\n    }\n\n    // Query in shadow DOMs\n    const visited = new Set();\n    const stack = [document.documentElement];\n    while (stack.length) {\n      const node = stack.pop();\n      if (!node || visited.has(node)) continue;\n      visited.add(node);\n\n      try {\n        const shadowRoot = /** @type {any} */ (node).shadowRoot;\n        if (shadowRoot) {\n          try {\n            const shadowMatches = shadowRoot.querySelectorAll(selector);\n            for (let i = 0; i < shadowMatches.length; i++) {\n              if (recordMatch(shadowMatches[i])) {\n                // Early exit: either found first match (allowMultiple) or found multiple (not allowed)\n                return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 };\n              }\n            }\n          } catch (e) {\n            return {\n              element: null,\n              matchCount: 0,\n              error: `Invalid CSS selector \"${selector}\": ${e.message || e}`,\n            };\n          }\n\n          // Add shadow root children to stack\n          try {\n            const shadowChildren = shadowRoot.children || [];\n            for (let i = 0; i < shadowChildren.length; i++) {\n              stack.push(shadowChildren[i]);\n            }\n          } catch (_) {}\n        }\n      } catch (_) {}\n\n      // Add regular children to stack\n      try {\n        const children = /** @type {Element} */ (node).children || [];\n        for (let i = 0; i < children.length; i++) {\n          stack.push(children[i]);\n        }\n      } catch (_) {}\n    }\n\n    return { element: firstMatch, matchCount: Math.min(matchCount, 2) };\n  }\n\n  /**\n   * Query XPath selector and return match info including uniqueness check.\n   * @param {string} selector - XPath selector to query\n   * @param {boolean} allowMultiple - If true, skip uniqueness check and return first match\n   * @returns {{element: Element | null, matchCount: number, error?: string}}\n   * Note: matchCount is capped at 2 (where 2 means \"2 or more\") for performance\n   */\n  function queryXPathWithUniquenessCheck(selector, allowMultiple = false) {\n    if (!selector) {\n      return { element: null, matchCount: 0 };\n    }\n\n    try {\n      if (allowMultiple) {\n        // When multiple matches are allowed, use ANY_UNORDERED_NODE_TYPE for performance\n        // This returns just the first match without evaluating the entire result set\n        const result = document.evaluate(\n          selector,\n          document,\n          null,\n          XPathResult.ANY_UNORDERED_NODE_TYPE,\n          null,\n        );\n        const firstMatch =\n          result.singleNodeValue instanceof Element\n            ? /** @type {Element} */ (result.singleNodeValue)\n            : null;\n        return { element: firstMatch, matchCount: firstMatch ? 1 : 0 };\n      } else {\n        // When uniqueness is required, use ORDERED_NODE_SNAPSHOT_TYPE to count matches\n        const snapshot = document.evaluate(\n          selector,\n          document,\n          null,\n          XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,\n          null,\n        );\n        const totalMatches = snapshot.snapshotLength;\n        // Cap at 2 for performance (2 means \"2 or more\")\n        const matchCount = Math.min(totalMatches, 2);\n        const firstMatch =\n          totalMatches > 0 && snapshot.snapshotItem(0) instanceof Element\n            ? /** @type {Element} */ (snapshot.snapshotItem(0))\n            : null;\n        return { element: firstMatch, matchCount };\n      }\n    } catch (e) {\n      return {\n        element: null,\n        matchCount: 0,\n        error: `Invalid XPath \"${selector}\": ${e.message || e}`,\n      };\n    }\n  }\n\n  /**\n   * Whether to include element in tree under config\n   * @param {Element} el\n   * @param {{filter?: 'all'|'interactive'}} cfg\n   */\n  function shouldInclude(el, cfg) {\n    const tag = el.tagName.toLowerCase();\n    if (['script', 'style', 'meta', 'link', 'title', 'noscript'].includes(tag)) return false;\n    if (el.getAttribute('aria-hidden') === 'true') return false;\n    if (!isVisible(el)) return false;\n    if (cfg.filter !== 'all') {\n      const r = /** @type {HTMLElement} */ (el).getBoundingClientRect();\n      if (\n        !(r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0)\n      )\n        return false;\n    }\n    if (cfg.filter === 'interactive') return isInteractive(el);\n    if (isInteractive(el)) return true;\n    if (isStructural(el)) return true;\n    if (inferLabel(el).length > 0) return true;\n    return isFormishContainer(el);\n  }\n\n  /**\n   * Generate a fairly stable CSS selector\n   * @param {Element} el\n   * @returns {string}\n   */\n  function generateSelector(el) {\n    if (!(el instanceof Element)) return '';\n    if (/** @type {HTMLElement} */ (el).id) {\n      const idSel = `#${CSS.escape(/** @type {HTMLElement} */ (el).id)}`;\n      if (document.querySelectorAll(idSel).length === 1) return idSel;\n    }\n    for (const attr of ['data-testid', 'data-cy', 'name']) {\n      const attrValue = el.getAttribute(attr);\n      if (attrValue) {\n        const s = `[${attr}=\"${CSS.escape(attrValue)}\"]`;\n        if (document.querySelectorAll(s).length === 1) return s;\n      }\n    }\n    let path = '';\n    let current = el;\n    while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') {\n      let selector = current.tagName.toLowerCase();\n      const parent = current.parentElement;\n      if (parent) {\n        const siblings = Array.from(parent.children).filter(\n          (child) => child.tagName === current.tagName,\n        );\n        if (siblings.length > 1) {\n          const index = siblings.indexOf(current) + 1;\n          selector += `:nth-of-type(${index})`;\n        }\n      }\n      path = path ? `${selector} > ${path}` : selector;\n      current = parent;\n    }\n    return path ? `body > ${path}` : 'body';\n  }\n\n  /**\n   * Traverse DOM and build pageContent lines; collect ref map for interactive nodes.\n   * @param {Element} el\n   * @param {number} depth\n   * @param {{filter?: 'all'|'interactive', maxDepth?: number}} cfg\n   * @param {string[]} out\n   * @param {Array<{ref:string, selector:string, rect:{x:number,y:number,width:number,height:number}}>} refMap\n   */\n  function traverse(el, depth, cfg, out, refMap, state) {\n    const maxDepth = cfg && typeof cfg.maxDepth === 'number' ? cfg.maxDepth : MAX_DEPTH;\n    if (depth > maxDepth || !el || !el.tagName) return;\n    if (state.processed >= MAX_NODES) return;\n    if (state.visited.has(el)) return;\n    state.visited.add(el);\n    const include = shouldInclude(el, cfg) || depth === 0;\n    if (include) {\n      const role = inferRole(el);\n      let label = inferLabel(el);\n      let refId = null;\n      for (const k in window.__claudeElementMap) {\n        if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) {\n          refId = k;\n          break;\n        }\n      }\n      if (!refId) {\n        refId = `ref_${++window.__claudeRefCounter}`;\n        window.__claudeElementMap[refId] = new WeakRef(el);\n      }\n      const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();\n      const cx = Math.round(rect.left + rect.width / 2);\n      const cy = Math.round(rect.top + rect.height / 2);\n      let line = `${'  '.repeat(depth)}- ${role}`;\n      if (label) {\n        label = label.replace(/\\s+/g, ' ').substring(0, MAX_LINE_LABEL);\n        line += ` \"${label.replace(/\"/g, '\\\\\"')}\"`;\n      }\n      line += ` [ref=${refId}] (x=${cx},y=${cy})`;\n      if (/** @type {HTMLElement} */ (el).id) line += ` id=\"${/** @type {HTMLElement} */ (el).id}\"`;\n      const href = el.getAttribute('href');\n      if (href) line += ` href=\"${href}\"`;\n      const type = el.getAttribute('type');\n      if (type) line += ` type=\"${type}\"`;\n      const placeholder = el.getAttribute('placeholder');\n      if (placeholder) line += ` placeholder=\"${placeholder}\"`;\n      // Surface disabled/pointer-events for better agent judgement\n      try {\n        const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';\n        if (disabled) line += ` disabled`;\n        const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el));\n        if (cs && cs.pointerEvents === 'none') line += ` pe=none`;\n      } catch (_) {\n        /* ignore style issues */\n      }\n      out.push(line);\n      state.included++;\n      state.processed++;\n\n      // Only collect ref mapping for interactive elements to limit cost\n      if (isInteractive(el) && refMap.length < REF_MAP_LIMIT) {\n        refMap.push({\n          ref: /** @type {string} */ (refId),\n          selector: generateSelector(el),\n          rect: {\n            x: rect.x,\n            y: rect.y,\n            width: rect.width,\n            height: rect.height,\n          },\n        });\n      }\n    }\n    if (state.processed >= MAX_NODES) return;\n    // Traverse light DOM children\n    if (/** @type {HTMLElement} */ (el).children && depth < maxDepth) {\n      const children = /** @type {HTMLElement} */ (el).children;\n      for (let i = 0; i < children.length; i++) {\n        if (state.processed >= MAX_NODES) break;\n        traverse(children[i], include ? depth + 1 : depth, cfg, out, refMap, state);\n      }\n    }\n    // Traverse shadow DOM roots (limited by maxDepth and MAX_NODES)\n    try {\n      const anyEl = /** @type {any} */ (el);\n      if (anyEl && anyEl.shadowRoot && depth < maxDepth) {\n        const srChildren = anyEl.shadowRoot.children || [];\n        for (let i = 0; i < srChildren.length; i++) {\n          if (state.processed >= MAX_NODES) break;\n          traverse(srChildren[i], include ? depth + 1 : depth, cfg, out, refMap, state);\n        }\n      }\n    } catch (_) {\n      /* ignore shadow errors */\n    }\n  }\n\n  /**\n   * Generate tree and return\n   * @param {'all'|'interactive'|null} filter\n   * @param {{maxDepth?: number, refId?: string}|undefined} options\n   */\n  function __generateAccessibilityTree(filter, options) {\n    try {\n      const start = performance && performance.now ? performance.now() : Date.now();\n      const out = [];\n      const cfg = { filter: filter || undefined };\n\n      // Clamp maxDepth to MAX_DEPTH to keep costs bounded\n      if (options && Number.isFinite(options.maxDepth)) {\n        const d = Math.max(0, Math.floor(Number(options.maxDepth)));\n        cfg.maxDepth = Math.min(d, MAX_DEPTH);\n      }\n\n      const refMap = [];\n      const state = { processed: 0, included: 0, visited: new WeakSet() };\n\n      // Determine root element (body or refId-specified element)\n      let focus = null;\n      let root = document.body;\n      if (options && options.refId) {\n        const refIdStr = String(options.refId || '').trim();\n        if (refIdStr) {\n          const el = resolveRef(refIdStr);\n          if (!el || !(el instanceof Element)) {\n            return { error: `ref \"${refIdStr}\" not found or expired` };\n          }\n          root = el;\n          focus = { refId: refIdStr };\n        }\n      }\n\n      if (root) traverse(root, 0, cfg, out, refMap, state);\n      for (const k in window.__claudeElementMap) {\n        if (!window.__claudeElementMap[k].deref || !window.__claudeElementMap[k].deref())\n          delete window.__claudeElementMap[k];\n      }\n      const pageContent = out\n        .filter((line) => !/^\\s*- generic \\[ref=ref_\\d+\\]$/.test(line))\n        .join('\\n');\n      const end = performance && performance.now ? performance.now() : Date.now();\n      return {\n        pageContent,\n        focus,\n        viewport: {\n          width: window.innerWidth,\n          height: window.innerHeight,\n          dpr: window.devicePixelRatio || 1,\n        },\n        stats: {\n          processed: state.processed,\n          included: state.included,\n          durationMs: Math.round(end - start),\n        },\n        refMap,\n      };\n    } catch (err) {\n      throw new Error(\n        'Error generating accessibility tree: ' +\n          (err && err.message ? err.message : 'Unknown error'),\n      );\n    }\n  }\n\n  // Expose API on window\n  window.__generateAccessibilityTree = __generateAccessibilityTree;\n\n  // ============================================================================\n  // Hover for Ref (DOM Fallback Support)\n  // ============================================================================\n\n  async function handleHoverForRef(ref) {\n    if (!ref) return { success: false, error: 'ref is required' };\n    const el = resolveRef(ref);\n    if (el) {\n      dispatchHoverEvents(el);\n      return { success: true, target: summarizeElement(el) };\n    }\n    return await forwardHoverRefToChildren(ref);\n  }\n\n  function resolveRef(ref) {\n    const map = window.__claudeElementMap || {};\n    const weak = map[ref];\n    return weak && typeof weak.deref === 'function' ? weak.deref() : null;\n  }\n\n  function dispatchHoverEvents(el) {\n    const rect = el.getBoundingClientRect();\n    const center = {\n      x: Math.round(rect.left + rect.width / 2),\n      y: Math.round(rect.top + rect.height / 2),\n    };\n    ['mousemove', 'mouseover', 'mouseenter'].forEach((type) => {\n      el.dispatchEvent(\n        new MouseEvent(type, {\n          bubbles: true,\n          cancelable: true,\n          clientX: center.x,\n          clientY: center.y,\n          view: window,\n        }),\n      );\n    });\n  }\n\n  function summarizeElement(el) {\n    return {\n      tagName: el.tagName,\n      id: el.id || '',\n      className: el.className || '',\n      text: (el.textContent || '').trim().slice(0, 100),\n    };\n  }\n\n  function forwardHoverRefToChildren(ref) {\n    return new Promise((resolve) => {\n      const frames = Array.from(document.querySelectorAll('iframe, frame'));\n      if (!frames.length) {\n        resolve({ success: false, error: `ref \"${ref}\" not found` });\n        return;\n      }\n      const reqId = `hover_ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n      const listener = (ev) => {\n        const data = ev?.data;\n        if (!data || data.type !== 'rr-bridge-hover-ref-result' || data.reqId !== reqId) return;\n        window.removeEventListener('message', listener, true);\n        resolve(data.result);\n      };\n      window.addEventListener('message', listener, true);\n      setTimeout(() => {\n        window.removeEventListener('message', listener, true);\n        resolve({ success: false, error: `ref \"${ref}\" not found in child frames` });\n      }, 1500);\n      for (const frame of frames) {\n        try {\n          frame.contentWindow?.postMessage({ type: 'rr-bridge-hover-ref', reqId, ref }, '*');\n        } catch {}\n      }\n    });\n  }\n\n  // Chrome message bridge for ping and tree generation\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    try {\n      if (request && request.action === 'chrome_read_page_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n      if (request && request.action === 'rr_overlay') {\n        try {\n          const cmd = request.cmd || 'init';\n          let root = document.getElementById('__rr_overlay_root');\n          if (!root) {\n            root = document.createElement('div');\n            root.id = '__rr_overlay_root';\n            Object.assign(root.style, {\n              position: 'fixed',\n              right: '8px',\n              bottom: '8px',\n              zIndex: 2_147_483_647,\n              maxWidth: '40vw',\n              maxHeight: '40vh',\n              overflow: 'auto',\n              background: 'rgba(0,0,0,0.6)',\n              color: '#fff',\n              fontFamily:\n                'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace',\n              fontSize: '12px',\n              padding: '8px',\n              borderRadius: '6px',\n              boxShadow: '0 2px 8px rgba(0,0,0,0.3)',\n            });\n            const title = document.createElement('div');\n            title.textContent = 'Record-Replay 运行日志';\n            Object.assign(title.style, { fontWeight: 'bold', marginBottom: '6px' });\n            const body = document.createElement('div');\n            body.id = '__rr_overlay_body';\n            root.appendChild(title);\n            root.appendChild(body);\n            document.documentElement.appendChild(root);\n          }\n          const body = document.getElementById('__rr_overlay_body');\n          if (cmd === 'append' && body) {\n            const line = document.createElement('div');\n            line.textContent = String(request.text || '');\n            body.appendChild(line);\n            body.scrollTop = body.scrollHeight;\n          }\n          if (cmd === 'done' && root) {\n            root.style.opacity = '0.5';\n          }\n          sendResponse({ success: true });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      // Element picker: start a temporary overlay to let user pick an element\n      if (request && request.action === 'rr_picker_start') {\n        try {\n          // state\n          const state = { active: true };\n          const hostId = '__rr_picker_host__';\n          let host = document.getElementById(hostId);\n          if (host) host.remove();\n          host = document.createElement('div');\n          host.id = hostId;\n          Object.assign(host.style, {\n            position: 'fixed',\n            inset: '0',\n            zIndex: 2147483646,\n            cursor: 'crosshair',\n            background: 'rgba(0,0,0,0.0)',\n          });\n          const box = document.createElement('div');\n          Object.assign(box.style, {\n            position: 'fixed',\n            border: '2px solid #3b82f6',\n            background: 'rgba(59,130,246,0.15)',\n            pointerEvents: 'none',\n          });\n          const tip = document.createElement('div');\n          tip.textContent = '点击选取元素（Esc 取消）';\n          Object.assign(tip.style, {\n            position: 'fixed',\n            top: '10px',\n            left: '10px',\n            background: 'rgba(0,0,0,0.7)',\n            color: '#fff',\n            padding: '6px 10px',\n            borderRadius: '6px',\n            fontSize: '12px',\n            fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',\n          });\n          host.appendChild(box);\n          host.appendChild(tip);\n          document.documentElement.appendChild(host);\n\n          const cleanup = () => {\n            try {\n              host.remove();\n            } catch {}\n            try {\n              document.removeEventListener('mousemove', onMove, true);\n            } catch {}\n            try {\n              document.removeEventListener('click', onClick, true);\n            } catch {}\n            try {\n              document.removeEventListener('keydown', onKey, true);\n            } catch {}\n            state.active = false;\n          };\n\n          const onMove = (e) => {\n            if (!state.active) return;\n            const el = e.target instanceof Element ? e.target : null;\n            if (!el) return;\n            try {\n              const r = el.getBoundingClientRect();\n              Object.assign(box.style, {\n                left: `${Math.round(r.left)}px`,\n                top: `${Math.round(r.top)}px`,\n                width: `${Math.round(Math.max(0, r.width))}px`,\n                height: `${Math.round(Math.max(0, r.height))}px`,\n                display: r.width > 0 && r.height > 0 ? 'block' : 'none',\n              });\n            } catch {}\n          };\n          const uniqueClassSelector = (node) => {\n            try {\n              const classes = Array.from(node.classList || []).filter(\n                (c) => c && /^[a-zA-Z0-9_-]+$/.test(c),\n              );\n              for (const cls of classes) {\n                const sel = `.${CSS.escape(cls)}`;\n                if (document.querySelectorAll(sel).length === 1) return sel;\n              }\n              const tag = node.tagName ? node.tagName.toLowerCase() : '';\n              for (const cls of classes) {\n                const sel = `${tag}.${CSS.escape(cls)}`;\n                if (document.querySelectorAll(sel).length === 1) return sel;\n              }\n              for (let i = 0; i < Math.min(classes.length, 3); i++) {\n                for (let j = i + 1; j < Math.min(classes.length, 3); j++) {\n                  const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;\n                  if (document.querySelectorAll(sel).length === 1) return sel;\n                }\n              }\n            } catch {}\n            return '';\n          };\n          const computeCandidates = (el) => {\n            const cands = [];\n            // css by id / class / short path\n            if (el.id) {\n              const idSel = `#${CSS.escape(el.id)}`;\n              if (document.querySelectorAll(idSel).length === 1)\n                cands.push({ type: 'css', value: idSel });\n            }\n            const classSel = uniqueClassSelector(el);\n            if (classSel) cands.push({ type: 'css', value: classSel });\n            // data-* and name\n            for (const attr of ['data-testid', 'data-cy', 'name']) {\n              const val = el.getAttribute(attr);\n              if (val) {\n                const s = `[${attr}=\"${CSS.escape(val)}\"]`;\n                if (document.querySelectorAll(s).length === 1)\n                  cands.push({ type: 'attr', value: s });\n              }\n            }\n            // aria\n            const aria = el.getAttribute && el.getAttribute('aria-label');\n            if (aria) cands.push({ type: 'aria', value: `textbox[name=${aria}]` });\n            // text for clickable\n            const tag = (el.tagName || '').toLowerCase();\n            if (['button', 'a', 'summary'].includes(tag)) {\n              const text = (el.textContent || '').trim();\n              if (text) cands.push({ type: 'text', value: text.substring(0, 64) });\n            }\n            // fallback path selector\n            const gen = (node) => {\n              if (!(node instanceof Element)) return '';\n              let path = '';\n              let current = node;\n              while (\n                current &&\n                current.nodeType === Node.ELEMENT_NODE &&\n                current.tagName !== 'BODY'\n              ) {\n                let sel = current.tagName.toLowerCase();\n                const parent = current.parentElement;\n                if (parent) {\n                  const siblings = Array.from(parent.children).filter(\n                    (child) => child.tagName === current.tagName,\n                  );\n                  if (siblings.length > 1) {\n                    const index = siblings.indexOf(current) + 1;\n                    sel += `:nth-of-type(${index})`;\n                  }\n                }\n                path = path ? `${sel} > ${path}` : sel;\n                current = parent;\n              }\n              return path ? `body > ${path}` : 'body';\n            };\n            const pathSel = gen(el);\n            if (pathSel) cands.push({ type: 'css', value: pathSel });\n            return cands;\n          };\n          const onClick = (e) => {\n            if (!state.active) return;\n            e.preventDefault();\n            e.stopPropagation();\n            const el = e.target instanceof Element ? e.target : null;\n            if (!el) {\n              cleanup();\n              sendResponse({ success: false, error: 'no element' });\n              return true;\n            }\n            // create ref\n            try {\n              if (!window.__claudeElementMap) window.__claudeElementMap = {};\n              if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n            } catch {}\n            let refId = null;\n            try {\n              for (const k in window.__claudeElementMap) {\n                if (\n                  window.__claudeElementMap[k].deref &&\n                  window.__claudeElementMap[k].deref() === el\n                ) {\n                  refId = k;\n                  break;\n                }\n              }\n              if (!refId) {\n                refId = `ref_${++window.__claudeRefCounter}`;\n                window.__claudeElementMap[refId] = new WeakRef(el);\n              }\n            } catch {}\n            const cands = computeCandidates(el);\n            cleanup();\n            sendResponse({ success: true, ref: refId, candidates: cands });\n            return true;\n          };\n          const onKey = (e) => {\n            if (e.key === 'Escape') {\n              cleanup();\n              sendResponse({ success: false, cancelled: true });\n            }\n          };\n          document.addEventListener('mousemove', onMove, true);\n          document.addEventListener('click', onClick, true);\n          document.addEventListener('keydown', onKey, true);\n          return true; // async\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'rr_picker_stop') {\n        try {\n          const host = document.getElementById('__rr_picker_host__');\n          if (host) host.remove();\n          sendResponse({ success: true });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'generateAccessibilityTree') {\n        const result = __generateAccessibilityTree(request.filter || null, {\n          maxDepth: request.depth,\n          refId: request.refId,\n        });\n        if (result && result.error) {\n          sendResponse({ success: false, error: result.error });\n          return true;\n        }\n        sendResponse({ success: true, ...result });\n        return true;\n      }\n      if (request && request.action === 'ensureRefForSelector') {\n        try {\n          // Composite selector support: \"frameSelector |> innerSelector\"\n          const maybeSel = String(request.selector || '').trim();\n          const allowMultiple = !!request.allowMultiple;\n          if (maybeSel.includes('|>')) {\n            try {\n              const parts = maybeSel\n                .split('|>')\n                .map((s) => s.trim())\n                .filter(Boolean);\n              if (parts.length >= 2) {\n                const frameSel = parts[0];\n                const innerSel = parts.slice(1).join(' |> ');\n                // Find target frame element in current document\n                let frameEl = null;\n                try {\n                  frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel);\n                } catch {}\n                if (\n                  !frameEl ||\n                  !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement)\n                ) {\n                  sendResponse({\n                    success: false,\n                    error: `Composite frame selector not found: ${frameSel}`,\n                  });\n                  return true;\n                }\n                const cw = frameEl.contentWindow;\n                if (!cw) {\n                  sendResponse({\n                    success: false,\n                    error: 'Unable to obtain contentWindow of target frame',\n                  });\n                  return true;\n                }\n                // Bridge to child frame via postMessage with timeout\n                const reqId = `rrc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n                const BRIDGE_TIMEOUT_MS = 5000; // 5 second timeout for iframe bridge\n                let responded = false;\n                let timeoutHandle = null;\n\n                const cleanup = () => {\n                  window.removeEventListener('message', listener, true);\n                  if (timeoutHandle) {\n                    clearTimeout(timeoutHandle);\n                    timeoutHandle = null;\n                  }\n                };\n\n                const listener = (ev) => {\n                  try {\n                    const data = ev && ev.data;\n                    if (\n                      !data ||\n                      data.type !== 'rr-bridge-ensure-ref-result' ||\n                      data.reqId !== reqId\n                    )\n                      return;\n                    // Validate source is the expected frame (security check)\n                    if (ev.source !== cw) return;\n\n                    if (responded) return; // Already timed out\n                    responded = true;\n                    cleanup();\n\n                    if (data.success) {\n                      sendResponse({\n                        success: true,\n                        ref: data.ref,\n                        center: data.center,\n                        href: data.href,\n                      });\n                    } else {\n                      sendResponse({ success: false, error: data.error || 'child failed' });\n                    }\n                  } catch (e) {\n                    if (!responded) {\n                      responded = true;\n                      cleanup();\n                      sendResponse({\n                        success: false,\n                        error: String(e && e.message ? e.message : e),\n                      });\n                    }\n                  }\n                };\n\n                // Set up timeout to prevent infinite wait\n                timeoutHandle = setTimeout(() => {\n                  if (!responded) {\n                    responded = true;\n                    cleanup();\n                    sendResponse({\n                      success: false,\n                      error: `iframe bridge timeout after ${BRIDGE_TIMEOUT_MS}ms`,\n                    });\n                  }\n                }, BRIDGE_TIMEOUT_MS);\n\n                window.addEventListener('message', listener, true);\n                cw.postMessage(\n                  {\n                    type: 'rr-bridge-ensure-ref',\n                    reqId,\n                    selector: innerSel,\n                    useText: !!request.useText,\n                    isXPath: !!request.isXPath,\n                    tagName: String(request.tagName || ''),\n                    allowMultiple: !!request.allowMultiple,\n                  },\n                  '*',\n                );\n                return true; // async response via message bridge\n              }\n            } catch (e) {\n              sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n              return true;\n            }\n          }\n          // Support CSS selector, XPath, or visible text search\n          const useText = !!request.useText;\n          const textQuery = String(request.text || '').trim();\n          const sel = String(request.selector || '').trim();\n          const isXPath = !!request.isXPath;\n          const limitTag = String(request.tagName || '')\n            .trim()\n            .toUpperCase();\n          let el = null;\n          if (useText && textQuery) {\n            const normalize = (s) =>\n              String(s || '')\n                .replace(/\\s+/g, ' ')\n                .trim()\n                .toLowerCase();\n            const query = normalize(textQuery);\n            const bigrams = (s) => {\n              const arr = [];\n              for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));\n              return arr;\n            };\n            const dice = (a, b) => {\n              if (!a || !b) return 0;\n              const A = bigrams(a);\n              const B = bigrams(b);\n              if (A.length === 0 || B.length === 0) return 0;\n              let inter = 0;\n              const map = new Map();\n              for (const t of A) map.set(t, (map.get(t) || 0) + 1);\n              for (const t of B) {\n                const c = map.get(t) || 0;\n                if (c > 0) {\n                  inter++;\n                  map.set(t, c - 1);\n                }\n              }\n              return (2 * inter) / (A.length + B.length);\n            };\n            let best = { el: null, score: 0 };\n            // Deep traversal including shadow roots\n            const stack = [document.documentElement];\n            let visited = 0;\n            while (stack.length) {\n              const node = /** @type {any} */ (stack.pop());\n              if (!node || !(node instanceof Element)) continue;\n              try {\n                if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) {\n                  // still traverse into children/shadow for performance? yes\n                } else {\n                  const cs = window.getComputedStyle(node);\n                  if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') {\n                    /* skip hidden */\n                  } else {\n                    const rect = /** @type {HTMLElement} */ (node).getBoundingClientRect();\n                    if (rect.width > 0 && rect.height > 0) {\n                      const txt = normalize(node.textContent || '');\n                      if (txt) {\n                        if (txt.includes(query)) {\n                          el = /** @type {Element} */ (node);\n                          break;\n                        }\n                        const sc = dice(txt, query);\n                        if (sc > best.score)\n                          best = { el: /** @type {Element} */ (node), score: sc };\n                      }\n                    }\n                  }\n                }\n              } catch {}\n              // push children and shadow children\n              try {\n                const children = node.children || [];\n                for (let i = 0; i < children.length; i++) stack.push(children[i]);\n              } catch {}\n              try {\n                const sr = node.shadowRoot;\n                if (sr && sr.children) {\n                  for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);\n                }\n              } catch {}\n              if (++visited > 8000) break;\n            }\n            if (!el && best.el && best.score >= 0.6) el = best.el;\n          } else if (isXPath) {\n            if (!sel) {\n              sendResponse({ success: false, error: 'selector is required' });\n              return true;\n            }\n            const result = queryXPathWithUniquenessCheck(sel, allowMultiple);\n            if (result.error) {\n              sendResponse({ success: false, error: result.error });\n              return true;\n            }\n            if (result.matchCount === 0) {\n              sendResponse({ success: false, error: `selector not found: ${sel}` });\n              return true;\n            }\n            if (!allowMultiple && result.matchCount > 1) {\n              sendResponse({\n                success: false,\n                error: `Selector \"${sel}\" matched multiple elements. Please refine the selector to match only one element.`,\n              });\n              return true;\n            }\n            el = result.element;\n          } else {\n            if (!sel) {\n              sendResponse({ success: false, error: 'selector is required' });\n              return true;\n            }\n            const result = querySelectorWithUniquenessCheck(sel, allowMultiple);\n            if (result.error) {\n              sendResponse({ success: false, error: result.error });\n              return true;\n            }\n            if (result.matchCount === 0) {\n              sendResponse({ success: false, error: `selector not found: ${sel}` });\n              return true;\n            }\n            if (!allowMultiple && result.matchCount > 1) {\n              sendResponse({\n                success: false,\n                error: `Selector \"${sel}\" matched multiple elements. Please refine the selector to match only one element.`,\n              });\n              return true;\n            }\n            el = result.element;\n          }\n          if (!el) {\n            sendResponse({ success: false, error: `selector not found: ${sel}` });\n            return true;\n          }\n          let refId = null;\n          for (const k in window.__claudeElementMap) {\n            if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) {\n              refId = k;\n              break;\n            }\n          }\n          if (!refId) {\n            refId = `ref_${++window.__claudeRefCounter}`;\n            window.__claudeElementMap[refId] = new WeakRef(el);\n          }\n          const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();\n          sendResponse({\n            success: true,\n            ref: refId,\n            center: {\n              x: Math.round(rect.left + rect.width / 2),\n              y: Math.round(rect.top + rect.height / 2),\n            },\n          });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'dispatchHoverForRef') {\n        handleHoverForRef(String(request.ref || '').trim())\n          .then((result) => sendResponse(result))\n          .catch((error) =>\n            sendResponse({ success: false, error: error?.message || String(error) }),\n          );\n        return true;\n      }\n      if (request && request.action === 'getAttributeForSelector') {\n        try {\n          const sel = String(request.selector || '').trim();\n          const name = String(request.name || '').trim();\n          if (!sel || !name) {\n            sendResponse({ success: false, error: 'selector and name are required' });\n            return true;\n          }\n          const el = document.querySelector(sel) || querySelectorDeepFirst(sel);\n          if (!el) {\n            sendResponse({ success: false, error: `selector not found: ${sel}` });\n            return true;\n          }\n          let value = null;\n          if (name === 'text' || name === 'textContent') {\n            value = (el.textContent || '').trim();\n          } else if (name === 'value') {\n            try {\n              value = /** @type {HTMLInputElement} */ (el).value ?? null;\n            } catch (_) {\n              value = el.getAttribute('value');\n            }\n          } else {\n            value = el.getAttribute(name);\n          }\n          sendResponse({ success: true, value });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'collectVariables') {\n        try {\n          let vars = Array.isArray(request.variables) ? request.variables : [];\n          if ((!vars || vars.length === 0) && request.payload) {\n            try {\n              const p = JSON.parse(String(request.payload || '{}'));\n              if (Array.isArray(p.variables)) vars = p.variables;\n            } catch {}\n          }\n          const useOverlay = request.useOverlay !== false; // default true\n          const values = {};\n          if (!useOverlay) {\n            for (const v of vars) {\n              const key = String(v && v.key ? v.key : '');\n              if (!key) continue;\n              const label = v.label || key;\n              const def = v.default || '';\n              const promptText = `请输入参数 ${label} (${key})`;\n              let val = window.prompt(promptText, def);\n              if (typeof val !== 'string') val = def;\n              values[key] = val;\n            }\n            sendResponse({ success: true, values });\n            return true;\n          }\n          // Build overlay form\n          const hostId = '__rr_var_overlay__';\n          let host = document.getElementById(hostId);\n          if (host) host.remove();\n          host = document.createElement('div');\n          host.id = hostId;\n          Object.assign(host.style, {\n            position: 'fixed',\n            inset: '0',\n            background: 'rgba(0,0,0,0.35)',\n            zIndex: 2147483646,\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n          });\n          const panel = document.createElement('div');\n          Object.assign(panel.style, {\n            background: '#fff',\n            borderRadius: '8px',\n            width: 'min(520px, 96vw)',\n            maxHeight: '80vh',\n            overflow: 'auto',\n            boxShadow: '0 8px 24px rgba(0,0,0,0.2)',\n            padding: '16px',\n            fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',\n          });\n          const title = document.createElement('div');\n          title.textContent = '请输入回放参数';\n          Object.assign(title.style, { fontSize: '16px', fontWeight: '600', marginBottom: '12px' });\n          const form = document.createElement('form');\n          for (const v of vars) {\n            const row = document.createElement('div');\n            Object.assign(row.style, { marginBottom: '10px' });\n            const label = document.createElement('label');\n            label.textContent = `${v.label || v.key}${v.sensitive ? ' (敏感)' : ''}`;\n            Object.assign(label.style, {\n              display: 'block',\n              marginBottom: '6px',\n              fontWeight: '500',\n            });\n            const input = document.createElement('input');\n            input.type = v.sensitive ? 'password' : 'text';\n            input.name = String(v.key);\n            input.value = String(v.default || '');\n            Object.assign(input.style, {\n              width: '100%',\n              boxSizing: 'border-box',\n              padding: '8px 10px',\n              border: '1px solid #d0d7de',\n              borderRadius: '6px',\n              outline: 'none',\n            });\n            row.appendChild(label);\n            row.appendChild(input);\n            form.appendChild(row);\n          }\n          const actions = document.createElement('div');\n          Object.assign(actions.style, { display: 'flex', gap: '8px', marginTop: '12px' });\n          const ok = document.createElement('button');\n          ok.type = 'submit';\n          ok.textContent = '确定';\n          Object.assign(ok.style, {\n            background: '#0969da',\n            color: '#fff',\n            border: 'none',\n            padding: '8px 16px',\n            borderRadius: '6px',\n            cursor: 'pointer',\n          });\n          const cancel = document.createElement('button');\n          cancel.type = 'button';\n          cancel.textContent = '取消';\n          Object.assign(cancel.style, {\n            background: '#f3f4f6',\n            color: '#111',\n            border: '1px solid #d0d7de',\n            padding: '8px 16px',\n            borderRadius: '6px',\n            cursor: 'pointer',\n          });\n          actions.appendChild(ok);\n          actions.appendChild(cancel);\n          panel.appendChild(title);\n          panel.appendChild(form);\n          panel.appendChild(actions);\n          host.appendChild(panel);\n          document.documentElement.appendChild(host);\n\n          const cleanup = () => {\n            try {\n              host.remove();\n            } catch {}\n          };\n          cancel.onclick = () => {\n            cleanup();\n            sendResponse({ success: false, cancelled: true });\n          };\n          form.onsubmit = (e) => {\n            e.preventDefault();\n            for (const v of vars) {\n              const el = form.querySelector(`input[name=\"${CSS.escape(String(v.key))}\"]`);\n              if (el) values[v.key] = /** @type {HTMLInputElement} */ (el).value;\n            }\n            cleanup();\n            sendResponse({ success: true, values });\n          };\n          return true; // async\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'resolveRef') {\n        const ref = request.ref;\n        try {\n          const map = window.__claudeElementMap;\n          const weak = map && map[ref];\n          const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;\n          if (!el || !(el instanceof Element)) {\n            sendResponse({ success: false, error: `ref \"${ref}\" not found or expired` });\n            return true;\n          }\n          const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();\n          sendResponse({\n            success: true,\n            rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },\n            center: {\n              x: Math.round(rect.left + rect.width / 2),\n              y: Math.round(rect.top + rect.height / 2),\n            },\n            selector: (function () {\n              // Simple selector generation inline to avoid duplication\n              const generateSelector = function (node) {\n                if (!(node instanceof Element)) return '';\n                if (node.id) {\n                  const idSel = `#${CSS.escape(node.id)}`;\n                  if (document.querySelectorAll(idSel).length === 1) return idSel;\n                }\n                // prefer unique class selectors if available\n                try {\n                  const classes = Array.from(node.classList || []).filter(\n                    (c) => c && /^[a-zA-Z0-9_-]+$/.test(c),\n                  );\n                  for (const cls of classes) {\n                    const sel = `.${CSS.escape(cls)}`;\n                    if (document.querySelectorAll(sel).length === 1) return sel;\n                  }\n                  const tag = node.tagName ? node.tagName.toLowerCase() : '';\n                  for (const cls of classes) {\n                    const sel = `${tag}.${CSS.escape(cls)}`;\n                    if (document.querySelectorAll(sel).length === 1) return sel;\n                  }\n                  for (let i = 0; i < Math.min(classes.length, 3); i++) {\n                    for (let j = i + 1; j < Math.min(classes.length, 3); j++) {\n                      const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;\n                      if (document.querySelectorAll(sel).length === 1) return sel;\n                    }\n                  }\n                } catch {}\n                for (const attr of ['data-testid', 'data-cy', 'name']) {\n                  const val = node.getAttribute(attr);\n                  if (val) {\n                    const s = `[${attr}=\"${CSS.escape(val)}\"]`;\n                    if (document.querySelectorAll(s).length === 1) return s;\n                  }\n                }\n                let path = '';\n                let current = node;\n                while (\n                  current &&\n                  current.nodeType === Node.ELEMENT_NODE &&\n                  current.tagName !== 'BODY'\n                ) {\n                  let sel = current.tagName.toLowerCase();\n                  const parent = current.parentElement;\n                  if (parent) {\n                    const siblings = Array.from(parent.children).filter(\n                      (c) => c.tagName === current.tagName,\n                    );\n                    if (siblings.length > 1) {\n                      const idx = siblings.indexOf(current) + 1;\n                      sel += `:nth-of-type(${idx})`;\n                    }\n                  }\n                  path = path ? `${sel} > ${path}` : sel;\n                  current = parent;\n                }\n                return path ? `body > ${path}` : 'body';\n              };\n              return generateSelector(el);\n            })(),\n          });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'verifyFingerprint') {\n        try {\n          const ref = String(request.ref || '').trim();\n          const fingerprint = String(request.fingerprint || '').trim();\n          if (!ref || !fingerprint) {\n            sendResponse({ success: false, error: 'ref and fingerprint are required' });\n            return true;\n          }\n          const map = window.__claudeElementMap;\n          const weak = map && map[ref];\n          const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;\n          if (!el || !(el instanceof Element)) {\n            sendResponse({ success: false, error: `ref \"${ref}\" not found or expired` });\n            return true;\n          }\n          // 验证指纹：解析存储的指纹并与当前元素对比\n          const parts = fingerprint.split('|');\n          const storedTag = parts[0] || 'unknown';\n          const currentTag = el.tagName ? String(el.tagName).toLowerCase() : 'unknown';\n          // Tag 必须匹配\n          if (storedTag !== currentTag) {\n            sendResponse({ success: true, match: false });\n            return true;\n          }\n          // 如果存储的指纹有 id，当前元素必须有相同的 id\n          const storedIdPart = parts.find((p) => p.startsWith('id='));\n          if (storedIdPart) {\n            const storedId = storedIdPart.slice(3);\n            const currentId = el.id ? String(el.id).trim() : '';\n            if (storedId !== currentId) {\n              sendResponse({ success: true, match: false });\n              return true;\n            }\n          }\n          sendResponse({ success: true, match: true });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n      if (request && request.action === 'focusByRef') {\n        try {\n          const ref = String(request.ref || '');\n          const map = window.__claudeElementMap || {};\n          const weak = map[ref];\n          const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;\n          if (!el || !(el instanceof Element)) {\n            sendResponse({ success: false, error: `ref \"${ref}\" not found or expired` });\n            return true;\n          }\n          try {\n            /** @type {HTMLElement} */ (el).scrollIntoView({\n              behavior: 'instant',\n              block: 'center',\n              inline: 'nearest',\n            });\n          } catch {}\n          try {\n            /** @type {HTMLElement} */ (el).focus && /** @type {HTMLElement} */ (el).focus();\n          } catch {}\n          sendResponse({ success: true });\n          return true;\n        } catch (e) {\n          sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n          return true;\n        }\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: e && e.message ? e.message : String(e) });\n      return true;\n    }\n    return false;\n  });\n\n  console.log('Accessibility tree helper script loaded');\n  // Cross-frame bridge: child listens for ensure-ref requests from parent (composite selector)\n  try {\n    window.addEventListener(\n      'message',\n      (ev) => {\n        try {\n          const data = ev && ev.data;\n          // Handle hover-ref bridge requests from parent frame\n          if (data && data.type === 'rr-bridge-hover-ref') {\n            handleHoverForRef(data.ref)\n              .then((result) => {\n                ev.source?.postMessage(\n                  { type: 'rr-bridge-hover-ref-result', reqId: data.reqId, result },\n                  '*',\n                );\n              })\n              .catch((error) => {\n                ev.source?.postMessage(\n                  {\n                    type: 'rr-bridge-hover-ref-result',\n                    reqId: data.reqId,\n                    result: { success: false, error: error?.message || String(error) },\n                  },\n                  '*',\n                );\n              });\n            return;\n          }\n          if (!data || data.type !== 'rr-bridge-ensure-ref') return;\n          const { reqId, selector, useText, isXPath, tagName } = data || {};\n          const respond = (payload) => {\n            try {\n              ev.source &&\n                ev.source.postMessage(\n                  { type: 'rr-bridge-ensure-ref-result', reqId, ...payload },\n                  '*',\n                );\n            } catch {}\n          };\n          try {\n            const sel = String(selector || '').trim();\n            const limitTag = String(tagName || '')\n              .trim()\n              .toUpperCase();\n            let el = null;\n            if (useText && sel) {\n              const normalize = (s) =>\n                String(s || '')\n                  .replace(/\\s+/g, ' ')\n                  .trim()\n                  .toLowerCase();\n              const query = normalize(sel);\n              const bigrams = (s) => {\n                const arr = [];\n                for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));\n                return arr;\n              };\n              const dice = (a, b) => {\n                if (!a || !b) return 0;\n                const A = bigrams(a),\n                  B = bigrams(b);\n                if (!A.length || !B.length) return 0;\n                let inter = 0;\n                const m = new Map();\n                for (const t of A) m.set(t, (m.get(t) || 0) + 1);\n                for (const t of B) {\n                  const c = m.get(t) || 0;\n                  if (c > 0) {\n                    inter++;\n                    m.set(t, c - 1);\n                  }\n                }\n                return (2 * inter) / (A.length + B.length);\n              };\n              let best = { el: null, score: 0 };\n              const stack = [document.documentElement];\n              while (stack.length) {\n                const node = stack.pop();\n                if (!node || !(node instanceof Element)) continue;\n                try {\n                  if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) {\n                  } else {\n                    const cs = window.getComputedStyle(node);\n                    if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') {\n                      const rect = node.getBoundingClientRect();\n                      if (rect.width > 0 && rect.height > 0) {\n                        const txt = normalize(node.textContent || '');\n                        if (txt) {\n                          if (txt.includes(query)) {\n                            el = node;\n                            break;\n                          }\n                          const sc = dice(txt, query);\n                          if (sc > best.score) best = { el: node, score: sc };\n                        }\n                      }\n                    }\n                  }\n                } catch {}\n                try {\n                  const children = node.children || [];\n                  for (let i = 0; i < children.length; i++) stack.push(children[i]);\n                  const sr = node.shadowRoot;\n                  if (sr && sr.children)\n                    for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);\n                } catch {}\n              }\n              if (!el && best.el) el = best.el;\n            } else if (isXPath) {\n              if (!sel) {\n                respond({ success: false, error: 'selector is required' });\n                return;\n              }\n              const allowMultiple = !!data.allowMultiple;\n              const result = queryXPathWithUniquenessCheck(sel, allowMultiple);\n              if (result.error) {\n                respond({ success: false, error: result.error });\n                return;\n              }\n              if (result.matchCount === 0) {\n                respond({ success: false, error: `Selector \"${sel}\" not found in child frame` });\n                return;\n              }\n              if (!allowMultiple && result.matchCount > 1) {\n                respond({\n                  success: false,\n                  error: `Selector \"${sel}\" matched multiple elements inside frame. Please refine the selector to match only one element.`,\n                });\n                return;\n              }\n              el = result.element;\n            } else {\n              if (!sel) {\n                respond({ success: false, error: 'selector is required' });\n                return;\n              }\n              const allowMultiple = !!data.allowMultiple;\n              const result = querySelectorWithUniquenessCheck(sel, allowMultiple);\n              if (result.error) {\n                respond({ success: false, error: result.error });\n                return;\n              }\n              if (result.matchCount === 0) {\n                respond({ success: false, error: `Selector \"${sel}\" not found in child frame` });\n                return;\n              }\n              if (!allowMultiple && result.matchCount > 1) {\n                respond({\n                  success: false,\n                  error: `Selector \"${sel}\" matched multiple elements inside frame. Please refine the selector to match only one element.`,\n                });\n                return;\n              }\n              el = result.element;\n            }\n            if (!el || !(el instanceof Element)) {\n              respond({ success: false, error: 'Element not found in child frame' });\n              return;\n            }\n            if (!window.__claudeElementMap) window.__claudeElementMap = {};\n            if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n            let refId = null;\n            for (const k in window.__claudeElementMap) {\n              const w = window.__claudeElementMap[k];\n              if (w && typeof w.deref === 'function' && w.deref && w.deref() === el) {\n                refId = k;\n                break;\n              }\n            }\n            if (!refId) {\n              refId = `ref_${++window.__claudeRefCounter}`;\n              window.__claudeElementMap[refId] = new WeakRef(el);\n            }\n            const rect = el.getBoundingClientRect();\n            respond({\n              success: true,\n              ref: refId,\n              center: {\n                x: Math.round(rect.left + rect.width / 2),\n                y: Math.round(rect.top + rect.height / 2),\n              },\n              href: String(location && location.href ? location.href : ''),\n            });\n          } catch (e) {\n            respond({ success: false, error: String(e && e.message ? e.message : e) });\n          }\n        } catch {}\n      },\n      true,\n    );\n  } catch {}\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/click-helper.js",
    "content": "/* eslint-disable */\n// click-helper.js\n// This script is injected into the page to handle click operations\n\nif (window.__CLICK_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__CLICK_HELPER_INITIALIZED__ = true;\n  /**\n   * Click on an element matching the selector or at specific coordinates\n   * @param {string} selector - CSS selector for the element to click\n   * @param {boolean} waitForNavigation - Whether to wait for navigation to complete after click\n   * @param {number} timeout - Timeout in milliseconds for waiting for the element or navigation\n   * @param {Object} coordinates - Optional coordinates for clicking at a specific position\n   * @param {number} coordinates.x - X coordinate relative to the viewport\n   * @param {number} coordinates.y - Y coordinate relative to the viewport\n   * @returns {Promise<Object>} - Result of the click operation\n   */\n  async function clickElement(\n    selector,\n    waitForNavigation = false,\n    timeout = 5000,\n    coordinates = null,\n    ref = null,\n    double = false,\n    options = {},\n  ) {\n    try {\n      let element = null;\n      let elementInfo = null;\n      let clickX, clickY;\n\n      if (ref && typeof ref === 'string') {\n        // Resolve element from weak map\n        let target = null;\n        try {\n          const map = window.__claudeElementMap;\n          const weak = map && map[ref];\n          target = weak && typeof weak.deref === 'function' ? weak.deref() : null;\n        } catch (e) {\n          // ignore\n        }\n\n        if (!target || !(target instanceof Element)) {\n          return {\n            error: `Element ref \"${ref}\" not found. Please call chrome_read_page first and ensure the ref is still valid.`,\n          };\n        }\n\n        element = target;\n        element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });\n        await new Promise((resolve) => setTimeout(resolve, 80));\n\n        const rect = element.getBoundingClientRect();\n        clickX = rect.left + rect.width / 2;\n        clickY = rect.top + rect.height / 2;\n        elementInfo = {\n          tagName: element.tagName,\n          id: element.id,\n          className: element.className,\n          text: element.textContent?.trim().substring(0, 100) || '',\n          href: element.href || null,\n          type: element.type || null,\n          isVisible: true,\n          rect: {\n            x: rect.x,\n            y: rect.y,\n            width: rect.width,\n            height: rect.height,\n            top: rect.top,\n            right: rect.right,\n            bottom: rect.bottom,\n            left: rect.left,\n          },\n          clickMethod: 'ref',\n          ref,\n        };\n      } else if (\n        coordinates &&\n        typeof coordinates.x === 'number' &&\n        typeof coordinates.y === 'number'\n      ) {\n        clickX = coordinates.x;\n        clickY = coordinates.y;\n\n        element = document.elementFromPoint(clickX, clickY);\n\n        if (element) {\n          const rect = element.getBoundingClientRect();\n          elementInfo = {\n            tagName: element.tagName,\n            id: element.id,\n            className: element.className,\n            text: element.textContent?.trim().substring(0, 100) || '',\n            href: element.href || null,\n            type: element.type || null,\n            isVisible: true,\n            rect: {\n              x: rect.x,\n              y: rect.y,\n              width: rect.width,\n              height: rect.height,\n              top: rect.top,\n              right: rect.right,\n              bottom: rect.bottom,\n              left: rect.left,\n            },\n            clickMethod: 'coordinates',\n            clickPosition: { x: clickX, y: clickY },\n          };\n        } else {\n          elementInfo = {\n            clickMethod: 'coordinates',\n            clickPosition: { x: clickX, y: clickY },\n            warning: 'No element found at the specified coordinates',\n          };\n        }\n      } else {\n        element = document.querySelector(selector);\n        if (!element) {\n          return {\n            error: `Element with selector \"${selector}\" not found`,\n          };\n        }\n\n        const rect = element.getBoundingClientRect();\n        elementInfo = {\n          tagName: element.tagName,\n          id: element.id,\n          className: element.className,\n          text: element.textContent?.trim().substring(0, 100) || '',\n          href: element.href || null,\n          type: element.type || null,\n          isVisible: true,\n          rect: {\n            x: rect.x,\n            y: rect.y,\n            width: rect.width,\n            height: rect.height,\n            top: rect.top,\n            right: rect.right,\n            bottom: rect.bottom,\n            left: rect.left,\n          },\n          clickMethod: 'selector',\n        };\n\n        // First sroll so that the element is in view, then check visibility.\n        element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });\n        await new Promise((resolve) => setTimeout(resolve, 100));\n        elementInfo.isVisible = isElementVisible(element);\n        if (!elementInfo.isVisible) {\n          return {\n            error: `Element with selector \"${selector}\" is not visible`,\n            elementInfo,\n          };\n        }\n\n        const updatedRect = element.getBoundingClientRect();\n        clickX = updatedRect.left + updatedRect.width / 2;\n        clickY = updatedRect.top + updatedRect.height / 2;\n      }\n\n      let navigationPromise;\n      if (waitForNavigation) {\n        navigationPromise = new Promise((resolve) => {\n          const beforeUnloadListener = () => {\n            window.removeEventListener('beforeunload', beforeUnloadListener);\n            resolve(true);\n          };\n          window.addEventListener('beforeunload', beforeUnloadListener);\n\n          setTimeout(() => {\n            window.removeEventListener('beforeunload', beforeUnloadListener);\n            resolve(false);\n          }, timeout);\n        });\n      }\n\n      if (\n        element &&\n        (elementInfo.clickMethod === 'selector' || elementInfo.clickMethod === 'ref')\n      ) {\n        if (double) {\n          dispatchClickSequence(element, clickX, clickY, options, true);\n        } else {\n          dispatchClickSequence(element, clickX, clickY, options, false);\n        }\n      } else {\n        if (double) simulateDoubleClick(clickX, clickY, options);\n        else simulateClick(clickX, clickY, options);\n      }\n\n      // Wait for navigation if needed\n      let navigationOccurred = false;\n      if (waitForNavigation) {\n        navigationOccurred = await navigationPromise;\n      }\n\n      return {\n        success: true,\n        message: 'Element clicked successfully',\n        elementInfo,\n        navigationOccurred,\n      };\n    } catch (error) {\n      return {\n        error: `Error clicking element: ${error.message}`,\n      };\n    }\n  }\n\n  /**\n   * Simulate a mouse click at specific coordinates\n   * @param {number} x - X coordinate relative to the viewport\n   * @param {number} y - Y coordinate relative to the viewport\n   */\n  function simulateClick(x, y, options = {}) {\n    const element = document.elementFromPoint(x, y);\n    if (!element) return;\n    dispatchClickSequence(element, x, y, options, false);\n  }\n\n  /**\n   * Simulate a double click sequence at specific coordinates\n   */\n  function simulateDoubleClick(x, y, options = {}) {\n    const element = document.elementFromPoint(x, y);\n    if (!element) return;\n    dispatchClickSequence(element, x, y, options, true);\n  }\n\n  /**\n   * Simulate double click using element when available\n   */\n  function simulateDomDoubleClick(element, x, y, options) {\n    dispatchClickSequence(element, x, y, options, true);\n  }\n\n  function normalizeMouseOpts(x, y, options = {}) {\n    const bubbles = options.bubbles !== false; // default true\n    const cancelable = options.cancelable !== false; // default true\n    const altKey = !!(options.modifiers && options.modifiers.altKey);\n    const ctrlKey = !!(options.modifiers && options.modifiers.ctrlKey);\n    const metaKey = !!(options.modifiers && options.modifiers.metaKey);\n    const shiftKey = !!(options.modifiers && options.modifiers.shiftKey);\n    const btn = String(options.button || 'left');\n    const button = btn === 'right' ? 2 : btn === 'middle' ? 1 : 0;\n    const buttons = btn === 'right' ? 2 : btn === 'middle' ? 4 : 1;\n    return {\n      bubbles,\n      cancelable,\n      altKey,\n      ctrlKey,\n      metaKey,\n      shiftKey,\n      button,\n      buttons,\n      clientX: x,\n      clientY: y,\n      view: window,\n    };\n  }\n\n  function dispatchClickSequence(element, x, y, options = {}, isDouble = false) {\n    const base = normalizeMouseOpts(x, y, options);\n    const down = new MouseEvent('mousedown', base);\n    const up = new MouseEvent('mouseup', base);\n    const click = new MouseEvent('click', base);\n    try {\n      element.dispatchEvent(down);\n    } catch {}\n    try {\n      element.dispatchEvent(up);\n    } catch {}\n    try {\n      element.dispatchEvent(click);\n    } catch {}\n    if (base.button === 2) {\n      // right button contextmenu\n      const ctx = new MouseEvent('contextmenu', base);\n      try {\n        element.dispatchEvent(ctx);\n      } catch {}\n    }\n    if (isDouble) {\n      // second sequence + dblclick\n      setTimeout(() => {\n        try {\n          element.dispatchEvent(new MouseEvent('mousedown', base));\n        } catch {}\n        try {\n          element.dispatchEvent(new MouseEvent('mouseup', base));\n        } catch {}\n        try {\n          element.dispatchEvent(new MouseEvent('click', base));\n        } catch {}\n        try {\n          element.dispatchEvent(new MouseEvent('dblclick', base));\n        } catch {}\n      }, 30);\n    }\n  }\n\n  /**\n   * Check if an element is visible\n   * @param {Element} element - The element to check\n   * @returns {boolean} - Whether the element is visible\n   */\n  function isElementVisible(element) {\n    if (!element) return false;\n\n    const style = window.getComputedStyle(element);\n    if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {\n      return false;\n    }\n\n    const rect = element.getBoundingClientRect();\n    if (rect.width === 0 || rect.height === 0) {\n      return false;\n    }\n\n    if (\n      rect.bottom < 0 ||\n      rect.top > window.innerHeight ||\n      rect.right < 0 ||\n      rect.left > window.innerWidth\n    ) {\n      return false;\n    }\n\n    const centerX = rect.left + rect.width / 2;\n    const centerY = rect.top + rect.height / 2;\n\n    const elementAtPoint = document.elementFromPoint(centerX, centerY);\n    if (!elementAtPoint) return false;\n\n    return element === elementAtPoint || element.contains(elementAtPoint);\n  }\n\n  // Listen for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    if (request.action === 'clickElement') {\n      clickElement(\n        request.selector,\n        request.waitForNavigation,\n        request.timeout,\n        request.coordinates,\n        request.ref,\n        !!request.double,\n        {\n          button: request.button,\n          bubbles: request.bubbles,\n          cancelable: request.cancelable,\n          modifiers: request.modifiers,\n        },\n      )\n        .then(sendResponse)\n        .catch((error) => {\n          sendResponse({\n            error: `Unexpected error: ${error.message}`,\n          });\n        });\n      return true; // Indicates async response\n    } else if (request.action === 'chrome_click_element_ping') {\n      sendResponse({ status: 'pong' });\n      return false;\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/dom-observer.js",
    "content": "/* eslint-disable */\n// dom-observer.js - observe DOM for triggers and notify background\n(function () {\n  if (window.__RR_DOM_OBSERVER__) return;\n  window.__RR_DOM_OBSERVER__ = true;\n\n  const active = { triggers: [], hits: new Map() };\n\n  function now() {\n    return Date.now();\n  }\n\n  function applyTriggers(list) {\n    try {\n      active.triggers = Array.isArray(list) ? list.slice() : [];\n      active.hits.clear();\n      checkAll();\n    } catch (e) {}\n  }\n\n  function checkAll() {\n    try {\n      for (const t of active.triggers) {\n        maybeFire(t);\n      }\n    } catch (e) {}\n  }\n\n  function maybeFire(t) {\n    try {\n      const appear = t.appear !== false; // default true\n      const sel = String(t.selector || '').trim();\n      if (!sel) return;\n      const exists = !!document.querySelector(sel);\n      const key = t.id;\n      const last = active.hits.get(key) || 0;\n      const debounce = Math.max(0, Number(t.debounceMs ?? 800));\n      if (now() - last < debounce) return;\n      const should = appear ? exists : !exists;\n      if (should) {\n        active.hits.set(key, now());\n        chrome.runtime.sendMessage({\n          action: 'dom_trigger_fired',\n          triggerId: t.id,\n          url: location.href,\n        });\n        if (t.once !== false) removeTrigger(t.id);\n      }\n    } catch (e) {}\n  }\n\n  function removeTrigger(id) {\n    try {\n      active.triggers = active.triggers.filter((x) => x.id !== id);\n    } catch (e) {}\n  }\n\n  const mo = new MutationObserver(() => {\n    checkAll();\n  });\n  try {\n    mo.observe(document.documentElement || document, {\n      childList: true,\n      subtree: true,\n      attributes: false,\n      characterData: false,\n    });\n  } catch (e) {}\n\n  chrome.runtime.onMessage.addListener((req, _sender, sendResponse) => {\n    try {\n      if (req && req.action === 'dom_observer_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n      if (req && req.action === 'set_dom_triggers') {\n        applyTriggers(req.triggers || []);\n        sendResponse({ success: true, count: active.triggers.length });\n        return true;\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n      return true;\n    }\n    return false;\n  });\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/element-marker.js",
    "content": "/* eslint-disable */\n(function () {\n  if (window.__ELEMENT_MARKER_INSTALLED__) return;\n  window.__ELEMENT_MARKER_INSTALLED__ = true;\n\n  const IS_MAIN = window === window.top;\n\n  // ============================================================================\n  // Utility Functions\n  // ============================================================================\n\n  function sleep(ms) {\n    return new Promise((resolve) => setTimeout(resolve, ms));\n  }\n\n  // ============================================================================\n  // Constants & Configuration\n  // ============================================================================\n\n  const CONFIG = {\n    DEFAULTS: {\n      PREFS: {\n        preferId: true,\n        preferStableAttr: true,\n        preferClass: true,\n      },\n      SELECTOR_TYPE: 'css',\n      LIST_MODE: false,\n    },\n    Z_INDEX: {\n      OVERLAY: 2147483646,\n      HIGHLIGHTER: 2147483645,\n      RECTS: 2147483644,\n    },\n    COLORS: {\n      PRIMARY: '#2563eb',\n      SUCCESS: '#10b981',\n      WARNING: '#f59e0b',\n      DANGER: '#ef4444',\n      HOVER: '#10b981',\n      VERIFY: '#3b82f6',\n    },\n  };\n\n  // ============================================================================\n  // Panel Host Module - Shadow DOM Management\n  // ============================================================================\n\n  const PanelHost = (() => {\n    let hostElement = null;\n    let shadowRoot = null;\n\n    const PANEL_STYLES = `\n      * {\n        box-sizing: border-box;\n        margin: 0;\n        padding: 0;\n      }\n\n      .em-panel {\n        width: 400px;\n        background: #ffffff;\n        border-radius: 12px;\n        box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);\n        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n        padding: 20px;\n        transition: opacity 150ms ease;\n      }\n\n\n      /* Header */\n      .em-header {\n        display: flex;\n        align-items: center;\n        justify-content: space-between;\n        margin-bottom: 20px;\n        user-select: none;\n      }\n\n      .em-title {\n        font-size: 20px;\n        font-weight: 500;\n        color: #262626;\n      }\n\n      .em-header-actions {\n        display: flex;\n        gap: 4px;\n        align-items: center;\n      }\n\n      .em-icon-btn {\n        width: 32px;\n        height: 32px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        border: none;\n        background: transparent;\n        color: #a3a3a3;\n        cursor: pointer;\n        transition: color 150ms ease;\n        padding: 0;\n      }\n\n      .em-icon-btn:hover {\n        color: #525252;\n      }\n\n      .em-icon-btn svg {\n        width: 20px;\n        height: 20px;\n        stroke-width: 2;\n      }\n\n      /* Controls Row */\n      .em-controls {\n        display: flex;\n        gap: 8px;\n        margin-bottom: 12px;\n      }\n\n      .em-select-wrapper {\n        flex: 1;\n        position: relative;\n      }\n\n      .em-select {\n        width: 100%;\n        height: 44px;\n        padding: 0 40px 0 16px;\n        background: #f5f5f5;\n        color: #262626;\n        font-size: 15px;\n        border: none;\n        border-radius: 10px;\n        appearance: none;\n        cursor: pointer;\n        outline: none;\n        font-family: inherit;\n        font-weight: 400;\n      }\n\n      .em-select-wrapper::after {\n        content: '';\n        position: absolute;\n        right: 16px;\n        top: 50%;\n        transform: translateY(-50%);\n        width: 0;\n        height: 0;\n        border-left: 5px solid transparent;\n        border-right: 5px solid transparent;\n        border-top: 6px solid #737373;\n        pointer-events: none;\n      }\n\n      .em-square-btn {\n        width: 44px;\n        height: 44px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background: #f5f5f5;\n        border: none;\n        border-radius: 10px;\n        cursor: pointer;\n        transition: background 150ms ease;\n        padding: 0;\n      }\n\n      .em-square-btn:hover {\n        background: #e5e5e5;\n      }\n\n      .em-square-btn.active {\n        background: #2563eb;\n      }\n\n      .em-square-btn.active svg {\n        color: #ffffff;\n      }\n\n      .em-square-btn svg {\n        width: 18px;\n        height: 18px;\n        color: #525252;\n        stroke-width: 2;\n      }\n\n      /* Selector Display */\n      .em-selector-display {\n        display: flex;\n        align-items: center;\n        gap: 10px;\n        height: 44px;\n        padding: 0 12px 0 16px;\n        background: #f5f5f5;\n        border-radius: 10px;\n        margin-bottom: 16px;\n      }\n\n      .em-selector-display svg {\n        width: 18px;\n        height: 18px;\n        color: #a3a3a3;\n        flex-shrink: 0;\n        stroke-width: 2;\n      }\n\n      .em-selector-text {\n        flex: 1;\n        font-size: 14px;\n        color: #525252;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        user-select: text;\n      }\n\n      .em-selector-nav {\n        display: flex;\n        gap: 2px;\n      }\n\n      .em-nav-btn {\n        width: 28px;\n        height: 28px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        border: none;\n        background: transparent;\n        cursor: pointer;\n        transition: background 150ms ease;\n        border-radius: 6px;\n        padding: 0;\n      }\n\n      .em-nav-btn:hover {\n        background: #e5e5e5;\n      }\n\n      .em-nav-btn svg {\n        width: 16px;\n        height: 16px;\n        color: #525252;\n        stroke-width: 2;\n      }\n\n      /* Tabs */\n      .em-tabs {\n        display: inline-flex;\n        gap: 2px;\n        padding: 2px;\n        background: #f5f5f5;\n        border-radius: 8px;\n        margin-bottom: 16px;\n      }\n\n      .em-tab {\n        padding: 6px 16px;\n        font-size: 12px;\n        font-weight: 500;\n        color: #737373;\n        background: transparent;\n        border: none;\n        border-radius: 6px;\n        cursor: pointer;\n        transition: all 150ms ease;\n      }\n\n      .em-tab:hover {\n        color: #404040;\n      }\n\n      .em-tab.active {\n        color: #262626;\n        background: #ffffff;\n        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n      }\n\n      /* Content */\n      .em-content {\n        margin-bottom: 0;\n      }\n\n      #__em_tab_settings {\n        max-height: min(60vh, 480px);\n        overflow-y: auto;\n        scrollbar-width: none; /* Firefox */\n        -ms-overflow-style: none; /* IE and Edge */\n      }\n\n      #__em_tab_settings::-webkit-scrollbar {\n        display: none; /* Chrome, Safari, Opera */\n      }\n\n      .em-section-title {\n        font-size: 13px;\n        color: #737373;\n        margin-bottom: 16px;\n        font-weight: 400;\n      }\n\n      .em-attributes {\n        display: flex;\n        flex-direction: column;\n        gap: 12px;\n      }\n\n      .em-attribute {\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n      }\n\n      .em-attribute-label {\n        font-size: 12px;\n        color: #a3a3a3;\n        font-weight: 400;\n      }\n\n      .em-attribute-value {\n        display: flex;\n        align-items: center;\n        gap: 10px;\n        min-height: 44px;\n        padding: 0 12px 0 16px;\n        background: #f5f5f5;\n        border-radius: 10px;\n      }\n\n      .em-attribute-value.editable {\n        padding: 0 16px;\n      }\n\n      .em-attribute-value svg {\n        width: 18px;\n        height: 18px;\n        stroke-width: 2;\n        cursor: pointer;\n        transition: color 150ms ease;\n        flex-shrink: 0;\n      }\n\n      .em-attribute-value svg.copy-icon {\n        color: #a3a3a3;\n      }\n\n      .em-attribute-value svg.copy-icon:hover {\n        color: #525252;\n      }\n\n      .em-attribute-value svg.copy-icon.disabled {\n        color: #d4d4d4;\n        cursor: default;\n      }\n\n      .em-attribute-text {\n        flex: 1;\n        font-size: 14px;\n        color: #404040;\n        user-select: text;\n      }\n\n      .em-attribute-text.empty {\n        color: #a3a3a3;\n      }\n\n      .em-input {\n        flex: 1;\n        border: none;\n        background: transparent;\n        font-size: 14px;\n        color: #404040;\n        font-family: inherit;\n        outline: none;\n        padding: 0;\n        height: 44px;\n      }\n\n      .em-input::placeholder {\n        color: #a3a3a3;\n      }\n\n      /* Settings Panel */\n      .em-settings {\n        display: flex;\n        flex-direction: column;\n        gap: 16px;\n      }\n\n      .em-settings-group {\n        display: flex;\n        flex-direction: column;\n        gap: 8px;\n      }\n\n      .em-settings-label {\n        font-size: 12px;\n        font-weight: 500;\n        color: #737373;\n      }\n\n      .em-checkbox-group {\n        display: flex;\n        flex-direction: column;\n        gap: 10px;\n      }\n\n      .em-checkbox-label {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        font-size: 14px;\n        color: #404040;\n        cursor: pointer;\n      }\n\n      .em-checkbox-label input[type=\"checkbox\"] {\n        width: 18px;\n        height: 18px;\n        cursor: pointer;\n        margin: 0;\n      }\n\n      /* Action Buttons */\n      .em-actions {\n        display: flex;\n        gap: 8px;\n        margin-top: 20px;\n      }\n\n      .em-btn {\n        flex: 1;\n        height: 40px;\n        border: none;\n        border-radius: 8px;\n        font-size: 14px;\n        font-weight: 600;\n        cursor: pointer;\n        transition: all 150ms ease;\n      }\n\n      .em-btn-primary {\n        background: #2563eb;\n        color: #ffffff;\n      }\n\n      .em-btn-primary:hover {\n        background: #1d4ed8;\n        transform: translateY(-1px);\n        box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);\n      }\n\n      .em-btn-success {\n        background: #10b981;\n        color: #ffffff;\n      }\n\n      .em-btn-success:hover {\n        background: #059669;\n        transform: translateY(-1px);\n        box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);\n      }\n\n      .em-btn-ghost {\n        background: #f5f5f5;\n        color: #404040;\n      }\n\n      .em-btn-ghost:hover {\n        background: #e5e5e5;\n      }\n\n      /* Footer */\n      .em-footer {\n        font-size: 12px;\n        color: #a3a3a3;\n        text-align: center;\n        margin-top: 16px;\n      }\n\n      .em-footer kbd {\n        display: inline-block;\n        padding: 2px 6px;\n        background: #f5f5f5;\n        border-radius: 4px;\n        font-family: monospace;\n        font-size: 11px;\n        color: #737373;\n      }\n\n      /* Status */\n      .em-status {\n        font-size: 13px;\n        padding: 10px 12px;\n        border-radius: 8px;\n        margin-bottom: 12px;\n        display: flex;\n        align-items: center;\n        gap: 6px;\n      }\n\n      .em-status.idle {\n        display: none;\n      }\n\n      .em-status.running {\n        background: rgba(37, 99, 235, 0.1);\n        color: #2563eb;\n      }\n\n      .em-status.success {\n        background: rgba(16, 185, 129, 0.1);\n        color: #10b981;\n      }\n\n      .em-status.failure {\n        background: rgba(239, 68, 68, 0.1);\n        color: #ef4444;\n      }\n\n      /* Grid Layout */\n      .em-grid {\n        display: grid;\n        grid-template-columns: repeat(2, 1fr);\n        gap: 12px;\n      }\n\n      .em-field {\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n      }\n\n      .em-field-label {\n        font-size: 12px;\n        color: #a3a3a3;\n      }\n\n      .em-field-input {\n        height: 40px;\n        padding: 0 12px;\n        background: #f5f5f5;\n        border: none;\n        border-radius: 8px;\n        font-size: 14px;\n        color: #404040;\n        font-family: inherit;\n        outline: none;\n      }\n\n      .em-field-input:focus {\n        background: #e5e5e5;\n      }\n\n      /* Details/Accordion */\n      .em-details {\n        margin-top: 12px;\n        padding-top: 12px;\n        border-top: 1px solid #f5f5f5;\n      }\n\n      .em-details summary {\n        cursor: pointer;\n        font-size: 13px;\n        font-weight: 600;\n        color: #737373;\n        padding: 8px 0;\n        user-select: none;\n        list-style: none;\n      }\n\n      .em-details summary::-webkit-details-marker {\n        display: none;\n      }\n\n      .em-details summary:hover {\n        color: #404040;\n      }\n\n      .em-details[open] summary {\n        margin-bottom: 12px;\n      }\n\n      /* Dragging state */\n      body[data-em-dragging] {\n        user-select: none !important;\n        cursor: grabbing !important;\n      }\n\n      body[data-em-dragging] * {\n        cursor: grabbing !important;\n      }\n\n      /* SVG Icons */\n      svg {\n        fill: none;\n        stroke: currentColor;\n      }\n\n      .em-drag-handle {\n        cursor: grab;\n      }\n\n      .em-drag-handle:active {\n        cursor: grabbing;\n      }\n    `;\n\n    const PANEL_TEMPLATE = `\n      <div class=\"em-panel\" id=\"em_panel_root\">\n        <!-- Header -->\n        <div class=\"em-header em-drag-handle\" id=\"__em_drag_handle\" title=\"Drag to move\">\n          <h2 class=\"em-title\">元素标注</h2>\n          <div class=\"em-header-actions\">\n            <button class=\"em-icon-btn\" id=\"__em_close\" title=\"Close\">\n              <svg viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\"/>\n              </svg>\n            </button>\n          </div>\n        </div>\n\n        <!-- Controls -->\n        <div class=\"em-controls\">\n          <div class=\"em-select-wrapper\">\n            <select class=\"em-select\" id=\"__em_selector_type\">\n              <option value=\"css\">CSS Selector</option>\n              <option value=\"xpath\">XPath</option>\n            </select>\n          </div>\n          <button class=\"em-square-btn\" id=\"__em_toggle_list\" title=\"列表模式 - 批量标注相似元素 (仅支持CSS)\">\n            <svg viewBox=\"0 0 24 24\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 6h16M4 12h16M4 18h16\"/>\n            </svg>\n          </button>\n          <button class=\"em-square-btn\" id=\"__em_toggle_tab\" title=\"Toggle Execute tab\">\n            <svg viewBox=\"0 0 24 24\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"/>\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"/>\n            </svg>\n          </button>\n        </div>\n\n        <!-- Selector Display -->\n        <div class=\"em-selector-display\">\n          <svg viewBox=\"0 0 24 24\" id=\"__em_copy_selector\" title=\"Copy selector\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"/>\n          </svg>\n          <span class=\"em-selector-text\" id=\"__em_selector_text\">Click an element to select</span>\n          <div class=\"em-selector-nav\">\n            <button class=\"em-nav-btn\" id=\"__em_nav_up\" title=\"Select parent\">\n              <svg viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 15l7-7 7 7\"/>\n              </svg>\n            </button>\n            <button class=\"em-nav-btn\" id=\"__em_nav_down\" title=\"Select child\">\n              <svg viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M19 9l-7 7-7-7\"/>\n              </svg>\n            </button>\n          </div>\n        </div>\n\n        <!-- Tabs -->\n        <div class=\"em-tabs\">\n          <button class=\"em-tab active\" data-tab=\"attributes\">Attributes</button>\n          <button class=\"em-tab\" data-tab=\"execute\">Execute</button>\n        </div>\n\n        <!-- Status -->\n        <div class=\"em-status idle\" id=\"__em_status\"></div>\n\n        <!-- Content: Attributes Tab -->\n        <div class=\"em-content\" id=\"__em_tab_attributes\">\n          <h3 class=\"em-section-title\">#1 Element</h3>\n          \n          <div class=\"em-attributes\">\n            <div class=\"em-attribute\">\n              <div class=\"em-attribute-label\">name</div>\n              <div class=\"em-attribute-value editable\">\n                <input class=\"em-input\" id=\"__em_name\" placeholder=\"Element name\" />\n              </div>\n            </div>\n\n            <div class=\"em-attribute\">\n              <div class=\"em-attribute-label\">selector</div>\n              <div class=\"em-attribute-value\">\n                <svg class=\"copy-icon\" viewBox=\"0 0 24 24\" id=\"__em_copy\" title=\"Copy\">\n                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\"/>\n                </svg>\n                <span class=\"em-attribute-text\" id=\"__em_selector\">-</span>\n              </div>\n            </div>\n          </div>\n\n          <h3 class=\"em-section-title\">Selector Preferences</h3>\n          <div class=\"em-settings\">\n            <div class=\"em-checkbox-group\">\n              <label class=\"em-checkbox-label\">\n                <input type=\"checkbox\" id=\"__em_pref_id\" checked />\n                <span>Prefer ID</span>\n              </label>\n              <label class=\"em-checkbox-label\">\n                <input type=\"checkbox\" id=\"__em_pref_attr\" checked />\n                <span>Prefer stable attributes</span>\n              </label>\n              <label class=\"em-checkbox-label\">\n                <input type=\"checkbox\" id=\"__em_pref_class\" checked />\n                <span>Prefer class names</span>\n              </label>\n            </div>\n          </div>\n\n          <div class=\"em-actions\">\n            <button class=\"em-btn em-btn-primary\" id=\"__em_verify\">Verify (Highlight Only)</button>\n          </div>\n\n          <div class=\"em-actions\">\n            <button class=\"em-btn em-btn-success\" id=\"__em_save\">Save</button>\n            <button class=\"em-btn em-btn-ghost\" id=\"__em_cancel\">Cancel</button>\n          </div>\n        </div>\n\n        <!-- Content: Execute Tab -->\n        <div class=\"em-content\" id=\"__em_tab_execute\" style=\"display: none;\">\n          <div class=\"em-settings\">\n            <div class=\"em-settings-group\">\n              <div class=\"em-settings-label\">Action</div>\n              <div class=\"em-select-wrapper\">\n                <select class=\"em-select\" id=\"__em_action\">\n                  <option value=\"hover\">Hover</option>\n                  <option value=\"left_click\">Left click</option>\n                  <option value=\"double_click\">Double click</option>\n                  <option value=\"right_click\">Right click</option>\n                  <option value=\"scroll\">Scroll</option>\n                  <option value=\"type_text\">Type text</option>\n                  <option value=\"press_keys\">Press keys</option>\n                </select>\n              </div>\n            </div>\n\n            <!-- Action-specific inputs (dynamically shown/hidden) -->\n            <div class=\"em-settings-group\" id=\"__em_action_text_group\" style=\"display: none;\">\n              <div class=\"em-settings-label\">Text</div>\n              <input class=\"em-field-input\" id=\"__em_action_text\" placeholder=\"Text to type\" />\n            </div>\n\n            <div class=\"em-settings-group\" id=\"__em_action_keys_group\" style=\"display: none;\">\n              <div class=\"em-settings-label\">Keys</div>\n              <input class=\"em-field-input\" id=\"__em_action_keys\" placeholder=\"Keys to press (e.g., Enter, Ctrl+C)\" />\n            </div>\n\n            <div class=\"em-settings-group\" id=\"__em_scroll_options\" style=\"display: none;\">\n              <div class=\"em-settings-label\">Scroll Direction</div>\n              <div class=\"em-select-wrapper\">\n                <select class=\"em-select\" id=\"__em_scroll_direction\">\n                  <option value=\"down\">Down</option>\n                  <option value=\"up\">Up</option>\n                  <option value=\"left\">Left</option>\n                  <option value=\"right\">Right</option>\n                </select>\n              </div>\n              <div class=\"em-field\" style=\"margin-top: 8px;\">\n                <div class=\"em-field-label\">Amount (1-10, ~100px each)</div>\n                <input class=\"em-field-input\" id=\"__em_scroll_distance\" type=\"number\" min=\"1\" max=\"10\" step=\"1\" value=\"3\" />\n              </div>\n            </div>\n\n            <!-- Click-specific options -->\n            <div id=\"__em_click_options\" style=\"display: none;\">\n              <div class=\"em-grid\">\n                <div class=\"em-field\">\n                  <div class=\"em-field-label\">Button</div>\n                  <select class=\"em-select\" id=\"__em_btn\">\n                    <option value=\"left\">Left</option>\n                    <option value=\"middle\">Middle</option>\n                    <option value=\"right\">Right</option>\n                  </select>\n                </div>\n                <div class=\"em-field\">\n                  <div class=\"em-field-label\">Timeout (ms)</div>\n                  <input class=\"em-field-input\" id=\"__em_nav_timeout\" type=\"number\" value=\"3000\" />\n                </div>\n              </div>\n\n              <div class=\"em-checkbox-group\" style=\"margin-top: 12px;\">\n                <label class=\"em-checkbox-label\">\n                  <input type=\"checkbox\" id=\"__em_wait_nav\" />\n                  <span>Wait for navigation</span>\n                </label>\n                <label class=\"em-checkbox-label\">\n                  <input type=\"checkbox\" id=\"__em_mod_alt\" />\n                  <span>Alt key</span>\n                </label>\n                <label class=\"em-checkbox-label\">\n                  <input type=\"checkbox\" id=\"__em_mod_ctrl\" />\n                  <span>Ctrl key</span>\n                </label>\n                <label class=\"em-checkbox-label\">\n                  <input type=\"checkbox\" id=\"__em_mod_meta\" />\n                  <span>Meta key</span>\n                </label>\n                <label class=\"em-checkbox-label\">\n                  <input type=\"checkbox\" id=\"__em_mod_shift\" />\n                  <span>Shift key</span>\n                </label>\n              </div>\n            </div>\n\n            <div class=\"em-actions\" style=\"margin-top: 16px;\">\n              <button class=\"em-btn em-btn-primary\" id=\"__em_execute\">Execute</button>\n            </div>\n\n            <!-- Execution History -->\n            <div id=\"__em_execution_history\" style=\"margin-top: 16px; display: none;\">\n              <div class=\"em-settings-label\">Recent Executions</div>\n              <div id=\"__em_history_list\" style=\"font-size: 12px; color: #737373; margin-top: 8px;\"></div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Footer -->\n        <div class=\"em-footer\">\n          Click or press <kbd>Space</kbd> to select an element\n        </div>\n      </div>\n    `;\n\n    function mount() {\n      if (hostElement) return { host: hostElement, shadow: shadowRoot };\n\n      hostElement = document.createElement('div');\n      hostElement.id = '__element_marker_overlay';\n      Object.assign(hostElement.style, {\n        position: 'fixed',\n        top: '24px',\n        right: '24px',\n        zIndex: String(CONFIG.Z_INDEX.OVERLAY),\n        pointerEvents: 'none',\n      });\n\n      shadowRoot = hostElement.attachShadow({ mode: 'open' });\n      shadowRoot.innerHTML = `<style>${PANEL_STYLES}</style>${PANEL_TEMPLATE}`;\n\n      hostElement.querySelector = (...args) => shadowRoot.querySelector(...args);\n      hostElement.querySelectorAll = (...args) => shadowRoot.querySelectorAll(...args);\n\n      const panel = shadowRoot.querySelector('.em-panel');\n      if (panel) {\n        panel.style.pointerEvents = 'auto';\n      }\n\n      document.documentElement.appendChild(hostElement);\n      return { host: hostElement, shadow: shadowRoot };\n    }\n\n    function unmount() {\n      if (hostElement?.parentNode) {\n        hostElement.parentNode.removeChild(hostElement);\n      }\n      hostElement = null;\n      shadowRoot = null;\n    }\n\n    function getHost() {\n      return hostElement;\n    }\n\n    function getShadow() {\n      return shadowRoot;\n    }\n\n    return {\n      mount,\n      unmount,\n      getHost,\n      getShadow,\n    };\n  })();\n\n  // ============================================================================\n  // State Store Module - Centralized State Management\n  // ============================================================================\n\n  const StateStore = (() => {\n    const state = {\n      selectorType: CONFIG.DEFAULTS.SELECTOR_TYPE,\n      listMode: CONFIG.DEFAULTS.LIST_MODE,\n      prefs: { ...CONFIG.DEFAULTS.PREFS },\n      activeTab: 'attributes',\n      validation: {\n        status: 'idle',\n        message: '',\n      },\n      validationHistory: [], // Last 5 validation results\n    };\n\n    const listeners = new Set();\n\n    function init() {\n      return state;\n    }\n\n    function get(key) {\n      return key ? state[key] : state;\n    }\n\n    function set(partial) {\n      const changed = {};\n\n      Object.keys(partial).forEach((key) => {\n        if (JSON.stringify(state[key]) !== JSON.stringify(partial[key])) {\n          changed[key] = true;\n          state[key] = partial[key];\n        }\n      });\n\n      if (Object.keys(changed).length === 0) return;\n\n      if (changed.validation) {\n        updateValidationUI();\n      }\n      if (changed.activeTab) {\n        updateTabUI();\n      }\n      if (changed.listMode) {\n        updateListModeUI();\n      }\n      if (changed.validationHistory) {\n        updateValidationHistoryUI();\n      }\n\n      notifyListeners();\n    }\n\n    function subscribe(callback) {\n      listeners.add(callback);\n      return () => listeners.delete(callback);\n    }\n\n    function notifyListeners() {\n      listeners.forEach((cb) => {\n        try {\n          cb(state);\n        } catch (err) {\n          console.error('[StateStore] Listener error:', err);\n        }\n      });\n    }\n\n    function updateValidationUI() {\n      const statusEl = PanelHost.getShadow()?.getElementById('__em_status');\n      if (!statusEl) return;\n\n      const { status, message } = state.validation;\n      statusEl.className = `em-status ${status}`;\n      statusEl.textContent = message;\n    }\n\n    function updateListModeUI() {\n      const shadow = PanelHost.getShadow();\n      if (!shadow) return;\n\n      const btn = shadow.getElementById('__em_toggle_list');\n      if (!btn) return;\n\n      if (state.listMode) {\n        btn.classList.add('active');\n      } else {\n        btn.classList.remove('active');\n      }\n    }\n\n    function updateTabUI() {\n      const shadow = PanelHost.getShadow();\n      if (!shadow) return;\n\n      const tabs = shadow.querySelectorAll('.em-tab');\n      tabs.forEach((tab) => {\n        if (tab.dataset.tab === state.activeTab) {\n          tab.classList.add('active');\n        } else {\n          tab.classList.remove('active');\n        }\n      });\n\n      const attrContent = shadow.getElementById('__em_tab_attributes');\n      const executeContent = shadow.getElementById('__em_tab_execute');\n\n      if (attrContent)\n        attrContent.style.display = state.activeTab === 'attributes' ? 'block' : 'none';\n      if (executeContent)\n        executeContent.style.display = state.activeTab === 'execute' ? 'block' : 'none';\n\n      // Sync interaction mode when tab changes\n      syncInteractionMode();\n    }\n\n    function updateValidationHistoryUI() {\n      const shadow = PanelHost.getShadow();\n      if (!shadow) return;\n\n      const historyContainer = shadow.getElementById('__em_execution_history');\n      const historyList = shadow.getElementById('__em_history_list');\n      if (!historyContainer || !historyList) return;\n\n      if (state.validationHistory.length === 0) {\n        historyContainer.style.display = 'none';\n        return;\n      }\n\n      historyContainer.style.display = 'block';\n      historyList.innerHTML = state.validationHistory\n        .slice(-5)\n        .reverse()\n        .map((entry) => {\n          const icon = entry.success ? '✓' : '✗';\n          const color = entry.success ? '#10b981' : '#ef4444';\n          const timestamp = new Date(entry.timestamp).toLocaleTimeString();\n          return `<div style=\"padding: 6px 0; border-bottom: 1px solid #f5f5f5;\">\n            <span style=\"color: ${color}; font-weight: 600;\">${icon}</span>\n            <span style=\"margin-left: 6px;\">${entry.action}</span>\n            <span style=\"float: right; color: #a3a3a3; font-size: 11px;\">${timestamp}</span>\n          </div>`;\n        })\n        .join('');\n    }\n\n    return {\n      init,\n      get,\n      set,\n      subscribe,\n    };\n  })();\n\n  // ============================================================================\n  // Drag Controller Module\n  // ============================================================================\n\n  const DragController = (() => {\n    let dragging = false;\n    let startPos = { x: 0, y: 0 };\n    let startOffset = { top: 0, right: 0 };\n\n    function init(handleElement) {\n      if (!handleElement) return;\n      handleElement.addEventListener('mousedown', onDragStart);\n    }\n\n    function onDragStart(event) {\n      event.preventDefault();\n      dragging = true;\n\n      const host = PanelHost.getHost();\n      if (!host) return;\n\n      startPos = { x: event.clientX, y: event.clientY };\n      startOffset = {\n        top: parseInt(host.style.top) || 0,\n        right: parseInt(host.style.right) || 0,\n      };\n\n      document.addEventListener('mousemove', onDragMove, { capture: true, passive: false });\n      document.addEventListener('mouseup', onDragEnd, { capture: true, passive: false });\n      document.body.setAttribute('data-em-dragging', 'true');\n    }\n\n    function onDragMove(event) {\n      if (!dragging) return;\n      event.preventDefault();\n      event.stopPropagation();\n\n      const host = PanelHost.getHost();\n      if (!host) return;\n\n      const deltaX = event.clientX - startPos.x;\n      const deltaY = event.clientY - startPos.y;\n\n      const newTop = Math.max(8, startOffset.top + deltaY);\n      const newRight = Math.max(8, startOffset.right - deltaX);\n\n      host.style.top = `${newTop}px`;\n      host.style.right = `${newRight}px`;\n    }\n\n    function onDragEnd(event) {\n      if (!dragging) return;\n      event.preventDefault();\n      event.stopPropagation();\n\n      dragging = false;\n      document.removeEventListener('mousemove', onDragMove, { capture: true });\n      document.removeEventListener('mouseup', onDragEnd, { capture: true });\n      document.body.removeAttribute('data-em-dragging');\n    }\n\n    function destroy() {\n      if (dragging) {\n        onDragEnd(new MouseEvent('mouseup'));\n      }\n    }\n\n    return { init, destroy };\n  })();\n\n  // [继续下一部分...]\n  // ============================================================================\n  // Selector Engine - Heuristic Selector Generation\n  // ============================================================================\n\n  function generateSelector(el) {\n    if (!(el instanceof Element)) return '';\n\n    const prefs = StateStore.get('prefs');\n\n    if (prefs.preferId && el.id) {\n      const idSel = `#${CSS.escape(el.id)}`;\n      if (isDeepSelectorUnique(idSel, el)) return idSel;\n    }\n\n    if (prefs.preferStableAttr) {\n      const attrNames = [\n        'data-testid',\n        'data-testId',\n        'data-test',\n        'data-qa',\n        'data-cy',\n        'name',\n        'title',\n        'alt',\n        'aria-label',\n      ];\n      const tag = el.tagName.toLowerCase();\n\n      for (const attr of attrNames) {\n        const v = el.getAttribute(attr);\n        if (!v) continue;\n        const attrSel = `[${attr}=\"${CSS.escape(v)}\"]`;\n        const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;\n        if (isDeepSelectorUnique(testSel, el)) return testSel;\n      }\n    }\n\n    if (prefs.preferClass) {\n      try {\n        const classes = Array.from(el.classList || []).filter(\n          (c) => c && /^[a-zA-Z0-9_-]+$/.test(c),\n        );\n        const tag = el.tagName.toLowerCase();\n\n        for (const cls of classes) {\n          const sel = `.${CSS.escape(cls)}`;\n          if (isDeepSelectorUnique(sel, el)) return sel;\n        }\n\n        for (const cls of classes) {\n          const sel = `${tag}.${CSS.escape(cls)}`;\n          if (isDeepSelectorUnique(sel, el)) return sel;\n        }\n\n        for (let i = 0; i < Math.min(classes.length, 3); i++) {\n          for (let j = i + 1; j < Math.min(classes.length, 3); j++) {\n            const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;\n            if (isDeepSelectorUnique(sel, el)) return sel;\n          }\n        }\n      } catch {}\n    }\n\n    if (prefs.preferStableAttr) {\n      try {\n        let cur = el;\n        const anchorAttrs = [\n          'id',\n          'data-testid',\n          'data-testId',\n          'data-test',\n          'data-qa',\n          'data-cy',\n          'name',\n        ];\n\n        // Detect shadow DOM boundary\n        const root = el.getRootNode();\n        const isShadowElement = root instanceof ShadowRoot;\n        const boundary = isShadowElement ? root.host : document.body;\n\n        while (cur && cur !== boundary) {\n          if (cur.id) {\n            const anchor = `#${CSS.escape(cur.id)}`;\n            if (isDeepSelectorUnique(anchor, cur)) {\n              const rel = buildPathFromAncestor(cur, el);\n              const composed = rel ? `${anchor} ${rel}` : anchor;\n              if (isDeepSelectorUnique(composed, el)) return composed;\n            }\n          }\n\n          for (const attr of anchorAttrs) {\n            const val = cur.getAttribute(attr);\n            if (!val) continue;\n            const aSel = `[${attr}=\"${CSS.escape(val)}\"]`;\n            if (isDeepSelectorUnique(aSel, cur)) {\n              const rel = buildPathFromAncestor(cur, el);\n              const composed = rel ? `${aSel} ${rel}` : aSel;\n              if (isDeepSelectorUnique(composed, el)) return composed;\n            }\n          }\n          cur = cur.parentElement;\n        }\n      } catch {}\n    }\n\n    return buildFullPath(el);\n  }\n\n  function buildPathFromAncestor(ancestor, target) {\n    const segs = [];\n    let cur = target;\n\n    // Detect if we're inside shadow DOM\n    const root = target.getRootNode();\n    const isShadowElement = root instanceof ShadowRoot;\n    const boundary = isShadowElement ? root.host : document.body;\n\n    while (cur && cur !== ancestor && cur !== boundary) {\n      let seg = cur.tagName.toLowerCase();\n      const parent = cur.parentElement;\n\n      if (parent) {\n        const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);\n        if (siblings.length > 1) {\n          seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;\n        }\n      }\n\n      segs.unshift(seg);\n      cur = parent;\n\n      // Stop if we've reached the shadow root host\n      if (isShadowElement && cur === boundary) {\n        break;\n      }\n    }\n\n    return segs.join(' > ');\n  }\n\n  function buildFullPath(el) {\n    let path = '';\n    let current = el;\n\n    // Detect if the element is inside a shadow DOM\n    const root = el.getRootNode();\n    const isShadowElement = root instanceof ShadowRoot;\n\n    // Determine the boundary where we should stop traversing\n    const boundary = isShadowElement ? root.host : document.body;\n\n    while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {\n      let sel = current.tagName.toLowerCase();\n      const parent = current.parentElement;\n\n      if (parent) {\n        const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);\n        if (siblings.length > 1) {\n          sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;\n        }\n      }\n\n      path = path ? `${sel} > ${path}` : sel;\n      current = parent;\n\n      // Stop if we've reached the shadow root host\n      if (isShadowElement && current === boundary) {\n        break;\n      }\n    }\n\n    // For shadow DOM elements, don't prepend \"body >\"\n    // The selector should be relative within the shadow tree\n    if (isShadowElement) {\n      return path || el.tagName.toLowerCase();\n    }\n\n    // For light DOM elements, keep the original behavior\n    return path ? `body > ${path}` : 'body';\n  }\n\n  function generateXPath(el) {\n    if (!(el instanceof Element)) return '';\n    if (el.id) return `//*[@id=\"${el.id}\"]`;\n\n    const segs = [];\n    let cur = el;\n\n    while (cur && cur.nodeType === 1 && cur !== document.documentElement) {\n      const tag = cur.tagName.toLowerCase();\n\n      if (cur.id) {\n        segs.unshift(`//*[@id=\"${cur.id}\"]`);\n        break;\n      }\n\n      let i = 1;\n      let sib = cur;\n      while ((sib = sib.previousElementSibling)) {\n        if (sib.tagName.toLowerCase() === tag) i++;\n      }\n\n      segs.unshift(`${tag}[${i}]`);\n      cur = cur.parentElement;\n    }\n\n    return segs[0]?.startsWith('//*') ? segs.join('/') : '//' + segs.join('/');\n  }\n\n  function generateListSelector(target) {\n    const list = computeElementList(target);\n    const selected = list?.[0] || target;\n    const parent = selected.parentElement;\n\n    if (!parent) return generateSelector(target);\n\n    const parentSel = generateSelector(parent);\n    const childRel = generateSelectorWithinRoot(selected, parent);\n\n    return parentSel && childRel ? `${parentSel} ${childRel}` : generateSelector(target);\n  }\n\n  function generateSelectorWithinRoot(el, root) {\n    if (!(el instanceof Element)) return '';\n\n    const tag = el.tagName.toLowerCase();\n\n    // Use isDeepSelectorUnique for ID to support shadow DOM elements\n    if (el.id) {\n      const idSel = `#${CSS.escape(el.id)}`;\n      if (isDeepSelectorUnique(idSel, el)) return idSel;\n    }\n\n    const attrNames = [\n      'data-testid',\n      'data-testId',\n      'data-test',\n      'data-qa',\n      'data-cy',\n      'name',\n      'title',\n      'alt',\n      'aria-label',\n    ];\n\n    // Use isDeepSelectorUnique for attributes to support shadow DOM elements\n    for (const attr of attrNames) {\n      const v = el.getAttribute(attr);\n      if (!v) continue;\n      const aSel = `[${attr}=\"${CSS.escape(v)}\"]`;\n      const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${aSel}` : aSel;\n      if (isDeepSelectorUnique(testSel, el)) return testSel;\n    }\n\n    try {\n      const classes = Array.from(el.classList || []).filter((c) => c && /^[a-zA-Z0-9_-]+$/.test(c));\n\n      // Use isDeepSelectorUnique for classes to support shadow DOM elements\n      for (const cls of classes) {\n        const sel = `.${CSS.escape(cls)}`;\n        if (isDeepSelectorUnique(sel, el)) return sel;\n      }\n\n      for (const cls of classes) {\n        const sel = `${tag}.${CSS.escape(cls)}`;\n        if (isDeepSelectorUnique(sel, el)) return sel;\n      }\n    } catch {}\n\n    return buildPathFromAncestor(root, el);\n  }\n\n  function getAccessibleName(el) {\n    try {\n      const labelledby = el.getAttribute('aria-labelledby');\n      if (labelledby) {\n        const labelEl = document.getElementById(labelledby);\n        if (labelEl) return (labelEl.textContent || '').trim();\n      }\n\n      const ariaLabel = el.getAttribute('aria-label');\n      if (ariaLabel) return ariaLabel.trim();\n\n      if (el.id) {\n        const label = document.querySelector(`label[for=\"${el.id}\"]`);\n        if (label) return (label.textContent || '').trim();\n      }\n\n      const parentLabel = el.closest('label');\n      if (parentLabel) return (parentLabel.textContent || '').trim();\n\n      return (\n        el.getAttribute('placeholder') ||\n        el.getAttribute('value') ||\n        el.textContent ||\n        ''\n      ).trim();\n    } catch {\n      return '';\n    }\n  }\n\n  // ============================================================================\n  // List Mode Utilities\n  // ============================================================================\n\n  function getAllSiblings(el, selector) {\n    const siblings = [el];\n    const validate = (element) => {\n      const isSameTag = el.tagName === element.tagName;\n      let ok = isSameTag;\n      if (selector) {\n        try {\n          ok = ok && !!element.querySelector(selector);\n        } catch {}\n      }\n      return ok;\n    };\n\n    let next = el;\n    let prev = el;\n    let elementIndex = 1;\n\n    while ((prev = prev?.previousElementSibling)) {\n      if (validate(prev)) {\n        elementIndex += 1;\n        siblings.unshift(prev);\n      }\n    }\n\n    while ((next = next?.nextElementSibling)) {\n      if (validate(next)) siblings.push(next);\n    }\n\n    return { elements: siblings, index: elementIndex };\n  }\n\n  function getElementList(el, maxDepth = 50, paths = []) {\n    if (maxDepth === 0 || !el || el.tagName === 'BODY') return null;\n\n    let selector = el.tagName.toLowerCase();\n    const { elements, index } = getAllSiblings(el, paths.join(' > '));\n    let siblings = elements;\n\n    if (index !== 1) selector += `:nth-of-type(${index})`;\n    paths.unshift(selector);\n\n    if (siblings.length === 1) {\n      siblings = getElementList(el.parentElement, maxDepth - 1, paths);\n    }\n\n    return siblings;\n  }\n\n  function computeElementList(target) {\n    try {\n      return getElementList(target) || [target];\n    } catch {\n      return [target];\n    }\n  }\n\n  // ============================================================================\n  // Deep Query (Shadow DOM Support)\n  // ============================================================================\n\n  function* walkAllNodesDeep(root) {\n    const stack = [root];\n    let count = 0;\n    const MAX = 10000;\n\n    while (stack.length) {\n      const node = stack.pop();\n      if (!node || ++count > MAX) continue;\n\n      // Skip overlay elements to prevent panel self-highlighting\n      if (isOverlayElement(node)) {\n        continue;\n      }\n\n      yield node;\n\n      try {\n        if (node.children) {\n          const children = Array.from(node.children);\n          for (let i = children.length - 1; i >= 0; i--) {\n            stack.push(children[i]);\n          }\n        }\n\n        if (node.shadowRoot?.children) {\n          const srChildren = Array.from(node.shadowRoot.children);\n          for (let i = srChildren.length - 1; i >= 0; i--) {\n            stack.push(srChildren[i]);\n          }\n        }\n      } catch {}\n    }\n  }\n\n  function queryAllDeep(selector) {\n    const results = [];\n    for (const node of walkAllNodesDeep(document)) {\n      if (!(node instanceof Element)) continue;\n      try {\n        if (node.matches(selector)) results.push(node);\n      } catch {}\n    }\n    return results;\n  }\n\n  /**\n   * Check if a selector uniquely identifies the target element across the entire DOM tree,\n   * including shadow DOM boundaries.\n   *\n   * This function uses queryAllDeep to traverse both light DOM and shadow DOM,\n   * ensuring that selectors work correctly for elements inside shadow roots.\n   *\n   * @param {string} selector - The CSS selector to test\n   * @param {Element} target - The target element that should be uniquely identified\n   * @returns {boolean} True if the selector matches exactly one element and it's the target\n   */\n  function isDeepSelectorUnique(selector, target) {\n    if (!selector || !(target instanceof Element)) return false;\n    try {\n      const matches = queryAllDeep(selector);\n      return matches.length === 1 && matches[0] === target;\n    } catch (error) {\n      return false;\n    }\n  }\n\n  function evaluateXPathAll(xpath) {\n    try {\n      const arr = [];\n      const res = document.evaluate(\n        xpath,\n        document,\n        null,\n        XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,\n        null,\n      );\n\n      for (let i = 0; i < res.snapshotLength; i++) {\n        const n = res.snapshotItem(i);\n        // Filter out overlay elements to prevent panel self-highlighting\n        if (n?.nodeType === 1 && !isOverlayElement(n)) {\n          arr.push(n);\n        }\n      }\n      return arr;\n    } catch {\n      return [];\n    }\n  }\n\n  // ============================================================================\n  // Highlighter & Rects Management\n  // ============================================================================\n\n  const STATE = {\n    active: false,\n    hoverEl: null,\n    selectedEl: null,\n    box: null,\n    highlighter: null,\n    listenersAttached: false,\n    rectsHost: null,\n    hoveredList: [],\n    verifyRectsActive: false, // Track if verify rects are showing\n    // Performance optimization: rAF throttling for hover\n    hoverRafId: null,\n    lastHoverTarget: null,\n    // DOM pooling for rect elements\n    rectPool: [],\n    rectPoolUsed: 0,\n  };\n\n  function ensureHighlighter() {\n    if (STATE.highlighter) return STATE.highlighter;\n\n    const hl = document.createElement('div');\n    hl.id = '__element_marker_highlight';\n    Object.assign(hl.style, {\n      position: 'fixed',\n      zIndex: String(CONFIG.Z_INDEX.HIGHLIGHTER),\n      pointerEvents: 'none',\n      border: `2px solid ${CONFIG.COLORS.HOVER}`,\n      borderRadius: '4px',\n      boxShadow: `0 0 0 2px ${CONFIG.COLORS.HOVER}33`,\n      transition: 'all 100ms ease-out',\n    });\n\n    document.documentElement.appendChild(hl);\n    STATE.highlighter = hl;\n    return hl;\n  }\n\n  function ensureRectsHost() {\n    if (STATE.rectsHost) return STATE.rectsHost;\n\n    const host = document.createElement('div');\n    host.id = '__element_marker_rects';\n    Object.assign(host.style, {\n      position: 'fixed',\n      zIndex: String(CONFIG.Z_INDEX.RECTS),\n      pointerEvents: 'none',\n      inset: '0',\n    });\n\n    document.documentElement.appendChild(host);\n    STATE.rectsHost = host;\n    return host;\n  }\n\n  function moveHighlighterTo(el) {\n    const hl = ensureHighlighter();\n    const r = el.getBoundingClientRect();\n    hl.style.left = `${r.left}px`;\n    hl.style.top = `${r.top}px`;\n    hl.style.width = `${r.width}px`;\n    hl.style.height = `${r.height}px`;\n    hl.style.display = 'block';\n  }\n\n  function clearHighlighter() {\n    if (STATE.highlighter) STATE.highlighter.style.display = 'none';\n    // Only clear hover rects, not verify rects\n    if (!STATE.verifyRectsActive) {\n      clearRects();\n    }\n  }\n\n  function clearRects() {\n    // Hide all pooled rect boxes instead of destroying them\n    const used = STATE.rectPoolUsed || 0;\n    for (let i = 0; i < used; i++) {\n      const box = STATE.rectPool[i];\n      if (box) box.style.display = 'none';\n    }\n    STATE.rectPoolUsed = 0;\n    STATE.verifyRectsActive = false;\n    // Invalidate lastHoverTarget so next hover will redraw even on same element\n    STATE.lastHoverTarget = null;\n  }\n\n  /**\n   * Get or create a rect box from the pool\n   * @param {HTMLElement} host - The container element\n   * @param {number} index - The pool index\n   * @returns {HTMLDivElement} The rect box element\n   */\n  function getOrCreateRectBox(host, index) {\n    let box = STATE.rectPool[index];\n    if (!box) {\n      box = document.createElement('div');\n      Object.assign(box.style, {\n        position: 'fixed',\n        pointerEvents: 'none',\n        borderRadius: '4px',\n        transition: 'all 100ms ease-out',\n        display: 'none',\n      });\n      STATE.rectPool[index] = box;\n    }\n    // Ensure the box is attached to the host\n    if (!box.isConnected) {\n      host.appendChild(box);\n    }\n    return box;\n  }\n\n  // Maximum rect pool size to prevent memory bloat\n  const MAX_RECT_POOL_SIZE = 100;\n\n  /**\n   * Draw rect boxes with pooling optimization\n   * @param {Array<{x: number, y: number, width: number, height: number}>} rects - Rect data\n   * @param {Object} options - Drawing options\n   * @param {boolean} options.isVerify - Whether this is a verify highlight (affects verifyRectsActive)\n   */\n  function drawRectBoxes(\n    rects,\n    { color = CONFIG.COLORS.HOVER, dashed = true, offsetX = 0, offsetY = 0, isVerify = false } = {},\n  ) {\n    const host = ensureRectsHost();\n    const prevUsed = STATE.rectPoolUsed || 0;\n    // Limit rect count to prevent memory bloat\n    const count = Math.min(Array.isArray(rects) ? rects.length : 0, MAX_RECT_POOL_SIZE);\n\n    // Update or show rect boxes\n    for (let i = 0; i < count; i++) {\n      const r = rects[i];\n      if (!r) continue;\n\n      const x = Number.isFinite(r.left) ? r.left : Number.isFinite(r.x) ? r.x : 0;\n      const y = Number.isFinite(r.top) ? r.top : Number.isFinite(r.y) ? r.y : 0;\n      const w = Number.isFinite(r.width) ? r.width : 0;\n      const h = Number.isFinite(r.height) ? r.height : 0;\n\n      const box = getOrCreateRectBox(host, i);\n      Object.assign(box.style, {\n        left: `${offsetX + x}px`,\n        top: `${offsetY + y}px`,\n        width: `${w}px`,\n        height: `${h}px`,\n        border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`,\n        boxShadow: `0 0 0 2px ${color}22`,\n        display: 'block',\n      });\n    }\n\n    // Hide excess boxes from previous render\n    for (let i = count; i < prevUsed; i++) {\n      const box = STATE.rectPool[i];\n      if (box) box.style.display = 'none';\n    }\n\n    STATE.rectPoolUsed = count;\n    // Reset verifyRectsActive for hover operations (so clearHighlighter works correctly)\n    // Only set to true when isVerify is explicitly true\n    STATE.verifyRectsActive = isVerify;\n  }\n\n  function drawRects(elements, color = CONFIG.COLORS.HOVER, dashed = true, isVerify = false) {\n    const rects = elements.map((el) => {\n      const r = el.getBoundingClientRect();\n      return { x: r.left, y: r.top, width: r.width, height: r.height };\n    });\n    drawRectBoxes(rects, { color, dashed, isVerify });\n  }\n\n  // ============================================================================\n  // Interaction Logic\n  // ============================================================================\n\n  function isInsidePanel(target) {\n    const shadow = PanelHost.getShadow();\n    return !!shadow && target instanceof Node && shadow.contains(target);\n  }\n\n  /**\n   * Check if a node belongs to the element marker overlay (panel host or its shadow DOM)\n   * This is used to filter out overlay elements from query results to prevent self-highlighting\n   *\n   * @param {Node} node - The node to check\n   * @returns {boolean} True if the node is part of the overlay\n   */\n  function isOverlayElement(node) {\n    if (!(node instanceof Node)) return false;\n\n    const host = PanelHost.getHost();\n    if (!host) return false;\n\n    // Check if node is the panel host itself\n    if (node === host) return true;\n\n    // Check if node is within the shadow DOM of the panel host\n    const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;\n    return root instanceof ShadowRoot && root.host === host;\n  }\n\n  /**\n   * Filter out overlay elements from an array of elements\n   * This ensures that panel components are never included in highlight/verification results\n   *\n   * @param {Array} elements - Array of elements to filter\n   * @returns {Array} Filtered array without overlay elements\n   */\n  function filterOverlayElements(elements) {\n    if (!Array.isArray(elements)) return [];\n    return elements.filter((node) => !isOverlayElement(node));\n  }\n\n  /**\n   * Get the effective event target for page element selection, considering shadow DOM boundaries.\n   *\n   * This function resolves the real target element from a pointer event by walking the\n   * composed path (if available) to find the innermost page element, skipping overlay elements.\n   *\n   * Background:\n   * - When events bubble up from inside shadow DOM, they get \"retargeted\" at shadow boundaries\n   * - By the time a window-level listener receives the event, ev.target points to the shadow host\n   * - composedPath() exposes the original event path before retargeting\n   * - This allows us to select elements inside shadow DOM (e.g., <td-header> internals)\n   *\n   * IMPORTANT: This function should only be called AFTER verifying the event is not from\n   * overlay UI (panel buttons, etc). Otherwise it will filter out overlay elements and break\n   * panel interactions.\n   *\n   * @param {Event} ev - The pointer event (mousemove, click, etc.)\n   * @returns {Element|null} The innermost non-overlay page element, or null if none found\n   */\n  function getDeepPageTarget(ev) {\n    if (!ev) return null;\n\n    // Try to walk the composed path to find the innermost non-overlay element\n    try {\n      const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;\n      if (Array.isArray(path) && path.length > 0) {\n        // Walk from innermost to outermost, find the first real page element\n        for (const node of path) {\n          if (node instanceof Element && !isOverlayElement(node)) {\n            return node;\n          }\n        }\n      }\n    } catch (error) {\n      // composedPath() may throw in some edge cases (e.g., detached nodes)\n      // Fall through to use ev.target\n    }\n\n    // Fallback: use ev.target if composedPath is unavailable or all nodes were filtered\n    const fallback = ev.target instanceof Element ? ev.target : null;\n    // If fallback is overlay, return null (caller should handle this case)\n    if (fallback && !isOverlayElement(fallback)) {\n      return fallback;\n    }\n    return null;\n  }\n\n  // Store pending hover event for rAF processing\n  let pendingHoverEvent = null;\n\n  /**\n   * Process mouse move event - the actual hover update logic\n   * Separated from onMouseMove for rAF throttling\n   */\n  function processMouseMove(ev) {\n    if (!STATE.active) return;\n\n    const rawTarget = ev?.target;\n    if (!(rawTarget instanceof Element)) {\n      STATE.hoverEl = null;\n      STATE.lastHoverTarget = null;\n      clearHighlighter();\n      return;\n    }\n\n    const host = PanelHost.getHost();\n    if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {\n      STATE.hoverEl = null;\n      STATE.lastHoverTarget = null;\n      clearHighlighter();\n      return;\n    }\n\n    const target = getDeepPageTarget(ev) || rawTarget;\n    STATE.hoverEl = target;\n\n    // Get current listMode\n    let listMode = false;\n    try {\n      listMode = !!StateStore.get('listMode');\n    } catch {}\n\n    // Skip update if target and mode haven't changed\n    const last = STATE.lastHoverTarget;\n    if (last && last.element === target && last.listMode === listMode) {\n      return;\n    }\n    STATE.lastHoverTarget = { element: target, listMode };\n\n    if (!IS_MAIN) {\n      try {\n        const list = listMode ? computeElementList(target) || [target] : [target];\n        const rects = list.map((el) => {\n          const r = el.getBoundingClientRect();\n          return { x: r.left, y: r.top, width: r.width, height: r.height };\n        });\n\n        // Performance: Don't generate selector on hover (defer to click)\n        window.top.postMessage({ type: 'em_hover', rects }, '*');\n      } catch {}\n      return;\n    }\n\n    if (listMode) {\n      STATE.hoveredList = computeElementList(target) || [target];\n      drawRects(STATE.hoveredList);\n    } else {\n      moveHighlighterTo(target);\n    }\n  }\n\n  /**\n   * Mouse move handler with rAF throttling\n   * Ensures hover updates are batched to animation frame rate\n   */\n  function onMouseMove(ev) {\n    if (!STATE.active) return;\n\n    // Store the latest event\n    pendingHoverEvent = ev;\n\n    // Skip if already scheduled\n    if (STATE.hoverRafId != null) return;\n\n    // Schedule processing on next animation frame\n    STATE.hoverRafId = requestAnimationFrame(() => {\n      STATE.hoverRafId = null;\n      const latest = pendingHoverEvent;\n      pendingHoverEvent = null;\n      if (!latest) return;\n      processMouseMove(latest);\n    });\n  }\n\n  // ============================================================================\n  // Event Listeners Management\n  // ============================================================================\n\n  function attachPointerListeners() {\n    if (STATE.listenersAttached) return;\n    window.addEventListener('mousemove', onMouseMove, true);\n    window.addEventListener('click', onClick, true);\n    STATE.listenersAttached = true;\n  }\n\n  function detachPointerListeners() {\n    if (!STATE.listenersAttached) return;\n    window.removeEventListener('mousemove', onMouseMove, true);\n    window.removeEventListener('click', onClick, true);\n    STATE.listenersAttached = false;\n  }\n\n  function attachKeyboardListener() {\n    window.addEventListener('keydown', onKeyDown, true);\n  }\n\n  function detachKeyboardListener() {\n    window.removeEventListener('keydown', onKeyDown, true);\n  }\n\n  function syncInteractionMode() {\n    if (!STATE.active) return;\n    const activeTab = StateStore.get('activeTab');\n    if (activeTab === 'execute') {\n      // In execute mode, detach pointer listeners to allow real interactions\n      // but keep keyboard listener for Esc key\n      detachPointerListeners();\n      // Only clear the hover highlighter, not the verification rects\n      if (STATE.highlighter) STATE.highlighter.style.display = 'none';\n    } else {\n      // In attributes mode, attach all listeners for element selection\n      attachPointerListeners();\n    }\n  }\n\n  // ============================================================================\n  // Event Handlers\n  // ============================================================================\n\n  function onClick(ev) {\n    if (!STATE.active) return;\n\n    // First, use the raw ev.target to check for overlay UI\n    // This ensures panel buttons and other UI elements remain interactive\n    const rawTarget = ev.target;\n    const host = PanelHost.getHost();\n\n    // Check if raw target is the panel host itself or inside the shadow DOM\n    // IMPORTANT: Return early WITHOUT preventDefault to allow overlay button clicks\n    if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {\n      return;\n    }\n\n    // Now we know it's a page element, prevent default and get deep target\n    ev.preventDefault();\n    ev.stopPropagation();\n\n    if (!(rawTarget instanceof Element)) return;\n\n    // Get the deep target (considering shadow DOM) after confirming it's not overlay\n    const target = getDeepPageTarget(ev) || rawTarget;\n\n    if (!IS_MAIN) {\n      try {\n        const selectorType = StateStore.get('selectorType');\n        const listMode = StateStore.get('listMode');\n\n        const sel =\n          selectorType === 'xpath'\n            ? generateXPath(target)\n            : listMode\n              ? generateListSelector(target)\n              : generateSelector(target);\n\n        window.top.postMessage({ type: 'em_click', innerSel: sel }, '*');\n      } catch {}\n      return;\n    }\n\n    setSelection(target);\n  }\n\n  function onKeyDown(e) {\n    if (!STATE.active) return;\n\n    // Check if the focused element is inside the panel - if so, don't handle selection keys\n    if (isInsidePanel(e.target)) {\n      // Key event is from panel, don't interfere\n      if (e.key !== 'Escape') return; // Still allow Escape to close\n    }\n\n    // In execute mode, only handle Escape to close - don't intercept other keys\n    // This allows real page interactions (typing, scrolling, etc.)\n    const activeTab = StateStore.get('activeTab');\n    if (activeTab === 'execute') {\n      if (e.key === 'Escape') {\n        e.preventDefault();\n        stop();\n      }\n      return; // Don't intercept Space/Arrow keys in execute mode\n    }\n\n    if (e.key === 'Escape') {\n      e.preventDefault();\n      stop();\n    } else if (e.key === ' ' || e.code === 'Space') {\n      e.preventDefault();\n      const t = STATE.hoverEl || STATE.selectedEl;\n      if (t) setSelection(t);\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault();\n      const base = STATE.selectedEl || STATE.hoverEl;\n      if (base?.parentElement) setSelection(base.parentElement);\n    } else if (e.key === 'ArrowDown') {\n      e.preventDefault();\n      const base = STATE.selectedEl || STATE.hoverEl;\n      if (base?.firstElementChild) setSelection(base.firstElementChild);\n    }\n  }\n\n  function setSelection(el) {\n    if (!(el instanceof Element)) return;\n\n    STATE.selectedEl = el;\n\n    const selectorType = StateStore.get('selectorType');\n    const listMode = StateStore.get('listMode');\n\n    const sel =\n      selectorType === 'xpath'\n        ? generateXPath(el)\n        : listMode\n          ? generateListSelector(el)\n          : generateSelector(el);\n\n    const name = getAccessibleName(el) || el.tagName.toLowerCase();\n\n    const selectorText = STATE.box?.querySelector('#__em_selector');\n    const inputName = STATE.box?.querySelector('#__em_name');\n    const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');\n\n    if (selectorText) selectorText.textContent = sel;\n    if (selectorDisplay) selectorDisplay.textContent = sel;\n    if (inputName && !inputName.value) inputName.value = name;\n\n    moveHighlighterTo(el);\n  }\n\n  // ============================================================================\n  // Validation Logic\n  // ============================================================================\n\n  /**\n   * Verify selector by highlighting only (non-destructive)\n   */\n  async function verifyHighlightOnly() {\n    try {\n      const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();\n      if (!selector) return;\n\n      StateStore.set({\n        validation: { status: 'running', message: 'Verifying selector...' },\n      });\n\n      const selectorType = StateStore.get('selectorType');\n      const listMode = StateStore.get('listMode');\n      const effectiveType = listMode ? 'css' : selectorType;\n\n      // Query for matches\n      const matches =\n        effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);\n\n      // Additional defense: filter out any overlay elements that might have slipped through\n      const filteredMatches = filterOverlayElements(matches);\n\n      if (!filteredMatches || filteredMatches.length === 0) {\n        StateStore.set({\n          validation: { status: 'failure', message: 'No elements found' },\n        });\n        return;\n      }\n\n      // Scroll first match into view\n      const primaryMatch = filteredMatches[0];\n      if (primaryMatch) {\n        primaryMatch.scrollIntoView({\n          block: 'center',\n          inline: 'center',\n          behavior: 'smooth',\n        });\n      }\n\n      await sleep(200);\n\n      // Highlight matches with isVerify=true to prevent clearing on hover\n      drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false, true);\n\n      StateStore.set({\n        validation: {\n          status: 'success',\n          message: `Found ${filteredMatches.length} element${filteredMatches.length > 1 ? 's' : ''}`,\n        },\n      });\n\n      // Auto-clear highlight after 2 seconds\n      setTimeout(() => {\n        clearRects();\n        StateStore.set({\n          validation: { status: 'idle', message: '' },\n        });\n      }, 2000);\n    } catch (error) {\n      console.error('[verifyHighlightOnly] error:', error);\n      StateStore.set({\n        validation: { status: 'failure', message: error.message || 'Verification failed' },\n      });\n    }\n  }\n\n  /**\n   * Execute action on selector (destructive)\n   */\n  async function verifySelectorNow() {\n    try {\n      const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();\n      if (!selector) return;\n\n      StateStore.set({\n        validation: { status: 'running', message: 'Executing action...' },\n      });\n\n      const selectorType = StateStore.get('selectorType');\n      const listMode = StateStore.get('listMode');\n\n      const effectiveType = listMode ? 'css' : selectorType;\n\n      const matches =\n        effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);\n\n      // Additional defense: filter out any overlay elements that might have slipped through\n      const filteredMatches = filterOverlayElements(matches);\n\n      if (!filteredMatches || filteredMatches.length === 0) {\n        StateStore.set({\n          validation: { status: 'failure', message: 'No elements found' },\n        });\n        return;\n      }\n\n      drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);\n\n      const action = STATE.box?.querySelector('#__em_action')?.value || 'hover';\n\n      const payload = {\n        type: 'element_marker_validate',\n        selector,\n        selectorType: effectiveType,\n        action,\n        listMode,\n      };\n\n      // Action-specific parameters with validation\n      if (action === 'type_text') {\n        const actionText = String(\n          STATE.box?.querySelector('#__em_action_text')?.value || '',\n        ).trim();\n        if (!actionText) {\n          StateStore.set({\n            validation: { status: 'failure', message: 'Text is required for type_text' },\n          });\n          return;\n        }\n        payload.text = actionText;\n      }\n\n      if (action === 'press_keys') {\n        const actionKeys = String(\n          STATE.box?.querySelector('#__em_action_keys')?.value || '',\n        ).trim();\n        if (!actionKeys) {\n          StateStore.set({\n            validation: { status: 'failure', message: 'Keys are required for press_keys' },\n          });\n          return;\n        }\n        payload.keys = actionKeys;\n      }\n\n      if (action === 'scroll') {\n        const direction = STATE.box?.querySelector('#__em_scroll_direction')?.value || 'down';\n        const rawAmount = Number(STATE.box?.querySelector('#__em_scroll_distance')?.value);\n        // Clamp to 1-10 range (backend expects ticks, not pixels)\n        const amount = Math.max(\n          1,\n          Math.min(Math.round(Number.isFinite(rawAmount) ? rawAmount : 3), 10),\n        );\n        payload.scrollDirection = direction;\n        payload.scrollAmount = amount;\n      }\n\n      if (['left_click', 'double_click', 'right_click'].includes(action)) {\n        payload.modifiers = {\n          altKey: !!STATE.box?.querySelector('#__em_mod_alt')?.checked,\n          ctrlKey: !!STATE.box?.querySelector('#__em_mod_ctrl')?.checked,\n          metaKey: !!STATE.box?.querySelector('#__em_mod_meta')?.checked,\n          shiftKey: !!STATE.box?.querySelector('#__em_mod_shift')?.checked,\n        };\n        payload.button = STATE.box?.querySelector('#__em_btn')?.value || 'left';\n        payload.waitForNavigation = !!STATE.box?.querySelector('#__em_wait_nav')?.checked;\n        payload.timeoutMs = Number(STATE.box?.querySelector('#__em_nav_timeout')?.value) || 3000;\n      }\n\n      const res = await chrome.runtime.sendMessage(payload);\n\n      const success = !!res?.tool?.ok;\n      const newEntry = {\n        action,\n        success,\n        timestamp: Date.now(),\n        matchCount: filteredMatches.length,\n      };\n      const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);\n\n      if (res?.tool?.ok) {\n        StateStore.set({\n          validation: {\n            status: 'success',\n            message: `✓ 验证成功 (匹配 ${filteredMatches.length} 个元素)`,\n          },\n          validationHistory: history,\n        });\n      } else {\n        StateStore.set({\n          validation: {\n            status: 'failure',\n            message: res?.tool?.error || '验证失败',\n          },\n          validationHistory: history,\n        });\n      }\n    } catch (err) {\n      const newEntry = {\n        action: STATE.box?.querySelector('#__em_action')?.value || 'hover',\n        success: false,\n        timestamp: Date.now(),\n        matchCount: 0,\n      };\n      const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);\n\n      StateStore.set({\n        validation: {\n          status: 'failure',\n          message: `错误: ${err.message}`,\n        },\n        validationHistory: history,\n      });\n    }\n  }\n\n  /**\n   * Highlight selector from external request (popup/background)\n   * Supports composite iframe selectors: \"frameSelector |> innerSelector\"\n   */\n  async function highlightSelectorExternal({ selector, selectorType = 'css', listMode = false }) {\n    const normalized = String(selector || '').trim();\n    if (!normalized) {\n      return { success: false, error: 'selector is required' };\n    }\n\n    try {\n      // Handle composite iframe selector\n      if (normalized.includes('|>')) {\n        const parts = normalized\n          .split('|>')\n          .map((s) => s.trim())\n          .filter(Boolean);\n\n        if (parts.length >= 2) {\n          const frameSel = parts[0];\n          const innerSel = parts.slice(1).join(' |> ');\n\n          // Find frame element\n          let frameEl = null;\n          try {\n            frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel);\n          } catch {}\n\n          if (\n            !frameEl ||\n            !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement)\n          ) {\n            return { success: false, error: `Frame element not found: ${frameSel}` };\n          }\n\n          const cw = frameEl.contentWindow;\n          if (!cw) {\n            return { success: false, error: 'Unable to access frame contentWindow' };\n          }\n\n          // Forward highlight request to iframe\n          return new Promise((resolve) => {\n            const reqId = `em_highlight_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n            const listener = (ev) => {\n              try {\n                const data = ev?.data;\n                if (!data || data.type !== 'em-highlight-result' || data.reqId !== reqId) return;\n                window.removeEventListener('message', listener, true);\n                resolve(data.result);\n              } catch {}\n            };\n\n            window.addEventListener('message', listener, true);\n            setTimeout(() => {\n              window.removeEventListener('message', listener, true);\n              resolve({ success: false, error: 'Frame highlight timeout' });\n            }, 3000);\n\n            cw.postMessage(\n              {\n                type: 'em-highlight-request',\n                reqId,\n                selector: innerSel,\n                selectorType,\n                listMode,\n              },\n              '*',\n            );\n          });\n        }\n      }\n\n      // Handle normal selector (non-iframe)\n      const effectiveType = listMode ? 'css' : selectorType;\n      const matches =\n        effectiveType === 'xpath' ? evaluateXPathAll(normalized) : queryAllDeep(normalized);\n\n      // Additional defense: filter out any overlay elements that might have slipped through\n      const filteredMatches = filterOverlayElements(matches);\n\n      if (!filteredMatches || filteredMatches.length === 0) {\n        return { success: false, error: 'No elements found for selector' };\n      }\n\n      // Scroll first match into view\n      const primaryMatch = filteredMatches[0];\n      if (primaryMatch) {\n        primaryMatch.scrollIntoView({\n          block: 'center',\n          inline: 'center',\n          behavior: 'smooth',\n        });\n      }\n\n      await sleep(150);\n\n      // Draw highlight rectangles\n      drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);\n\n      // Auto-clear after 2 seconds\n      setTimeout(() => {\n        clearRects();\n      }, 2000);\n\n      return { success: true, count: filteredMatches.length };\n    } catch (error) {\n      return { success: false, error: error.message || String(error) };\n    }\n  }\n\n  function copySelectorNow() {\n    try {\n      const sel = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();\n      if (!sel) return;\n      navigator.clipboard?.writeText(sel).catch(() => {});\n\n      StateStore.set({\n        validation: { status: 'success', message: '✓ 已复制到剪贴板' },\n      });\n\n      setTimeout(() => {\n        StateStore.set({ validation: { status: 'idle', message: '' } });\n      }, 2000);\n    } catch {}\n  }\n\n  async function save() {\n    try {\n      const name = STATE.box?.querySelector('#__em_name')?.value?.trim();\n      const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();\n\n      if (!selector) return;\n\n      const url = location.href;\n      let selectorType = StateStore.get('selectorType');\n      const listMode = StateStore.get('listMode');\n\n      if (listMode && selectorType === 'xpath') {\n        selectorType = 'css';\n      }\n\n      await chrome.runtime.sendMessage({\n        type: 'element_marker_save',\n        marker: {\n          url,\n          name: name || selector,\n          selector,\n          selectorType,\n          listMode,\n        },\n      });\n    } catch {}\n\n    stop();\n  }\n\n  // ============================================================================\n  // Lifecycle Management\n  // ============================================================================\n\n  function start() {\n    if (STATE.active) return;\n    STATE.active = true;\n\n    if (IS_MAIN) {\n      const { host } = PanelHost.mount();\n      STATE.box = host;\n      StateStore.init();\n      bindControls();\n    }\n\n    ensureHighlighter();\n    ensureRectsHost();\n\n    attachPointerListeners();\n    attachKeyboardListener();\n    syncInteractionMode();\n  }\n\n  function stop() {\n    STATE.active = false;\n\n    detachPointerListeners();\n    detachKeyboardListener();\n\n    // Cancel pending rAF\n    if (STATE.hoverRafId != null) {\n      cancelAnimationFrame(STATE.hoverRafId);\n      STATE.hoverRafId = null;\n    }\n    pendingHoverEvent = null;\n\n    try {\n      STATE.highlighter?.remove();\n      STATE.rectsHost?.remove();\n      PanelHost.unmount();\n      DragController.destroy();\n    } catch {}\n\n    STATE.highlighter = null;\n    STATE.rectsHost = null;\n    STATE.box = null;\n    STATE.hoveredList = [];\n    STATE.hoverEl = null;\n    STATE.selectedEl = null;\n    STATE.lastHoverTarget = null;\n    STATE.verifyRectsActive = false;\n\n    // Clear rect pool to release DOM references\n    STATE.rectPool.length = 0;\n    STATE.rectPoolUsed = 0;\n  }\n\n  // ============================================================================\n  // Controls Binding\n  // ============================================================================\n\n  function bindControls() {\n    const host = STATE.box;\n    if (!host) return;\n\n    // Close/Cancel\n    host.querySelector('#__em_close')?.addEventListener('click', stop);\n    host.querySelector('#__em_cancel')?.addEventListener('click', stop);\n\n    // Save\n    host.querySelector('#__em_save')?.addEventListener('click', save);\n\n    // Verify (highlight only) & Execute (real action)\n    host.querySelector('#__em_verify')?.addEventListener('click', verifyHighlightOnly);\n    host.querySelector('#__em_execute')?.addEventListener('click', verifySelectorNow);\n\n    // Copy\n    host.querySelector('#__em_copy')?.addEventListener('click', copySelectorNow);\n    host.querySelector('#__em_copy_selector')?.addEventListener('click', copySelectorNow);\n\n    // Action change handler - show/hide action-specific options\n    host.querySelector('#__em_action')?.addEventListener('change', (e) => {\n      updateActionSpecificUI(e.target.value);\n    });\n\n    // Selector type\n    host.querySelector('#__em_selector_type')?.addEventListener('change', (e) => {\n      const newType = e.target.value;\n      const listMode = StateStore.get('listMode');\n\n      // If switching to XPath while in list mode, disable list mode\n      if (newType === 'xpath' && listMode) {\n        StateStore.set({ selectorType: newType, listMode: false });\n      } else {\n        StateStore.set({ selectorType: newType });\n      }\n\n      // Regenerate selector for the currently selected element\n      if (STATE.selectedEl) {\n        setSelection(STATE.selectedEl);\n      }\n      // Note: If no selectedEl (e.g., iframe selections or manual input),\n      // preserve existing selector text instead of clearing it\n    });\n\n    // List mode toggle\n    host.querySelector('#__em_toggle_list')?.addEventListener('click', (e) => {\n      const listMode = StateStore.get('listMode');\n      const newListMode = !listMode;\n\n      // If enabling list mode, force CSS selector type\n      if (newListMode) {\n        StateStore.set({ listMode: true, selectorType: 'css' });\n        const selectorTypeSelect = host.querySelector('#__em_selector_type');\n        if (selectorTypeSelect) selectorTypeSelect.value = 'css';\n      } else {\n        StateStore.set({ listMode: false });\n      }\n\n      // Update button active state\n      const btn = e.currentTarget;\n      if (btn) {\n        if (newListMode) {\n          btn.classList.add('active');\n        } else {\n          btn.classList.remove('active');\n        }\n      }\n\n      // Regenerate selector for the currently selected element\n      if (STATE.selectedEl) {\n        setSelection(STATE.selectedEl);\n      }\n\n      clearHighlighter();\n    });\n\n    // Tab toggle (switch between Attributes and Execute)\n    host.querySelector('#__em_toggle_tab')?.addEventListener('click', () => {\n      const currentTab = StateStore.get('activeTab');\n      StateStore.set({ activeTab: currentTab === 'attributes' ? 'execute' : 'attributes' });\n    });\n\n    // Tab switching\n    const tabs = host.querySelectorAll('.em-tab');\n    tabs.forEach((tab) => {\n      tab.addEventListener('click', () => {\n        StateStore.set({ activeTab: tab.dataset.tab });\n      });\n    });\n\n    // Navigation buttons\n    host.querySelector('#__em_nav_up')?.addEventListener('click', () => {\n      const base = STATE.selectedEl || STATE.hoverEl;\n      if (base?.parentElement) setSelection(base.parentElement);\n    });\n\n    host.querySelector('#__em_nav_down')?.addEventListener('click', () => {\n      const base = STATE.selectedEl || STATE.hoverEl;\n      if (base?.firstElementChild) setSelection(base.firstElementChild);\n    });\n\n    // Preferences\n    host.querySelector('#__em_pref_id')?.addEventListener('change', (e) => {\n      const prefs = { ...StateStore.get('prefs'), preferId: !!e.target.checked };\n      StateStore.set({ prefs });\n    });\n    host.querySelector('#__em_pref_attr')?.addEventListener('change', (e) => {\n      const prefs = { ...StateStore.get('prefs'), preferStableAttr: !!e.target.checked };\n      StateStore.set({ prefs });\n    });\n    host.querySelector('#__em_pref_class')?.addEventListener('change', (e) => {\n      const prefs = { ...StateStore.get('prefs'), preferClass: !!e.target.checked };\n      StateStore.set({ prefs });\n    });\n\n    // Drag - use entire header as drag handle\n    const dragHandle = host.querySelector('#__em_drag_handle');\n    if (dragHandle) {\n      DragController.init(dragHandle);\n    }\n\n    syncUIWithState();\n  }\n\n  function updateActionSpecificUI(action) {\n    const host = STATE.box;\n    if (!host) return;\n\n    // Hide all action-specific groups\n    const textGroup = host.querySelector('#__em_action_text_group');\n    const keysGroup = host.querySelector('#__em_action_keys_group');\n    const scrollOptions = host.querySelector('#__em_scroll_options');\n    const clickOptions = host.querySelector('#__em_click_options');\n\n    if (textGroup) textGroup.style.display = 'none';\n    if (keysGroup) keysGroup.style.display = 'none';\n    if (scrollOptions) scrollOptions.style.display = 'none';\n    if (clickOptions) clickOptions.style.display = 'none';\n\n    // Show relevant options based on action\n    if (action === 'type_text') {\n      if (textGroup) textGroup.style.display = 'block';\n    } else if (action === 'press_keys') {\n      if (keysGroup) keysGroup.style.display = 'block';\n    } else if (action === 'scroll') {\n      if (scrollOptions) scrollOptions.style.display = 'block';\n    } else if (['left_click', 'double_click', 'right_click'].includes(action)) {\n      if (clickOptions) clickOptions.style.display = 'block';\n\n      // For right_click, button selector is not relevant (always 'right')\n      // Hide the button field for right_click\n      const buttonField = host.querySelector('#__em_btn')?.closest('.em-field');\n      if (buttonField) {\n        buttonField.style.display = action === 'right_click' ? 'none' : 'block';\n      }\n    }\n    // hover: no extra options needed\n  }\n\n  function syncUIWithState() {\n    const host = STATE.box;\n    if (!host) return;\n\n    const state = StateStore.get();\n\n    const typeSelect = host.querySelector('#__em_selector_type');\n    if (typeSelect) typeSelect.value = state.selectorType;\n\n    // Initialize list mode button state\n    const listModeBtn = host.querySelector('#__em_toggle_list');\n    if (listModeBtn) {\n      if (state.listMode) {\n        listModeBtn.classList.add('active');\n      } else {\n        listModeBtn.classList.remove('active');\n      }\n    }\n\n    const prefId = host.querySelector('#__em_pref_id');\n    const prefAttr = host.querySelector('#__em_pref_attr');\n    const prefClass = host.querySelector('#__em_pref_class');\n    if (prefId) prefId.checked = state.prefs.preferId;\n    if (prefAttr) prefAttr.checked = state.prefs.preferStableAttr;\n    if (prefClass) prefClass.checked = state.prefs.preferClass;\n\n    // Initialize action-specific UI\n    const actionSelect = host.querySelector('#__em_action');\n    if (actionSelect) {\n      updateActionSpecificUI(actionSelect.value);\n    }\n  }\n\n  // ============================================================================\n  // Cross-Frame Bridge\n  // ============================================================================\n\n  // Register window message listener in all frames (not just main)\n  // to support cross-frame highlighting from popup validation\n  window.addEventListener(\n    'message',\n    (ev) => {\n      try {\n        const data = ev?.data;\n        if (!data) return;\n\n        // Handle iframe highlight request (works even when overlay is inactive)\n        if (data.type === 'em-highlight-request') {\n          highlightSelectorExternal({\n            selector: data.selector,\n            selectorType: data.selectorType || 'css',\n            listMode: !!data.listMode,\n          })\n            .then((result) => {\n              window.parent.postMessage(\n                {\n                  type: 'em-highlight-result',\n                  reqId: data.reqId,\n                  result,\n                },\n                '*',\n              );\n            })\n            .catch((error) => {\n              window.parent.postMessage(\n                {\n                  type: 'em-highlight-result',\n                  reqId: data.reqId,\n                  result: { success: false, error: error?.message || String(error) },\n                },\n                '*',\n              );\n            });\n          return;\n        }\n\n        // Following messages only relevant when overlay is active\n        if (!STATE.active) return;\n\n        // Only main frame handles these overlay-related messages\n        if (!IS_MAIN) return;\n\n        const iframes = Array.from(document.querySelectorAll('iframe'));\n        const host = iframes.find((f) => {\n          try {\n            return f.contentWindow === ev.source;\n          } catch {\n            return false;\n          }\n        });\n\n        if (!host) return;\n\n        const base = host.getBoundingClientRect();\n\n        if (data.type === 'em_hover' && Array.isArray(data.rects)) {\n          // Use pooled rect boxes for better performance\n          drawRectBoxes(data.rects, {\n            offsetX: base.left,\n            offsetY: base.top,\n            color: CONFIG.COLORS.HOVER,\n            dashed: true,\n          });\n        } else if (data.type === 'em_click' && data.innerSel) {\n          const frameSel = generateSelector(host);\n          const composite = frameSel ? `${frameSel} |> ${data.innerSel}` : data.innerSel;\n          const selectorText = STATE.box?.querySelector('#__em_selector');\n          const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');\n          if (selectorText) selectorText.textContent = composite;\n          if (selectorDisplay) selectorDisplay.textContent = composite;\n        }\n      } catch {}\n    },\n    true,\n  );\n\n  // ============================================================================\n  // Message Handlers\n  // ============================================================================\n\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    if (request?.action === 'element_marker_start') {\n      start();\n      sendResponse({ ok: true });\n      return true;\n    } else if (request?.action === 'element_marker_ping') {\n      sendResponse({ status: 'pong' });\n      return false;\n    } else if (request?.action === 'element_marker_highlight') {\n      highlightSelectorExternal({\n        selector: request.selector,\n        selectorType: request.selectorType,\n        listMode: !!request.listMode,\n      })\n        .then((result) => sendResponse(result))\n        .catch((error) => sendResponse({ success: false, error: error?.message || String(error) }));\n      return true;\n    }\n    return false;\n  });\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/element-picker.js",
    "content": "/* eslint-disable */\n/**\n * Element Picker Inject Script\n *\n * Injected script to let the user manually pick elements for chrome_request_element_selection.\n * - Writes refs into window.__claudeElementMap (compatible with accessibility-tree-helper.js)\n * - Generates stable CSS selectors (prefers id/data-testid/etc.)\n * - Supports iframe picking by reporting selection via chrome.runtime.sendMessage (background reads sender.frameId)\n */\n\n(function () {\n  'use strict';\n\n  // Prevent double initialization\n  if (window.__MCP_ELEMENT_PICKER_INITIALIZED__) return;\n  window.__MCP_ELEMENT_PICKER_INITIALIZED__ = true;\n\n  // ============================================================\n  // Constants\n  // ============================================================\n\n  const UI_HOST_ID = '__mcp_element_picker_host__';\n  const HIGHLIGHT_ID = '__mcp_element_picker_highlight__';\n  const MAX_TEXT_LEN = 160;\n\n  // Highlight colors matching Editorial accent (terracotta)\n  const HIGHLIGHT_COLOR = '#d97757';\n  const HIGHLIGHT_BG = 'rgba(217, 119, 87, 0.08)';\n  const HIGHLIGHT_BORDER = 'rgba(217, 119, 87, 0.4)';\n\n  // ============================================================\n  // State\n  // ============================================================\n\n  const STATE = {\n    active: false,\n    sessionId: null,\n    activeRequestId: null,\n    listenersAttached: false,\n    hoverRafId: null,\n    pendingHoverEvent: null,\n    lastHoverEl: null,\n    highlighter: null,\n  };\n\n  // ============================================================\n  // CSS Escape Helper\n  // ============================================================\n\n  function cssEscape(value) {\n    try {\n      if (window.CSS && typeof window.CSS.escape === 'function') {\n        return window.CSS.escape(value);\n      }\n    } catch {\n      // Fallback\n    }\n    return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\\\${c}`);\n  }\n\n  // ============================================================\n  // UI Detection Helpers\n  // ============================================================\n\n  function getUiHost() {\n    try {\n      return document.getElementById(UI_HOST_ID);\n    } catch {\n      return null;\n    }\n  }\n\n  function isOverlayElement(node) {\n    if (!(node instanceof Node)) return false;\n    const host = getUiHost();\n    if (!host) return false;\n    if (node === host) return true;\n    const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;\n    return root instanceof ShadowRoot && root.host === host;\n  }\n\n  function isEventFromUi(ev) {\n    if (!ev) return false;\n    try {\n      if (typeof ev.composedPath === 'function') {\n        const path = ev.composedPath();\n        if (Array.isArray(path)) {\n          return path.some((n) => isOverlayElement(n));\n        }\n      }\n    } catch {\n      // Fallback\n    }\n    return isOverlayElement(ev.target);\n  }\n\n  /**\n   * Get the deepest page target from an event, handling Shadow DOM.\n   */\n  function getDeepPageTarget(ev) {\n    if (!ev) return null;\n    try {\n      const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;\n      if (Array.isArray(path) && path.length > 0) {\n        for (const node of path) {\n          if (node instanceof Element && !isOverlayElement(node)) {\n            return node;\n          }\n        }\n      }\n    } catch {\n      // Fallback\n    }\n    const fallback = ev.target instanceof Element ? ev.target : null;\n    if (fallback && !isOverlayElement(fallback)) {\n      return fallback;\n    }\n    return null;\n  }\n\n  // ============================================================\n  // Highlighter\n  // ============================================================\n\n  function ensureHighlighter() {\n    if (STATE.highlighter && STATE.highlighter.isConnected) {\n      return STATE.highlighter;\n    }\n\n    // Remove any existing highlighter\n    try {\n      const existing = document.getElementById(HIGHLIGHT_ID);\n      if (existing) existing.remove();\n    } catch {\n      // Best effort\n    }\n\n    const hl = document.createElement('div');\n    hl.id = HIGHLIGHT_ID;\n    Object.assign(hl.style, {\n      position: 'fixed',\n      left: '0px',\n      top: '0px',\n      width: '0px',\n      height: '0px',\n      border: `2px solid ${HIGHLIGHT_COLOR}`,\n      borderRadius: '4px',\n      boxShadow: `0 0 0 1px ${HIGHLIGHT_BORDER}`,\n      background: HIGHLIGHT_BG,\n      pointerEvents: 'none',\n      zIndex: '2147483647',\n      display: 'none',\n      transition: 'transform 60ms linear, width 60ms linear, height 60ms linear',\n    });\n\n    try {\n      (document.documentElement || document.body).appendChild(hl);\n    } catch {\n      // Best effort\n    }\n\n    STATE.highlighter = hl;\n    return hl;\n  }\n\n  function clearHighlighter() {\n    const hl = STATE.highlighter;\n    if (!hl) return;\n    try {\n      hl.style.display = 'none';\n    } catch {\n      // Best effort\n    }\n  }\n\n  function moveHighlighterTo(el) {\n    const hl = ensureHighlighter();\n    if (!hl || !(el instanceof Element)) return;\n\n    let rect;\n    try {\n      rect = el.getBoundingClientRect();\n    } catch {\n      clearHighlighter();\n      return;\n    }\n\n    if (!rect || rect.width <= 0 || rect.height <= 0) {\n      clearHighlighter();\n      return;\n    }\n\n    try {\n      hl.style.display = 'block';\n      hl.style.transform = `translate(${Math.round(rect.left)}px, ${Math.round(rect.top)}px)`;\n      hl.style.width = `${Math.round(rect.width)}px`;\n      hl.style.height = `${Math.round(rect.height)}px`;\n    } catch {\n      // Best effort\n    }\n  }\n\n  // ============================================================\n  // Selector Uniqueness Check (Optimized)\n  // ============================================================\n\n  /**\n   * Check if element is inside a Shadow DOM.\n   */\n  function isInShadowDom(el) {\n    try {\n      const root = el.getRootNode();\n      return root instanceof ShadowRoot;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Fast uniqueness check using native querySelectorAll.\n   * For Shadow DOM elements, queries within their shadow root only.\n   */\n  function isSelectorUnique(selector, target) {\n    if (!selector || !(target instanceof Element)) return false;\n\n    try {\n      // For elements not in Shadow DOM, use fast native query\n      if (!isInShadowDom(target)) {\n        const matches = document.querySelectorAll(selector);\n        return matches.length === 1 && matches[0] === target;\n      }\n\n      // For Shadow DOM elements, query within their root\n      const root = target.getRootNode();\n      if (root instanceof ShadowRoot) {\n        const matches = root.querySelectorAll(selector);\n        return matches.length === 1 && matches[0] === target;\n      }\n\n      return false;\n    } catch {\n      return false;\n    }\n  }\n\n  // ============================================================\n  // Selector Generation (Stable & Unique)\n  // ============================================================\n\n  function buildPathFromAncestor(ancestor, target) {\n    const segs = [];\n    let cur = target;\n\n    const root = target.getRootNode();\n    const isShadowElement = root instanceof ShadowRoot;\n    const boundary = isShadowElement ? root.host : document.body;\n\n    while (cur && cur !== ancestor && cur !== boundary) {\n      let seg = cur.tagName.toLowerCase();\n      const parent = cur.parentElement;\n      if (parent) {\n        const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);\n        if (siblings.length > 1) {\n          seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;\n        }\n      }\n      segs.unshift(seg);\n      cur = parent;\n      if (isShadowElement && cur === boundary) break;\n    }\n\n    return segs.join(' > ');\n  }\n\n  function buildFullPath(el) {\n    let path = '';\n    let current = el;\n\n    const root = el.getRootNode();\n    const isShadowElement = root instanceof ShadowRoot;\n    const boundary = isShadowElement ? root.host : document.body;\n\n    while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {\n      let sel = current.tagName.toLowerCase();\n      const parent = current.parentElement;\n      if (parent) {\n        const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);\n        if (siblings.length > 1) {\n          sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;\n        }\n      }\n      path = path ? `${sel} > ${path}` : sel;\n      current = parent;\n      if (isShadowElement && current === boundary) break;\n    }\n\n    if (isShadowElement) return path || el.tagName.toLowerCase();\n    return path ? `body > ${path}` : 'body';\n  }\n\n  /**\n   * Generate a stable CSS selector for an element.\n   * Prioritizes: id > data-testid/data-test/etc > anchor + relative path > full path\n   */\n  function generateSelector(el) {\n    if (!(el instanceof Element)) return '';\n\n    // Prefer unique IDs\n    try {\n      if (el.id) {\n        const idSel = `#${cssEscape(el.id)}`;\n        if (isSelectorUnique(idSel, el)) return idSel;\n      }\n    } catch {\n      // Continue\n    }\n\n    // Prefer stable test attributes\n    try {\n      const attrNames = [\n        'data-testid',\n        'data-testId',\n        'data-test',\n        'data-qa',\n        'data-cy',\n        'name',\n        'aria-label',\n        'title',\n        'alt',\n      ];\n      const tag = el.tagName.toLowerCase();\n      for (const attr of attrNames) {\n        const v = el.getAttribute(attr);\n        if (!v) continue;\n        const attrSel = `[${attr}=\"${cssEscape(v)}\"]`;\n        const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;\n        if (isSelectorUnique(testSel, el)) return testSel;\n      }\n    } catch {\n      // Continue\n    }\n\n    // Anchor + relative path\n    try {\n      let cur = el;\n      const anchorAttrs = [\n        'id',\n        'data-testid',\n        'data-testId',\n        'data-test',\n        'data-qa',\n        'data-cy',\n        'name',\n      ];\n\n      const root = el.getRootNode();\n      const isShadowElement = root instanceof ShadowRoot;\n      const boundary = isShadowElement ? root.host : document.body;\n\n      while (cur && cur !== boundary) {\n        if (cur.id) {\n          const anchor = `#${cssEscape(cur.id)}`;\n          if (isSelectorUnique(anchor, cur)) {\n            const rel = buildPathFromAncestor(cur, el);\n            const composed = rel ? `${anchor} ${rel}` : anchor;\n            if (isSelectorUnique(composed, el)) return composed;\n          }\n        }\n\n        for (const attr of anchorAttrs) {\n          const val = cur.getAttribute(attr);\n          if (!val) continue;\n          const aSel = `[${attr}=\"${cssEscape(val)}\"]`;\n          if (isSelectorUnique(aSel, cur)) {\n            const rel = buildPathFromAncestor(cur, el);\n            const composed = rel ? `${aSel} ${rel}` : aSel;\n            if (isSelectorUnique(composed, el)) return composed;\n          }\n        }\n\n        cur = cur.parentElement;\n      }\n    } catch {\n      // Continue\n    }\n\n    // Fallback to full path\n    return buildFullPath(el);\n  }\n\n  // ============================================================\n  // Text Summarization\n  // ============================================================\n\n  function summarizeText(el) {\n    if (!(el instanceof Element)) return '';\n    try {\n      const aria = el.getAttribute('aria-label');\n      if (aria && aria.trim()) return aria.trim().slice(0, MAX_TEXT_LEN);\n      const placeholder = el.getAttribute('placeholder');\n      if (placeholder && placeholder.trim()) return placeholder.trim().slice(0, MAX_TEXT_LEN);\n      const title = el.getAttribute('title');\n      if (title && title.trim()) return title.trim().slice(0, MAX_TEXT_LEN);\n      const alt = el.getAttribute('alt');\n      if (alt && alt.trim()) return alt.trim().slice(0, MAX_TEXT_LEN);\n    } catch {\n      // Continue\n    }\n    try {\n      const t = (el.textContent || '').trim().replace(/\\s+/g, ' ');\n      return t ? t.slice(0, MAX_TEXT_LEN) : '';\n    } catch {\n      return '';\n    }\n  }\n\n  // ============================================================\n  // Ref Management (Compatible with accessibility-tree-helper.js)\n  // ============================================================\n\n  function ensureRefForElement(el) {\n    try {\n      if (!window.__claudeElementMap) window.__claudeElementMap = {};\n      if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n    } catch {\n      // Best effort\n    }\n\n    // Check if element already has a ref\n    let refId = null;\n    try {\n      for (const k in window.__claudeElementMap) {\n        const w = window.__claudeElementMap[k];\n        if (w && w.deref && w.deref() === el) {\n          refId = k;\n          break;\n        }\n      }\n    } catch {\n      // Continue\n    }\n\n    // Create new ref if needed\n    if (!refId) {\n      try {\n        refId = `ref_${++window.__claudeRefCounter}`;\n        window.__claudeElementMap[refId] = new WeakRef(el);\n      } catch {\n        // Continue\n      }\n    }\n\n    return refId || '';\n  }\n\n  // ============================================================\n  // Communication\n  // ============================================================\n\n  function sendFrameEvent(payload) {\n    try {\n      chrome.runtime.sendMessage(payload);\n    } catch {\n      // Best effort\n    }\n  }\n\n  // ============================================================\n  // Event Handlers\n  // ============================================================\n\n  function processMouseMove(ev) {\n    if (!STATE.active) return;\n\n    // Skip if event is from our UI\n    if (isEventFromUi(ev)) {\n      STATE.lastHoverEl = null;\n      clearHighlighter();\n      return;\n    }\n\n    const target = getDeepPageTarget(ev);\n    if (!target) {\n      STATE.lastHoverEl = null;\n      clearHighlighter();\n      return;\n    }\n\n    // Skip if same element\n    if (STATE.lastHoverEl === target) return;\n    STATE.lastHoverEl = target;\n    moveHighlighterTo(target);\n  }\n\n  function onMouseMove(ev) {\n    if (!STATE.active) return;\n    STATE.pendingHoverEvent = ev;\n    if (STATE.hoverRafId != null) return;\n    STATE.hoverRafId = requestAnimationFrame(() => {\n      STATE.hoverRafId = null;\n      const latest = STATE.pendingHoverEvent;\n      STATE.pendingHoverEvent = null;\n      if (!latest) return;\n      processMouseMove(latest);\n    });\n  }\n\n  function onClick(ev) {\n    if (!STATE.active) return;\n\n    // Allow UI interactions without interference\n    if (isEventFromUi(ev)) return;\n\n    const rawTarget = ev.target instanceof Element ? ev.target : null;\n    if (!rawTarget) return;\n\n    // Require an active request id so background can map the selection\n    if (!STATE.sessionId || !STATE.activeRequestId) return;\n\n    ev.preventDefault();\n    ev.stopPropagation();\n\n    const target = getDeepPageTarget(ev) || rawTarget;\n    if (!(target instanceof Element)) return;\n\n    const ref = ensureRefForElement(target);\n    const selector = generateSelector(target);\n    let rect;\n    try {\n      rect = target.getBoundingClientRect();\n    } catch {\n      rect = { x: 0, y: 0, width: 0, height: 0, left: 0, top: 0 };\n    }\n\n    const center = {\n      x: Math.round(rect.left + rect.width / 2),\n      y: Math.round(rect.top + rect.height / 2),\n    };\n\n    sendFrameEvent({\n      type: 'element_picker_frame_event',\n      sessionId: STATE.sessionId,\n      event: 'selected',\n      requestId: STATE.activeRequestId,\n      element: {\n        ref,\n        selector,\n        selectorType: 'css',\n        rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },\n        center,\n        text: summarizeText(target),\n        tagName: target.tagName ? String(target.tagName).toLowerCase() : '',\n      },\n    });\n  }\n\n  function onKeyDown(ev) {\n    if (!STATE.active) return;\n    if (ev && ev.key === 'Escape') {\n      if (isEventFromUi(ev)) return;\n      ev.preventDefault();\n      ev.stopPropagation();\n      if (STATE.sessionId) {\n        sendFrameEvent({\n          type: 'element_picker_frame_event',\n          sessionId: STATE.sessionId,\n          event: 'cancel',\n        });\n      }\n    }\n  }\n\n  // ============================================================\n  // Listener Management\n  // ============================================================\n\n  function attachListeners() {\n    if (STATE.listenersAttached) return;\n    window.addEventListener('mousemove', onMouseMove, true);\n    window.addEventListener('click', onClick, true);\n    window.addEventListener('keydown', onKeyDown, true);\n    STATE.listenersAttached = true;\n  }\n\n  function detachListeners() {\n    if (!STATE.listenersAttached) return;\n    window.removeEventListener('mousemove', onMouseMove, true);\n    window.removeEventListener('click', onClick, true);\n    window.removeEventListener('keydown', onKeyDown, true);\n    STATE.listenersAttached = false;\n  }\n\n  // ============================================================\n  // Session Management API\n  // ============================================================\n\n  function startSession(payload) {\n    const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';\n    if (!sessionId) return;\n\n    STATE.active = true;\n    STATE.sessionId = sessionId;\n    STATE.activeRequestId =\n      payload && payload.activeRequestId ? String(payload.activeRequestId) : null;\n    ensureHighlighter();\n    attachListeners();\n  }\n\n  function stopSession(payload) {\n    const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';\n    // Only stop if session matches or no specific session requested\n    if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;\n\n    STATE.active = false;\n    STATE.sessionId = null;\n    STATE.activeRequestId = null;\n    STATE.lastHoverEl = null;\n    detachListeners();\n    clearHighlighter();\n\n    // Remove highlighter element\n    try {\n      const hl = STATE.highlighter;\n      if (hl && hl.remove) hl.remove();\n    } catch {\n      // Best effort\n    }\n    STATE.highlighter = null;\n  }\n\n  function setActiveRequest(payload) {\n    const sessionId = payload && payload.sessionId ? String(payload.sessionId) : '';\n    if (sessionId && STATE.sessionId && sessionId !== STATE.sessionId) return;\n    STATE.activeRequestId =\n      payload && payload.activeRequestId ? String(payload.activeRequestId) : null;\n  }\n\n  // ============================================================\n  // Expose API for Background Script\n  // ============================================================\n\n  window.__mcpElementPicker = {\n    startSession,\n    stopSession,\n    setActiveRequest,\n  };\n\n  // ============================================================\n  // Message Listener (for direct communication)\n  // ============================================================\n\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    try {\n      if (request && request.action === 'chrome_request_element_selection_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n      if (request && request.action === 'elementPickerStart') {\n        startSession(request);\n        sendResponse({ success: true });\n        return false;\n      }\n      if (request && request.action === 'elementPickerStop') {\n        stopSession(request);\n        sendResponse({ success: true });\n        return false;\n      }\n      if (request && request.action === 'elementPickerSetActiveRequest') {\n        setActiveRequest(request);\n        sendResponse({ success: true });\n        return false;\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n      return false;\n    }\n    return false;\n  });\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/fill-helper.js",
    "content": "/* eslint-disable */\n// fill-helper.js\n// This script is injected into the page to handle form filling operations\n\nif (window.__FILL_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__FILL_HELPER_INITIALIZED__ = true;\n  /**\n   * Fill an input element with the specified value\n   * @param {string} selector - CSS selector for the element to fill\n   * @param {string} value - Value to fill into the element\n   * @returns {Promise<Object>} - Result of the fill operation\n   */\n  async function fillElement(selector, value, ref = null) {\n    try {\n      // Find the element\n      let element = null;\n      if (ref && typeof ref === 'string') {\n        try {\n          const map = window.__claudeElementMap;\n          const weak = map && map[ref];\n          element = weak && typeof weak.deref === 'function' ? weak.deref() : null;\n        } catch (e) {\n          // ignore\n        }\n        if (!element || !(element instanceof Element)) {\n          return {\n            error: `Element ref \"${ref}\" not found. Please call chrome_read_page first and ensure the ref is still valid.`,\n          };\n        }\n      } else {\n        element = document.querySelector(selector);\n      }\n      if (!element) {\n        return {\n          error: selector\n            ? `Element with selector \"${selector}\" not found`\n            : `Element for ref not found`,\n        };\n      }\n\n      // Get element information\n      const rect = element.getBoundingClientRect();\n      const elementInfo = {\n        tagName: element.tagName,\n        id: element.id,\n        className: element.className,\n        type: element.type || null,\n        isVisible: isElementVisible(element),\n        rect: {\n          x: rect.x,\n          y: rect.y,\n          width: rect.width,\n          height: rect.height,\n          top: rect.top,\n          right: rect.right,\n          bottom: rect.bottom,\n          left: rect.left,\n        },\n      };\n\n      // Check if element is visible\n      if (!elementInfo.isVisible) {\n        return {\n          error: `Element with selector \"${selector}\" is not visible`,\n          elementInfo,\n        };\n      }\n\n      // Check if element is an input, textarea, or select\n      const validTags = ['INPUT', 'TEXTAREA', 'SELECT'];\n      // Keep a permissive list to allow type-specific branches below to handle behavior\n      const validInputTypes = [\n        'text',\n        'email',\n        'password',\n        'number',\n        'search',\n        'tel',\n        'url',\n        'date',\n        'datetime-local',\n        'month',\n        'time',\n        'week',\n        'color',\n        'checkbox',\n        'radio',\n        'range',\n      ];\n\n      if (!validTags.includes(element.tagName)) {\n        // If the element is a custom element with open shadow root, try to find a fillable inner control\n        try {\n          const anyEl = /** @type {any} */ (element);\n          const sr = anyEl && anyEl.shadowRoot ? anyEl.shadowRoot : null;\n          if (sr) {\n            // Search common fillable targets inside shadow root (breadth-first)\n            const queue = Array.from(sr.children || []);\n            const isFillable = (el) =>\n              !!el &&\n              (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT');\n            while (queue.length) {\n              const cur = queue.shift();\n              if (!cur) continue;\n              if (isFillable(cur)) {\n                element = cur;\n                break;\n              }\n              try {\n                const children = cur.children || [];\n                for (let i = 0; i < children.length; i++) queue.push(children[i]);\n                const innerSr = /** @type {any} */ (cur).shadowRoot;\n                if (innerSr && innerSr.children) {\n                  for (let i = 0; i < innerSr.children.length; i++) queue.push(innerSr.children[i]);\n                }\n              } catch (_) {}\n            }\n            if (!validTags.includes(element.tagName)) {\n              return {\n                error: `Element with selector \"${selector}\" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`,\n                elementInfo,\n              };\n            }\n          } else {\n            return {\n              error: `Element with selector \"${selector}\" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`,\n              elementInfo,\n            };\n          }\n        } catch (_) {\n          return {\n            error: `Element with selector \"${selector}\" is not a fillable element (must be INPUT, TEXTAREA, or SELECT)`,\n            elementInfo,\n          };\n        }\n      }\n\n      // For input elements, check if the type is valid (allow type-specific branches below)\n      if (\n        element.tagName === 'INPUT' &&\n        !validInputTypes.includes(element.type) &&\n        element.type !== null\n      ) {\n        return {\n          error: `Input element with selector \"${selector}\" has type \"${element.type}\" which is not fillable`,\n          elementInfo,\n        };\n      }\n\n      // Scroll element into view\n      element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' });\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      // Focus the element\n      element.focus();\n\n      // Type-specific handling for tricky inputs first\n      if (element.tagName === 'INPUT' && element.type === 'checkbox') {\n        // Accept boolean or string-like boolean\n        let checkedVal;\n        if (typeof value === 'boolean') {\n          checkedVal = value;\n        } else if (typeof value === 'string') {\n          const v = value.trim().toLowerCase();\n          if (['true', '1', 'yes', 'on'].includes(v)) checkedVal = true;\n          else if (['false', '0', 'no', 'off'].includes(v)) checkedVal = false;\n        }\n        if (typeof checkedVal !== 'boolean') {\n          return {\n            error:\n              'Checkbox requires a boolean (true/false) or a boolean-like string (\"true\"/\"false\"/\"on\"/\"off\").',\n            elementInfo,\n          };\n        }\n        const previous = element.checked;\n        element.checked = checkedVal;\n        element.focus();\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n        element.blur();\n        return {\n          success: true,\n          message: `Checkbox set to ${element.checked}`,\n          elementInfo: { ...elementInfo, checked: element.checked, previousChecked: previous },\n        };\n      }\n\n      if (element.tagName === 'INPUT' && element.type === 'radio') {\n        // For radios, the selector/ref should target the specific input to select\n        const previous = element.checked;\n        element.checked = true;\n        element.focus();\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n        element.blur();\n        return {\n          success: true,\n          message: 'Radio selected',\n          elementInfo: {\n            ...elementInfo,\n            checked: element.checked,\n            previousChecked: previous,\n            name: element.name || null,\n          },\n        };\n      }\n\n      if (element.tagName === 'INPUT' && element.type === 'range') {\n        const numericValue = typeof value === 'number' ? value : Number(value);\n        if (Number.isNaN(numericValue)) {\n          return { error: 'Range input requires a numeric value', elementInfo };\n        }\n        const previous = element.value;\n        element.value = String(numericValue);\n        element.focus();\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n        element.blur();\n        return {\n          success: true,\n          message: `Set range to ${element.value} (min: ${element.min}, max: ${element.max})`,\n          elementInfo: { ...elementInfo, value: element.value },\n        };\n      }\n\n      if (element.tagName === 'INPUT' && element.type === 'number') {\n        if (value !== '' && value !== null && value !== undefined && Number.isNaN(Number(value))) {\n          return { error: 'Number input requires a numeric value', elementInfo };\n        }\n        const previous = element.value;\n        element.value = String(value ?? '');\n        element.focus();\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n        element.blur();\n        return {\n          success: true,\n          message: `Set number input to ${element.value} (previous: ${previous})`,\n          elementInfo: { ...elementInfo, value: element.value },\n        };\n      }\n\n      // Fill the element based on its type\n      if (element.tagName === 'SELECT') {\n        // For select elements, find the option with matching value or text\n        let optionFound = false;\n        for (const option of element.options) {\n          if (option.value === value || option.text === value) {\n            element.value = option.value;\n            optionFound = true;\n            break;\n          }\n        }\n\n        if (!optionFound) {\n          return {\n            error: `No option with value or text \"${value}\" found in select element`,\n            elementInfo,\n          };\n        }\n\n        // Trigger change event\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n      } else {\n        // For input and textarea elements\n        // Clear the current value then set new value\n        element.value = '';\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n\n        element.value = String(value);\n\n        element.dispatchEvent(new Event('input', { bubbles: true }));\n        element.dispatchEvent(new Event('change', { bubbles: true }));\n      }\n\n      // Blur the element\n      element.blur();\n\n      return {\n        success: true,\n        message: 'Element filled successfully',\n        elementInfo: {\n          ...elementInfo,\n          value: element.value, // Include the final value in the response\n        },\n      };\n    } catch (error) {\n      return {\n        error: `Error filling element: ${error.message}`,\n      };\n    }\n  }\n\n  /**\n   * Check if an element is visible\n   * @param {Element} element - The element to check\n   * @returns {boolean} - Whether the element is visible\n   */\n  function isElementVisible(element) {\n    if (!element) return false;\n\n    const style = window.getComputedStyle(element);\n    if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {\n      return false;\n    }\n\n    const rect = element.getBoundingClientRect();\n    if (rect.width === 0 || rect.height === 0) {\n      return false;\n    }\n\n    // Check if element is within viewport\n    if (\n      rect.bottom < 0 ||\n      rect.top > window.innerHeight ||\n      rect.right < 0 ||\n      rect.left > window.innerWidth\n    ) {\n      return false;\n    }\n\n    // Check if element is actually visible at its center point\n    const centerX = rect.left + rect.width / 2;\n    const centerY = rect.top + rect.height / 2;\n\n    const elementAtPoint = document.elementFromPoint(centerX, centerY);\n    if (!elementAtPoint) return false;\n\n    return element === elementAtPoint || element.contains(elementAtPoint);\n  }\n\n  // Listen for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    if (request.action === 'fillElement') {\n      fillElement(request.selector, request.value, request.ref)\n        .then(sendResponse)\n        .catch((error) => {\n          sendResponse({\n            error: `Unexpected error: ${error.message}`,\n          });\n        });\n      return true; // Indicates async response\n    } else if (request.action === 'chrome_fill_or_select_ping') {\n      sendResponse({ status: 'pong' });\n      return false;\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/inject-bridge.js",
    "content": "/* eslint-disable */\n\n(() => {\n  // Prevent duplicate injection of the bridge itself.\n  if (window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__) return;\n  window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__ = true;\n  const EVENT_NAME = {\n    RESPONSE: 'chrome-mcp:response',\n    CLEANUP: 'chrome-mcp:cleanup',\n    EXECUTE: 'chrome-mcp:execute',\n  };\n  const pendingRequests = new Map();\n\n  const messageHandler = (request, _sender, sendResponse) => {\n    // --- Lifecycle Command ---\n    if (request.type === EVENT_NAME.CLEANUP) {\n      window.dispatchEvent(new CustomEvent(EVENT_NAME.CLEANUP));\n      // Acknowledge cleanup signal received, but don't hold the connection.\n      sendResponse({ success: true });\n      return true;\n    }\n\n    // --- Execution Command for MAIN world ---\n    if (request.targetWorld === 'MAIN') {\n      const requestId = `req-${Date.now()}-${Math.random()}`;\n      pendingRequests.set(requestId, sendResponse);\n\n      window.dispatchEvent(\n        new CustomEvent(EVENT_NAME.EXECUTE, {\n          detail: {\n            action: request.action,\n            payload: request.payload,\n            requestId: requestId,\n          },\n        }),\n      );\n      return true; // Async response is expected.\n    }\n    // Note: Requests for ISOLATED world are handled by the user's isolatedWorldCode script directly.\n    // This listener won't process them unless it's the only script in ISOLATED world.\n  };\n\n  chrome.runtime.onMessage.addListener(messageHandler);\n\n  // Listen for responses coming back from the MAIN world.\n  const responseHandler = (event) => {\n    const { requestId, data, error } = event.detail;\n    if (pendingRequests.has(requestId)) {\n      const sendResponse = pendingRequests.get(requestId);\n      sendResponse({ data, error });\n      pendingRequests.delete(requestId);\n    }\n  };\n  window.addEventListener(EVENT_NAME.RESPONSE, responseHandler);\n\n  // --- Self Cleanup ---\n  // When the cleanup signal arrives, this bridge must also clean itself up.\n  const cleanupHandler = () => {\n    chrome.runtime.onMessage.removeListener(messageHandler);\n    window.removeEventListener(EVENT_NAME.RESPONSE, responseHandler);\n    window.removeEventListener(EVENT_NAME.CLEANUP, cleanupHandler);\n    delete window.__INJECT_SCRIPT_TOOL_UNIVERSAL_BRIDGE_LOADED__;\n  };\n  window.addEventListener(EVENT_NAME.CLEANUP, cleanupHandler);\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/interactive-elements-helper.js",
    "content": "/* eslint-disable */\n// interactive-elements-helper.js\n// This script is injected into the page to find interactive elements.\n// Final version by Calvin, featuring a multi-layered fallback strategy\n// and comprehensive element support, built on a performant and reliable core.\n\n(function () {\n  // Prevent re-initialization\n  if (window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__) {\n    return;\n  }\n  window.__INTERACTIVE_ELEMENTS_HELPER_INITIALIZED__ = true;\n\n  /**\n   * @typedef {Object} ElementInfo\n   * @property {string} type - The type of the element (e.g., 'button', 'link').\n   * @property {string} selector - A CSS selector to uniquely identify the element.\n   * @property {string} text - The visible text or accessible name of the element.\n   * @property {boolean} isInteractive - Whether the element is currently interactive.\n   * @property {Object} [coordinates] - The coordinates of the element if requested.\n   * @property {boolean} [disabled] - For elements that can be disabled.\n   * @property {string} [href] - For links.\n   * @property {boolean} [checked] - for checkboxes and radio buttons.\n   */\n\n  /**\n   * Configuration for element types and their corresponding selectors.\n   * Now more comprehensive with common ARIA roles.\n   */\n  const ELEMENT_CONFIG = {\n    button: 'button, input[type=\"button\"], input[type=\"submit\"], [role=\"button\"]',\n    link: 'a[href], [role=\"link\"]',\n    input:\n      'input:not([type=\"button\"]):not([type=\"submit\"]):not([type=\"checkbox\"]):not([type=\"radio\"])',\n    checkbox: 'input[type=\"checkbox\"], [role=\"checkbox\"]',\n    radio: 'input[type=\"radio\"], [role=\"radio\"]',\n    textarea: 'textarea, [role=\"textbox\"], [role=\"searchbox\"]',\n    select: 'select, [role=\"combobox\"]',\n    tab: '[role=\"tab\"]',\n    // Generic interactive elements: combines tabindex, common roles, and explicit handlers.\n    // This is the key to finding custom-built interactive components.\n    interactive: `[onclick], [tabindex]:not([tabindex^=\"-\"]), [role=\"menuitem\"], [role=\"slider\"], [role=\"option\"], [role=\"treeitem\"], [role=\"switch\"]`,\n  };\n\n  // A combined selector for ANY interactive element, used in the fallback logic.\n  const ANY_INTERACTIVE_SELECTOR = Object.values(ELEMENT_CONFIG).join(', ');\n\n  // Query helpers that pierce open shadow roots. These are used only in fallback paths or\n  // when a selector is explicitly provided, to keep costs bounded.\n  function* walkAllNodesDeep(root) {\n    const stack = [root];\n    const MAX = 12000; // safety bound\n    let count = 0;\n    while (stack.length) {\n      const node = stack.pop();\n      if (!node) continue;\n      if (++count > MAX) break;\n      yield node;\n      const anyNode = /** @type {any} */ (node);\n      try {\n        const children = node.children ? Array.from(node.children) : [];\n        for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);\n        const sr = anyNode && anyNode.shadowRoot ? anyNode.shadowRoot : null;\n        if (sr && sr.children) {\n          const srChildren = Array.from(sr.children);\n          for (let i = srChildren.length - 1; i >= 0; i--) stack.push(srChildren[i]);\n        }\n      } catch (_) {\n        /* ignore */\n      }\n    }\n  }\n\n  function querySelectorAllDeep(selector, root = document) {\n    const results = [];\n    for (const node of walkAllNodesDeep(root)) {\n      if (!(node instanceof Element)) continue;\n      try {\n        if (node.matches && node.matches(selector)) results.push(node);\n      } catch (_) {\n        /* ignore invalid selectors for given node */\n      }\n    }\n    return results;\n  }\n\n  // --- Core Helper Functions ---\n\n  /**\n   * Checks if an element is genuinely visible on the page.\n   * \"Visible\" means it's not styled with display:none, visibility:hidden, etc.\n   * This check intentionally IGNORES whether the element is within the current viewport.\n   * @param {Element} el The element to check.\n   * @returns {boolean} True if the element is visible.\n   */\n  function isElementVisible(el) {\n    if (!el || !el.isConnected) return false;\n\n    const style = window.getComputedStyle(el);\n    if (\n      style.display === 'none' ||\n      style.visibility === 'hidden' ||\n      parseFloat(style.opacity) === 0\n    ) {\n      return false;\n    }\n\n    const rect = el.getBoundingClientRect();\n    return rect.width > 0 || rect.height > 0 || el.tagName === 'A'; // Allow zero-size anchors as they can still be navigated\n  }\n\n  /**\n   * Checks if an element is considered interactive (not disabled or hidden from accessibility).\n   * @param {Element} el The element to check.\n   * @returns {boolean} True if the element is interactive.\n   */\n  function isElementInteractive(el) {\n    if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') {\n      return false;\n    }\n    if (el.closest('[aria-hidden=\"true\"]')) {\n      return false;\n    }\n    return true;\n  }\n\n  /**\n   * Generates a reasonably stable CSS selector for a given element.\n   * @param {Element} el The element.\n   * @returns {string} A CSS selector.\n   */\n  function generateSelector(el) {\n    if (!(el instanceof Element)) return '';\n\n    if (el.id) {\n      const idSelector = `#${CSS.escape(el.id)}`;\n      if (document.querySelectorAll(idSelector).length === 1) return idSelector;\n    }\n\n    for (const attr of ['data-testid', 'data-cy', 'name']) {\n      const attrValue = el.getAttribute(attr);\n      if (attrValue) {\n        const attrSelector = `[${attr}=\"${CSS.escape(attrValue)}\"]`;\n        if (document.querySelectorAll(attrSelector).length === 1) return attrSelector;\n      }\n    }\n\n    let path = '';\n    let current = el;\n    while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') {\n      let selector = current.tagName.toLowerCase();\n      const parent = current.parentElement;\n      if (parent) {\n        const siblings = Array.from(parent.children).filter(\n          (child) => child.tagName === current.tagName,\n        );\n        if (siblings.length > 1) {\n          const index = siblings.indexOf(current) + 1;\n          selector += `:nth-of-type(${index})`;\n        }\n      }\n      path = path ? `${selector} > ${path}` : selector;\n      current = parent;\n    }\n    return path ? `body > ${path}` : 'body';\n  }\n\n  /**\n   * Finds the accessible name for an element (label, aria-label, etc.).\n   * @param {Element} el The element.\n   * @returns {string} The accessible name.\n   */\n  function getAccessibleName(el) {\n    const labelledby = el.getAttribute('aria-labelledby');\n    if (labelledby) {\n      const labelElement = document.getElementById(labelledby);\n      if (labelElement) return labelElement.textContent?.trim() || '';\n    }\n    const ariaLabel = el.getAttribute('aria-label');\n    if (ariaLabel) return ariaLabel.trim();\n    if (el.id) {\n      const label = document.querySelector(`label[for=\"${el.id}\"]`);\n      if (label) return label.textContent?.trim() || '';\n    }\n    const parentLabel = el.closest('label');\n    if (parentLabel) return parentLabel.textContent?.trim() || '';\n    return (\n      el.getAttribute('placeholder') ||\n      el.getAttribute('value') ||\n      el.textContent?.trim() ||\n      el.getAttribute('title') ||\n      ''\n    );\n  }\n\n  /**\n   * Simple subsequence matching for fuzzy search.\n   * @param {string} text The text to search within.\n   * @param {string} query The query subsequence.\n   * @returns {boolean}\n   */\n  function fuzzyMatch(text, query) {\n    if (!text || !query) return false;\n    const lowerText = text.toLowerCase();\n    const lowerQuery = query.toLowerCase();\n    let textIndex = 0;\n    let queryIndex = 0;\n    while (textIndex < lowerText.length && queryIndex < lowerQuery.length) {\n      if (lowerText[textIndex] === lowerQuery[queryIndex]) {\n        queryIndex++;\n      }\n      textIndex++;\n    }\n    return queryIndex === lowerQuery.length;\n  }\n\n  /**\n   * Creates the standardized info object for an element.\n   * Modified to handle the new 'text' type from the final fallback.\n   */\n  function createElementInfo(el, type, includeCoordinates, isInteractiveOverride = null) {\n    const isActuallyInteractive = isElementInteractive(el);\n    const info = {\n      type,\n      selector: generateSelector(el),\n      text: getAccessibleName(el) || el.textContent?.trim(),\n      isInteractive: isInteractiveOverride !== null ? isInteractiveOverride : isActuallyInteractive,\n      disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',\n    };\n    if (includeCoordinates) {\n      const rect = el.getBoundingClientRect();\n      info.coordinates = {\n        x: rect.left + rect.width / 2,\n        y: rect.top + rect.height / 2,\n        rect: {\n          x: rect.x,\n          y: rect.y,\n          width: rect.width,\n          height: rect.height,\n          top: rect.top,\n          right: rect.right,\n          bottom: rect.bottom,\n          left: rect.left,\n        },\n      };\n    }\n    return info;\n  }\n\n  /**\n   * [CORE UTILITY] Finds interactive elements based on a set of types.\n   * This is our high-performance Layer 1 search function.\n   */\n  function findInteractiveElements(options = {}) {\n    const { textQuery, includeCoordinates = true, types = Object.keys(ELEMENT_CONFIG) } = options;\n\n    const selectorsToFind = types\n      .map((type) => ELEMENT_CONFIG[type])\n      .filter(Boolean)\n      .join(', ');\n    if (!selectorsToFind) return [];\n\n    const targetElements = querySelectorAllDeep(selectorsToFind);\n    const uniqueElements = new Set(targetElements);\n    const results = [];\n\n    for (const el of uniqueElements) {\n      if (!isElementVisible(el) || !isElementInteractive(el)) continue;\n\n      const accessibleName = getAccessibleName(el);\n      if (textQuery && !fuzzyMatch(accessibleName, textQuery)) continue;\n\n      let elementType = 'unknown';\n      for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) {\n        if (el.matches(typeSelector)) {\n          elementType = type;\n          break;\n        }\n      }\n      results.push(createElementInfo(el, elementType, includeCoordinates));\n    }\n    return results;\n  }\n\n  /**\n   * [ORCHESTRATOR] The main entry point that implements the 3-layer fallback logic.\n   * @param {object} options - The main search options.\n   * @returns {ElementInfo[]}\n   */\n  function findElementsByTextWithFallback(options = {}) {\n    const { textQuery, includeCoordinates = true } = options;\n\n    if (!textQuery) {\n      return findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) });\n    }\n\n    // --- Layer 1: High-reliability search for interactive elements matching text ---\n    let results = findInteractiveElements({ ...options, types: Object.keys(ELEMENT_CONFIG) });\n    if (results.length > 0) {\n      return results;\n    }\n\n    // --- Layer 2: Find text, then find its interactive ancestor ---\n    const lowerCaseText = textQuery.toLowerCase();\n    const xPath = `//text()[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${lowerCaseText}')]`;\n    const textNodes = document.evaluate(\n      xPath,\n      document,\n      null,\n      XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,\n      null,\n    );\n\n    const interactiveElements = new Set();\n    if (textNodes.snapshotLength > 0) {\n      for (let i = 0; i < textNodes.snapshotLength; i++) {\n        const parentElement = textNodes.snapshotItem(i).parentElement;\n        if (parentElement) {\n          const interactiveAncestor = parentElement.closest(ANY_INTERACTIVE_SELECTOR);\n          if (\n            interactiveAncestor &&\n            isElementVisible(interactiveAncestor) &&\n            isElementInteractive(interactiveAncestor)\n          ) {\n            interactiveElements.add(interactiveAncestor);\n          }\n        }\n      }\n\n      if (interactiveElements.size > 0) {\n        return Array.from(interactiveElements).map((el) => {\n          let elementType = 'interactive';\n          for (const [type, typeSelector] of Object.entries(ELEMENT_CONFIG)) {\n            if (el.matches(typeSelector)) {\n              elementType = type;\n              break;\n            }\n          }\n          return createElementInfo(el, elementType, includeCoordinates);\n        });\n      }\n    }\n\n    // --- Layer 3: Final fallback, return any element containing the text ---\n    const leafElements = new Set();\n    for (let i = 0; i < textNodes.snapshotLength; i++) {\n      const parentElement = textNodes.snapshotItem(i).parentElement;\n      if (parentElement && isElementVisible(parentElement)) {\n        leafElements.add(parentElement);\n      }\n    }\n\n    const finalElements = Array.from(leafElements).filter((el) => {\n      return ![...leafElements].some((otherEl) => el !== otherEl && el.contains(otherEl));\n    });\n\n    return finalElements.map((el) => createElementInfo(el, 'text', includeCoordinates, true));\n  }\n\n  // --- Chrome Message Listener ---\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    if (request.action === 'getInteractiveElements') {\n      try {\n        let elements;\n        if (request.selector) {\n          // If a selector is provided, bypass the text-based logic and use a direct query.\n          const foundEls = querySelectorAllDeep(request.selector);\n          elements = foundEls.map((el) =>\n            createElementInfo(\n              el,\n              'selected',\n              request.includeCoordinates !== false,\n              isElementInteractive(el),\n            ),\n          );\n        } else {\n          // Otherwise, use our powerful multi-layered text search\n          elements = findElementsByTextWithFallback(request);\n        }\n        sendResponse({ success: true, elements });\n      } catch (error) {\n        console.error('Error in getInteractiveElements:', error);\n        sendResponse({ success: false, error: error.message });\n      }\n      return true; // Async response\n    } else if (request.action === 'chrome_get_interactive_elements_ping') {\n      sendResponse({ status: 'pong' });\n      return false;\n    }\n  });\n\n  console.log('Interactive elements helper script loaded');\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/keyboard-helper.js",
    "content": "/* eslint-disable */\n// keyboard-helper.js\n// This script is injected into the page to handle keyboard event simulation\n\nif (window.__KEYBOARD_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__KEYBOARD_HELPER_INITIALIZED__ = true;\n\n  // A map for special keys to their KeyboardEvent properties\n  // Key names should be lowercase for matching\n  const SPECIAL_KEY_MAP = {\n    enter: { key: 'Enter', code: 'Enter', keyCode: 13 },\n    tab: { key: 'Tab', code: 'Tab', keyCode: 9 },\n    esc: { key: 'Escape', code: 'Escape', keyCode: 27 },\n    escape: { key: 'Escape', code: 'Escape', keyCode: 27 },\n    space: { key: ' ', code: 'Space', keyCode: 32 },\n    backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },\n    delete: { key: 'Delete', code: 'Delete', keyCode: 46 },\n    del: { key: 'Delete', code: 'Delete', keyCode: 46 },\n    up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },\n    arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },\n    down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },\n    arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },\n    left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },\n    arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },\n    right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },\n    arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },\n    home: { key: 'Home', code: 'Home', keyCode: 36 },\n    end: { key: 'End', code: 'End', keyCode: 35 },\n    pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },\n    pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },\n    insert: { key: 'Insert', code: 'Insert', keyCode: 45 },\n    // Function keys\n    ...Object.fromEntries(\n      Array.from({ length: 12 }, (_, i) => [\n        `f${i + 1}`,\n        { key: `F${i + 1}`, code: `F${i + 1}`, keyCode: 112 + i },\n      ]),\n    ),\n  };\n\n  const MODIFIER_KEYS = {\n    ctrl: 'ctrlKey',\n    control: 'ctrlKey',\n    alt: 'altKey',\n    shift: 'shiftKey',\n    meta: 'metaKey',\n    command: 'metaKey',\n    cmd: 'metaKey',\n  };\n\n  /**\n   * Parses a key string (e.g., \"Ctrl+Shift+A\", \"Enter\") into a main key and modifiers.\n   * @param {string} keyString - String representation of a single key press (can include modifiers).\n   * @returns { {key: string, code: string, keyCode: number, charCode?: number, modifiers: {ctrlKey:boolean, altKey:boolean, shiftKey:boolean, metaKey:boolean}} | null }\n   *          Returns null if the keyString is invalid or represents only modifiers.\n   */\n  function parseSingleKeyCombination(keyString) {\n    const parts = keyString.split('+').map((part) => part.trim().toLowerCase());\n    const modifiers = {\n      ctrlKey: false,\n      altKey: false,\n      shiftKey: false,\n      metaKey: false,\n    };\n    let mainKeyPart = null;\n\n    for (const part of parts) {\n      if (MODIFIER_KEYS[part]) {\n        modifiers[MODIFIER_KEYS[part]] = true;\n      } else if (mainKeyPart === null) {\n        // First non-modifier is the main key\n        mainKeyPart = part;\n      } else {\n        // Invalid format: multiple main keys in a single combination (e.g., \"Ctrl+A+B\")\n        console.error(`Invalid key combination string: ${keyString}. Multiple main keys found.`);\n        return null;\n      }\n    }\n\n    if (!mainKeyPart) {\n      // This case could happen if the keyString is something like \"Ctrl+\" or just \"Ctrl\"\n      // If the intent was to press JUST 'Control', the input should be 'Control' not 'Control+'\n      // Let's check if mainKeyPart is actually a modifier name used as a main key\n      if (Object.keys(MODIFIER_KEYS).includes(parts[parts.length - 1]) && parts.length === 1) {\n        mainKeyPart = parts[parts.length - 1]; // e.g. user wants to press \"Control\" key itself\n        // For \"Control\" key itself, key: \"Control\", code: \"ControlLeft\" (or Right)\n        if (mainKeyPart === 'ctrl' || mainKeyPart === 'control')\n          return { key: 'Control', code: 'ControlLeft', keyCode: 17, modifiers };\n        if (mainKeyPart === 'alt') return { key: 'Alt', code: 'AltLeft', keyCode: 18, modifiers };\n        if (mainKeyPart === 'shift')\n          return { key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifiers };\n        if (mainKeyPart === 'meta' || mainKeyPart === 'command' || mainKeyPart === 'cmd')\n          return { key: 'Meta', code: 'MetaLeft', keyCode: 91, modifiers };\n      } else {\n        console.error(`Invalid key combination string: ${keyString}. No main key specified.`);\n        return null;\n      }\n    }\n\n    const specialKey = SPECIAL_KEY_MAP[mainKeyPart];\n    if (specialKey) {\n      return { ...specialKey, modifiers };\n    }\n\n    // For single characters or other unmapped keys\n    if (mainKeyPart.length === 1) {\n      const charCode = mainKeyPart.charCodeAt(0);\n      // If Shift is active and it's a letter, use the uppercase version for 'key'\n      // This mimics more closely how keyboards behave.\n      let keyChar = mainKeyPart;\n      if (modifiers.shiftKey && mainKeyPart.match(/^[a-z]$/i)) {\n        keyChar = mainKeyPart.toUpperCase();\n      }\n\n      return {\n        key: keyChar,\n        code: `Key${mainKeyPart.toUpperCase()}`, // 'a' -> KeyA, 'A' -> KeyA\n        keyCode: charCode,\n        charCode: charCode, // charCode is legacy, but some old systems might use it\n        modifiers,\n      };\n    }\n\n    console.error(`Unknown key: ${mainKeyPart} in string \"${keyString}\"`);\n    return null; // Or handle as an error\n  }\n\n  /**\n   * Simulates a single key press (keydown, (keypress), keyup) for a parsed key.\n   * @param { {key: string, code: string, keyCode: number, charCode?: number, modifiers: object} } parsedKeyInfo\n   * @param {Element} element - Target element.\n   * @returns {{success: boolean, error?: string}}\n   */\n  function dispatchKeyEvents(parsedKeyInfo, element) {\n    if (!parsedKeyInfo) return { success: false, error: 'Invalid key info provided for dispatch.' };\n\n    const { key, code, keyCode, charCode, modifiers } = parsedKeyInfo;\n\n    const eventOptions = {\n      key: key,\n      code: code,\n      bubbles: true,\n      cancelable: true,\n      composed: true, // Important for shadow DOM\n      view: window,\n      ...modifiers, // ctrlKey, altKey, shiftKey, metaKey\n      // keyCode/which are deprecated but often set for compatibility\n      keyCode: keyCode || (key.length === 1 ? key.charCodeAt(0) : 0),\n      which: keyCode || (key.length === 1 ? key.charCodeAt(0) : 0),\n    };\n\n    try {\n      const kdRes = element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));\n\n      // keypress is deprecated, but simulate if it's a character key or Enter\n      // Only dispatch if keydown was not cancelled and it's a character producing key\n      if (kdRes && (key.length === 1 || key === 'Enter' || key === ' ')) {\n        const keypressOptions = { ...eventOptions };\n        if (charCode) keypressOptions.charCode = charCode;\n        element.dispatchEvent(new KeyboardEvent('keypress', keypressOptions));\n      }\n\n      element.dispatchEvent(new KeyboardEvent('keyup', eventOptions));\n      return { success: true };\n    } catch (error) {\n      console.error(`Error dispatching key events for \"${key}\":`, error);\n      return {\n        success: false,\n        error: `Error dispatching key events for \"${key}\": ${error.message}`,\n      };\n    }\n  }\n\n  /**\n   * Simulate keyboard events on an element or document\n   * @param {string} keysSequenceString - String representation of key(s) (e.g., \"Enter\", \"Ctrl+C, A, B\")\n   * @param {Element} targetElement - Element to dispatch events on (optional)\n   * @param {number} delay - Delay between key sequences in milliseconds (optional)\n   * @returns {Promise<Object>} - Result of the keyboard operation\n   */\n  async function simulateKeyboard(keysSequenceString, targetElement = null, delay = 0) {\n    try {\n      const element = targetElement || document.activeElement || document.body;\n\n      if (element !== document.activeElement && typeof element.focus === 'function') {\n        element.focus();\n        await new Promise((resolve) => setTimeout(resolve, 50)); // Small delay for focus\n      }\n\n      const keyCombinations = keysSequenceString\n        .split(',')\n        .map((k) => k.trim())\n        .filter((k) => k.length > 0);\n      const operationResults = [];\n\n      for (let i = 0; i < keyCombinations.length; i++) {\n        const comboString = keyCombinations[i];\n        const parsedKeyInfo = parseSingleKeyCombination(comboString);\n\n        if (!parsedKeyInfo) {\n          operationResults.push({\n            keyCombination: comboString,\n            success: false,\n            error: `Invalid key string or combination: ${comboString}`,\n          });\n          continue; // Skip to next combination in sequence\n        }\n\n        const dispatchResult = dispatchKeyEvents(parsedKeyInfo, element);\n        operationResults.push({\n          keyCombination: comboString,\n          ...dispatchResult,\n        });\n\n        if (dispatchResult.error) {\n          // Optionally, decide if sequence should stop on first error\n          // For now, we continue but log the error in results\n          console.warn(\n            `Failed to simulate key combination \"${comboString}\": ${dispatchResult.error}`,\n          );\n        }\n\n        if (delay > 0 && i < keyCombinations.length - 1) {\n          await new Promise((resolve) => setTimeout(resolve, delay));\n        }\n      }\n\n      // Check if all individual operations were successful\n      const overallSuccess = operationResults.every((r) => r.success);\n\n      return {\n        success: overallSuccess,\n        message: overallSuccess\n          ? `Keyboard events simulated successfully: ${keysSequenceString}`\n          : `Some keyboard events failed for: ${keysSequenceString}`,\n        results: operationResults, // Detailed results for each key combination\n        targetElement: {\n          tagName: element.tagName,\n          id: element.id,\n          className: element.className,\n          type: element.type, // if applicable e.g. for input\n        },\n      };\n    } catch (error) {\n      console.error('Error in simulateKeyboard:', error);\n      return {\n        success: false,\n        error: `Error simulating keyboard events: ${error.message}`,\n        results: [],\n      };\n    }\n  }\n\n  // Listener for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    if (request.action === 'simulateKeyboard') {\n      let targetEl = null;\n      if (request.selector) {\n        targetEl = document.querySelector(request.selector);\n        if (!targetEl) {\n          sendResponse({\n            success: false,\n            error: `Element with selector \"${request.selector}\" not found`,\n            results: [],\n          });\n          return true; // Keep channel open for async response\n        }\n      }\n\n      simulateKeyboard(request.keys, targetEl, request.delay)\n        .then(sendResponse)\n        .catch((error) => {\n          // This catch is for unexpected errors in simulateKeyboard promise chain itself\n          console.error('Unexpected error in simulateKeyboard promise chain:', error);\n          sendResponse({\n            success: false,\n            error: `Unexpected error during keyboard simulation: ${error.message}`,\n            results: [],\n          });\n        });\n      return true; // Indicates async response is expected\n    } else if (request.action === 'chrome_keyboard_ping') {\n      sendResponse({ status: 'pong', initialized: true }); // Respond that it's initialized\n      return false; // Synchronous response\n    }\n    // Not our message, or no async response needed\n    return false;\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/network-helper.js",
    "content": "/* eslint-disable */\n/**\n * Network Capture Helper\n *\n * This script helps replay network requests with the original cookies and headers.\n */\n\n// Prevent duplicate initialization\nif (window.__NETWORK_CAPTURE_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__NETWORK_CAPTURE_HELPER_INITIALIZED__ = true;\n\n  /**\n   * Replay a network request\n   * @param {string} url - The URL to send the request to\n   * @param {string} method - The HTTP method to use\n   * @param {Object} headers - The headers to include in the request\n   * @param {any} body - The body of the request\n   * @param {number} timeout - Timeout in milliseconds (default: 30000)\n   * @returns {Promise<Object>} - The response data\n   */\n  async function replayNetworkRequest(\n    url,\n    method,\n    headers,\n    body,\n    timeout = 30000,\n    formDataDescriptor = null,\n  ) {\n    try {\n      // Create fetch options\n      const options = {\n        method: method,\n        headers: headers || {},\n        credentials: 'include', // Include cookies\n        mode: 'cors',\n        cache: 'no-cache',\n      };\n\n      // Helper: convert base64 to Blob\n      const base64ToBlob = (base64, contentType = 'application/octet-stream') => {\n        try {\n          const decodedString = atob(base64);\n          const len = decodedString.length;\n          const bytes = new Uint8Array(len);\n          for (let i = 0; i < len; i++) bytes[i] = decodedString.charCodeAt(i);\n          return new Blob([bytes], { type: contentType });\n        } catch (e) {\n          return new Blob([]);\n        }\n      };\n\n      // Helper: request native to read filePath into base64\n      const readFileBase64 = (path) =>\n        new Promise((resolve) => {\n          const requestId = `net-helper-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n          const timeoutId = setTimeout(() => {\n            cleanup();\n            resolve(null);\n          }, 30000);\n          function onMessage(msg) {\n            if (\n              msg &&\n              msg.type === 'file_operation_response' &&\n              msg.responseToRequestId === requestId\n            ) {\n              cleanup();\n              const p = msg.payload || {};\n              if (p.success && p.base64Data)\n                resolve({ base64: p.base64Data, fileName: p.fileName });\n              else resolve(null);\n            }\n          }\n          function cleanup() {\n            clearTimeout(timeoutId);\n            chrome.runtime.onMessage.removeListener(onMessage);\n          }\n          chrome.runtime.onMessage.addListener(onMessage);\n          chrome.runtime\n            .sendMessage({\n              type: 'forward_to_native',\n              message: {\n                type: 'file_operation',\n                requestId,\n                payload: { action: 'readBase64File', filePath: path },\n              },\n            })\n            .catch(() => {\n              cleanup();\n              resolve(null);\n            });\n        });\n\n      // Build multipart/form-data if descriptor is provided\n      if (method !== 'GET' && method !== 'HEAD' && formDataDescriptor) {\n        const fd = new FormData();\n        try {\n          if (Array.isArray(formDataDescriptor)) {\n            for (const item of formDataDescriptor) {\n              if (!Array.isArray(item) || item.length < 2) continue;\n              const name = String(item[0] || 'file');\n              const spec = String(item[1] || '');\n              const filenameHint = item[2] ? String(item[2]) : undefined;\n              if (/^(https?:\\/\\/|url:)/i.test(spec)) {\n                const url = spec.replace(/^url:/i, '');\n                const resp = await fetch(url);\n                const blob = await resp.blob();\n                const fn =\n                  filenameHint || url.split('?')[0].split('#')[0].split('/').pop() || 'file';\n                fd.append(name, blob, fn);\n              } else if (/^base64:/i.test(spec)) {\n                const b64 = spec.replace(/^base64:/i, '');\n                const blob = base64ToBlob(b64);\n                fd.append(name, blob, filenameHint || 'file');\n              } else if (/^file:/i.test(spec)) {\n                const p = spec.replace(/^file:/i, '');\n                const res = await readFileBase64(p);\n                if (res && res.base64) {\n                  const blob = base64ToBlob(res.base64);\n                  fd.append(name, blob, filenameHint || res.fileName || 'file');\n                }\n              } else {\n                // treat as string field\n                fd.append(name, spec);\n              }\n            }\n          } else if (typeof formDataDescriptor === 'object') {\n            const fds = formDataDescriptor;\n            const fields = fds.fields || {};\n            const files = Array.isArray(fds.files) ? fds.files : [];\n            for (const [k, v] of Object.entries(fields)) fd.append(String(k), String(v));\n            for (const file of files) {\n              const name = String(file.name || 'file');\n              if (file.fileUrl) {\n                const resp = await fetch(String(file.fileUrl));\n                const blob = await resp.blob();\n                const fn =\n                  file.filename ||\n                  String(file.fileUrl).split('?')[0].split('#')[0].split('/').pop() ||\n                  'file';\n                fd.append(name, blob, fn);\n              } else if (file.base64Data) {\n                const blob = base64ToBlob(\n                  String(file.base64Data),\n                  String(file.contentType || 'application/octet-stream'),\n                );\n                fd.append(name, blob, file.filename || 'file');\n              } else if (file.filePath) {\n                const res = await readFileBase64(String(file.filePath));\n                if (res && res.base64) {\n                  const blob = base64ToBlob(\n                    res.base64,\n                    String(file.contentType || 'application/octet-stream'),\n                  );\n                  fd.append(name, blob, file.filename || res.fileName || 'file');\n                }\n              }\n            }\n          }\n        } catch (e) {\n          console.warn('Failed to construct FormData:', e);\n        }\n        // Let browser set the correct multipart boundary\n        try {\n          if (options.headers) {\n            delete options.headers['content-type'];\n            delete options.headers['Content-Type'];\n          }\n        } catch {}\n        options.body = fd;\n      } else if (method !== 'GET' && method !== 'HEAD' && body !== undefined) {\n        // Fallback to raw body\n        options.body = body;\n      }\n\n      // 创建一个带超时的 fetch\n      const fetchWithTimeout = async (url, options, timeout) => {\n        const controller = new AbortController();\n        const signal = controller.signal;\n\n        // 设置超时\n        const timeoutId = setTimeout(() => controller.abort(), timeout);\n\n        try {\n          const response = await fetch(url, { ...options, signal });\n          clearTimeout(timeoutId);\n          return response;\n        } catch (error) {\n          clearTimeout(timeoutId);\n          throw error;\n        }\n      };\n\n      // 发送带超时的请求\n      const response = await fetchWithTimeout(url, options, timeout);\n\n      // Process response\n      const responseData = {\n        status: response.status,\n        statusText: response.statusText,\n        headers: {},\n      };\n\n      // Get response headers\n      response.headers.forEach((value, key) => {\n        responseData.headers[key] = value;\n      });\n\n      // Try to get response body based on content type\n      const contentType = response.headers.get('content-type') || '';\n\n      try {\n        if (contentType.includes('application/json')) {\n          responseData.body = await response.json();\n        } else if (\n          contentType.includes('text/') ||\n          contentType.includes('application/xml') ||\n          contentType.includes('application/javascript')\n        ) {\n          responseData.body = await response.text();\n        } else {\n          // For binary data, just indicate it was received but not parsed\n          responseData.body = '[Binary data not displayed]';\n        }\n      } catch (error) {\n        responseData.body = `[Error parsing response body: ${error.message}]`;\n      }\n\n      return {\n        success: true,\n        response: responseData,\n      };\n    } catch (error) {\n      console.error('Error replaying request:', error);\n      return {\n        success: false,\n        error: `Error replaying request: ${error.message}`,\n      };\n    }\n  }\n\n  // Listen for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    // Respond to ping message\n    if (request.action === 'chrome_network_request_ping') {\n      sendResponse({ status: 'pong' });\n      return false; // Synchronous response\n    } else if (request.action === 'sendPureNetworkRequest') {\n      replayNetworkRequest(\n        request.url,\n        request.method,\n        request.headers,\n        request.body,\n        request.timeout,\n        request.formData,\n      )\n        .then(sendResponse)\n        .catch((error) => {\n          sendResponse({\n            success: false,\n            error: `Unexpected error: ${error.message}`,\n          });\n        });\n      return true; // Indicates async response\n    }\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/props-agent.js",
    "content": "/* eslint-disable */\n// @ts-nocheck\n/**\n * Props Agent - MAIN World Script\n *\n * Runtime hacking agent for React/Vue Props editing.\n * Communicates with ISOLATED world via CustomEvent.\n *\n * Architecture:\n * - Transport: CustomEvent-based request/response\n * - Locator: Simplified ElementLocator resolution\n * - ReactAdapter: DevTools Hook detection/injection + overrideProps\n * - VueAdapter: __vueParentComponent + $forceUpdate\n * - Serializer: Safe Props serialization with type preservation\n * - Handlers: Request operation dispatch\n *\n * @module props-agent\n */\n(() => {\n  'use strict';\n\n  // =============================================================================\n  // Constants & Guards\n  // =============================================================================\n\n  const GLOBAL_KEY = '__MCP_WEB_EDITOR_PROPS_AGENT__';\n  if (window[GLOBAL_KEY]) return;\n\n  const PROTOCOL_VERSION = 1;\n  const LOG_PREFIX = '[PropsAgent]';\n\n  const EVENT_NAME = Object.freeze({\n    REQUEST: 'web-editor-props:request',\n    RESPONSE: 'web-editor-props:response',\n    CLEANUP: 'web-editor-props:cleanup',\n  });\n\n  const REACT_HOOK_NAME = '__REACT_DEVTOOLS_GLOBAL_HOOK__';\n\n  /** @type {'READY' | 'HOOK_PRESENT_NO_RENDERERS' | 'RENDERERS_NO_EDITING' | 'HOOK_MISSING'} */\n  const HOOK_STATUS = Object.freeze({\n    READY: 'READY',\n    HOOK_PRESENT_NO_RENDERERS: 'HOOK_PRESENT_NO_RENDERERS',\n    RENDERERS_NO_EDITING: 'RENDERERS_NO_EDITING',\n    HOOK_MISSING: 'HOOK_MISSING',\n  });\n\n  const SERIALIZE_LIMITS = Object.freeze({\n    maxDepth: 4,\n    maxEntries: 100,\n    maxArrayLength: 50,\n    maxStringLength: 1500,\n  });\n\n  // =============================================================================\n  // Utilities\n  // =============================================================================\n\n  function isObject(value) {\n    return value !== null && typeof value === 'object';\n  }\n\n  function safeString(value) {\n    try {\n      if (typeof value === 'string') return value;\n      if (value === null || value === undefined) return '';\n      return String(value);\n    } catch {\n      return '';\n    }\n  }\n\n  function logWarn(...args) {\n    try {\n      console.warn(LOG_PREFIX, ...args);\n    } catch {\n      // Silently ignore\n    }\n  }\n\n  // =============================================================================\n  // Transport Layer\n  // =============================================================================\n\n  const Transport = {\n    dispatchResponse(detail) {\n      try {\n        window.dispatchEvent(new CustomEvent(EVENT_NAME.RESPONSE, { detail }));\n      } catch (err) {\n        logWarn('Failed to dispatch response:', err);\n      }\n    },\n\n    createResponse(requestId, success, data, error) {\n      const response = {\n        v: PROTOCOL_VERSION,\n        requestId,\n        success: Boolean(success),\n      };\n      if (data !== undefined) response.data = data;\n      if (error !== undefined) response.error = safeString(error);\n      return response;\n    },\n\n    normalizeRequest(detail) {\n      if (!isObject(detail)) return null;\n      if (detail.v !== PROTOCOL_VERSION) return null;\n\n      const requestId = typeof detail.requestId === 'string' ? detail.requestId : '';\n      const op = typeof detail.op === 'string' ? detail.op : '';\n      if (!requestId || !op) return null;\n\n      return {\n        v: PROTOCOL_VERSION,\n        requestId,\n        op,\n        locator: detail.locator,\n        payload: detail.payload,\n      };\n    },\n  };\n\n  // =============================================================================\n  // Locator - Element Resolution\n  // =============================================================================\n\n  const Locator = {\n    safeQuerySelector(root, selector) {\n      try {\n        if (!root || typeof selector !== 'string' || !selector.trim()) return null;\n        return root.querySelector(selector);\n      } catch {\n        return null;\n      }\n    },\n\n    safeQuerySelectorAll(root, selector) {\n      try {\n        if (!root || typeof selector !== 'string' || !selector.trim()) return [];\n        return Array.from(root.querySelectorAll(selector));\n      } catch {\n        return [];\n      }\n    },\n\n    isSelectorUnique(root, selector) {\n      return this.safeQuerySelectorAll(root, selector).length === 1;\n    },\n\n    computeFingerprint(element) {\n      try {\n        const parts = [];\n        const tag = element?.tagName ? String(element.tagName).toLowerCase() : 'unknown';\n        parts.push(tag);\n        const id = element?.id ? String(element.id).trim() : '';\n        if (id) parts.push(`id=${id}`);\n        return parts.join('|');\n      } catch {\n        return '';\n      }\n    },\n\n    verifyFingerprint(element, fingerprint) {\n      try {\n        const current = this.computeFingerprint(element);\n        const storedParts = safeString(fingerprint).split('|');\n        const currentParts = current.split('|');\n\n        // Tag must match\n        if (storedParts[0] !== currentParts[0]) return false;\n\n        // If stored has id, current must have same id\n        const storedId = storedParts.find((p) => p.startsWith('id='));\n        const currentId = currentParts.find((p) => p.startsWith('id='));\n        if (storedId && storedId !== currentId) return false;\n\n        return true;\n      } catch {\n        return false;\n      }\n    },\n\n    normalizeStringArray(value) {\n      if (!Array.isArray(value)) return [];\n      return value.map((v) => safeString(v).trim()).filter(Boolean);\n    },\n\n    /**\n     * Resolve ElementLocator to DOM element\n     * Simplified version for MAIN world (no iframe support yet)\n     */\n    locate(locator, rootDocument = document) {\n      try {\n        if (!isObject(locator)) return null;\n\n        let queryRoot = rootDocument;\n\n        // Traverse Shadow DOM host chain\n        const shadowHostChain = this.normalizeStringArray(locator.shadowHostChain);\n        for (const hostSelector of shadowHostChain) {\n          if (!this.isSelectorUnique(queryRoot, hostSelector)) return null;\n          const host = this.safeQuerySelector(queryRoot, hostSelector);\n          if (!host) return null;\n          const shadowRoot = host.shadowRoot;\n          if (!shadowRoot) return null;\n          queryRoot = shadowRoot;\n        }\n\n        // Try each selector candidate\n        const selectors = this.normalizeStringArray(locator.selectors);\n        for (const selector of selectors) {\n          if (!this.isSelectorUnique(queryRoot, selector)) continue;\n          const element = this.safeQuerySelector(queryRoot, selector);\n          if (!element) continue;\n\n          // Verify fingerprint if provided\n          const fp = safeString(locator.fingerprint);\n          if (fp && !this.verifyFingerprint(element, fp)) continue;\n\n          return element;\n        }\n      } catch {\n        // Best-effort\n      }\n      return null;\n    },\n  };\n\n  // =============================================================================\n  // React Adapter\n  // =============================================================================\n\n  const ReactAdapter = {\n    /** Store original values for reset (fiber -> { renderer, originals: Map }) */\n    overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null,\n\n    /** Flag to avoid repeated hook installation attempts */\n    hookInstallAttempted: false,\n\n    getHook() {\n      try {\n        return window[REACT_HOOK_NAME] || null;\n      } catch {\n        return null;\n      }\n    },\n\n    /**\n     * Install minimal DevTools hook if missing.\n     * Note: This only helps if React hasn't initialized yet.\n     * Only attempts once per session to avoid repeated pollution.\n     */\n    installMinimalHook() {\n      // Only attempt once per session\n      if (this.hookInstallAttempted) {\n        return { installed: false, hook: this.getHook(), skipped: true };\n      }\n      this.hookInstallAttempted = true;\n      try {\n        const existing = window[REACT_HOOK_NAME];\n        if (existing && typeof existing.inject === 'function') {\n          return { installed: false, hook: existing };\n        }\n\n        const listeners = Object.create(null);\n\n        const hook = {\n          renderers: new Map(),\n          supportsFiber: true,\n\n          inject(renderer) {\n            try {\n              const id = this.renderers.size + 1;\n              this.renderers.set(id, renderer);\n              this.emit('renderer', { id, renderer });\n              return id;\n            } catch {\n              return 0;\n            }\n          },\n\n          // Required lifecycle callbacks (no-ops)\n          onCommitFiberRoot() {},\n          onCommitFiberUnmount() {},\n          onPostCommitFiberRoot() {},\n          setStrictMode() {},\n          checkDCE() {},\n\n          // Event emitter\n          on(event, fn) {\n            if (typeof event !== 'string' || typeof fn !== 'function') return;\n            if (!listeners[event]) listeners[event] = new Set();\n            listeners[event].add(fn);\n          },\n\n          off(event, fn) {\n            if (typeof event !== 'string' || typeof fn !== 'function') return;\n            listeners[event]?.delete(fn);\n          },\n\n          emit(event, data) {\n            const set = listeners[event];\n            if (!set) return;\n            for (const fn of Array.from(set)) {\n              try {\n                fn(data);\n              } catch {\n                // Listener errors must not break the hook\n              }\n            }\n          },\n\n          sub(event, fn) {\n            this.on(event, fn);\n            return () => this.off(event, fn);\n          },\n        };\n\n        window[REACT_HOOK_NAME] = hook;\n        return { installed: true, hook };\n      } catch (err) {\n        return { installed: false, hook: null, error: err };\n      }\n    },\n\n    /**\n     * Normalize hook.renderers to array format\n     */\n    normalizeRenderers(hook) {\n      const result = [];\n      if (!hook) return result;\n\n      try {\n        const renderers = hook.renderers;\n        if (renderers instanceof Map) {\n          for (const [id, renderer] of renderers.entries()) {\n            result.push({ id, renderer });\n          }\n        } else if (renderers && typeof renderers === 'object') {\n          for (const [id, renderer] of Object.entries(renderers)) {\n            result.push({ id, renderer });\n          }\n        }\n      } catch {\n        // Best-effort\n      }\n      return result;\n    },\n\n    /**\n     * Detect Hook status (4 states)\n     */\n    detectStatus() {\n      const hook = this.getHook();\n\n      if (!hook || typeof hook.inject !== 'function') {\n        return {\n          hookStatus: HOOK_STATUS.HOOK_MISSING,\n          hook: null,\n          renderers: [],\n          editableRenderers: [],\n        };\n      }\n\n      const renderers = this.normalizeRenderers(hook);\n      if (!renderers.length) {\n        return {\n          hookStatus: HOOK_STATUS.HOOK_PRESENT_NO_RENDERERS,\n          hook,\n          renderers,\n          editableRenderers: [],\n        };\n      }\n\n      const editableRenderers = renderers.filter(\n        (r) => r?.renderer && typeof r.renderer.overrideProps === 'function',\n      );\n\n      if (editableRenderers.length) {\n        return {\n          hookStatus: HOOK_STATUS.READY,\n          hook,\n          renderers,\n          editableRenderers,\n        };\n      }\n\n      return {\n        hookStatus: HOOK_STATUS.RENDERERS_NO_EDITING,\n        hook,\n        renderers,\n        editableRenderers: [],\n      };\n    },\n\n    /**\n     * Get React version from renderer or global.\n     * Prioritizes specific renderer version for multi-renderer scenarios.\n     *\n     * @param {object} hookInfo - Result from detectStatus()\n     * @param {object} [specificRenderer] - Specific renderer to prefer (from resolveFiberWithRenderer)\n     * @returns {string | undefined}\n     */\n    getVersion(hookInfo, specificRenderer) {\n      try {\n        // Priority 1: Specific renderer version (for multi-renderer scenarios)\n        if (specificRenderer) {\n          const version = specificRenderer.version;\n          if (typeof version === 'string' && version.trim()) {\n            return version.trim();\n          }\n        }\n\n        // Priority 2: Any renderer with version\n        const renderers = hookInfo?.renderers || [];\n        for (const item of renderers) {\n          const version = item?.renderer?.version;\n          if (typeof version === 'string' && version.trim()) {\n            return version.trim();\n          }\n        }\n\n        // Priority 3: Global React object (if exposed)\n        if (typeof window !== 'undefined' && window.React?.version) {\n          return String(window.React.version).trim();\n        }\n      } catch {\n        // Best-effort\n      }\n      return undefined;\n    },\n\n    /**\n     * Find React fiber from DOM node\n     */\n    findFiberFromDOM(node) {\n      try {\n        if (!node || typeof node !== 'object') return null;\n        const keys = Object.keys(node);\n        for (const key of keys) {\n          if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {\n            return node[key];\n          }\n        }\n      } catch {\n        // Best-effort\n      }\n      return null;\n    },\n\n    /**\n     * Check if fiber tag is a component (Function/Class/ForwardRef etc.)\n     */\n    isComponentTag(tag) {\n      // 0=FunctionComponent, 1=ClassComponent, 2=IndeterminateComponent,\n      // 11=ForwardRef, 14=MemoComponent, 15=SimpleMemoComponent\n      return tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 14 || tag === 15;\n    },\n\n    /**\n     * Find nearest component fiber by walking up the fiber tree\n     */\n    findNearestComponentFiber(fiber) {\n      try {\n        let current = fiber;\n        for (let i = 0; i < 60 && current; i++) {\n          if (this.isComponentTag(current.tag)) return current;\n          current = current.return;\n        }\n      } catch {\n        // Best-effort\n      }\n      return null;\n    },\n\n    /**\n     * Get component display name from fiber\n     */\n    getComponentName(fiber) {\n      try {\n        const type = fiber?.type || fiber?.elementType;\n        if (!type) return 'Anonymous';\n        if (typeof type === 'string') return type;\n        return safeString(type.displayName || type.name) || 'Anonymous';\n      } catch {\n        return 'Anonymous';\n      }\n    },\n\n    /**\n     * Extract debug source from React Fiber.\n     * Walks up the fiber tree checking _debugSource and _debugOwner._debugSource.\n     *\n     * @param {object} fiber - React Fiber node\n     * @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}\n     */\n    getDebugSource(fiber) {\n      try {\n        let current = fiber;\n        for (let i = 0; i < 40 && current; i++) {\n          if (!isObject(current)) break;\n\n          // Try direct _debugSource first\n          const src = isObject(current._debugSource) ? current._debugSource : null;\n          if (src) {\n            const file = safeString(src.fileName).trim();\n            if (file) {\n              return this.buildDebugSourceResult(file, src.lineNumber, src.columnNumber, current);\n            }\n          }\n\n          // Fallback to _debugOwner._debugSource\n          const owner = isObject(current._debugOwner) ? current._debugOwner : null;\n          const ownerSrc = owner && isObject(owner._debugSource) ? owner._debugSource : null;\n          if (ownerSrc) {\n            const ownerFile = safeString(ownerSrc.fileName).trim();\n            if (ownerFile) {\n              return this.buildDebugSourceResult(\n                ownerFile,\n                ownerSrc.lineNumber,\n                ownerSrc.columnNumber,\n                owner,\n              );\n            }\n          }\n\n          current = current.return;\n        }\n      } catch {\n        // Best-effort extraction\n      }\n      return null;\n    },\n\n    /**\n     * Build debug source result with validated line/column values.\n     * @private\n     */\n    buildDebugSourceResult(file, lineNumber, columnNumber, fiberForName) {\n      const line = Number(lineNumber);\n      const column = Number(columnNumber);\n      return {\n        file,\n        line: Number.isFinite(line) && line > 0 ? line : undefined,\n        column: Number.isFinite(column) && column > 0 ? column : undefined,\n        componentName: this.getComponentName(fiberForName),\n      };\n    },\n\n    /**\n     * Resolve fiber using renderer.findFiberByHostInstance when available\n     */\n    resolveFiberWithRenderer(element, hookInfo) {\n      // Prefer renderer API (returns renderer-owned fiber suitable for overrideProps)\n      try {\n        const renderers = hookInfo?.renderers || [];\n        for (const item of renderers) {\n          const renderer = item?.renderer;\n          if (!renderer || typeof renderer.findFiberByHostInstance !== 'function') continue;\n          try {\n            const fiber = renderer.findFiberByHostInstance(element);\n            if (fiber) return { fiber, renderer };\n          } catch {\n            // Try next renderer\n          }\n        }\n      } catch {\n        // Best-effort\n      }\n\n      // Fallback: DOM-attached fiber reference\n      const fallback = this.findFiberFromDOM(element);\n      return { fiber: fallback, renderer: null };\n    },\n\n    /**\n     * Record original value for reset\n     */\n    recordOriginal(fiber, renderer, path, existed, value) {\n      if (!this.overrideStore || !fiber) return;\n\n      try {\n        const key = JSON.stringify(path);\n        let store = this.overrideStore.get(fiber);\n\n        if (!store) {\n          store = { renderer: renderer || null, originals: new Map() };\n          this.overrideStore.set(fiber, store);\n\n          // Also store by alternate to improve reset hit rate\n          if (fiber.alternate && typeof fiber.alternate === 'object') {\n            this.overrideStore.set(fiber.alternate, store);\n          }\n        }\n\n        if (!store.originals.has(key)) {\n          store.originals.set(key, { path, existed, value });\n        }\n\n        if (!store.renderer && renderer) {\n          store.renderer = renderer;\n        }\n      } catch {\n        // Best-effort\n      }\n    },\n\n    /**\n     * Get stored originals for fiber\n     */\n    getOriginals(fiber) {\n      if (!this.overrideStore || !fiber) return null;\n      return this.overrideStore.get(fiber) || null;\n    },\n\n    /**\n     * Clear stored originals for fiber\n     */\n    clearOriginals(fiber) {\n      if (!this.overrideStore || !fiber) return;\n      const store = this.overrideStore.get(fiber);\n      if (store?.originals) store.originals.clear();\n    },\n  };\n\n  // =============================================================================\n  // Vue Adapter\n  // =============================================================================\n\n  const VueAdapter = {\n    /** Store original values for reset (instance -> Map) */\n    overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null,\n\n    /**\n     * Find Vue 3 component instance from DOM node\n     */\n    findInstanceFromDOM(node) {\n      try {\n        if (!node || typeof node !== 'object') return null;\n        return node.__vueParentComponent || null;\n      } catch {\n        return null;\n      }\n    },\n\n    /**\n     * Get component name from instance\n     */\n    getComponentName(instance) {\n      try {\n        const type = instance?.type;\n        return safeString(type?.name || type?.__name) || 'Anonymous';\n      } catch {\n        return 'Anonymous';\n      }\n    },\n\n    /**\n     * Check if instance appears to be from dev build\n     */\n    isDevBuild(instance) {\n      try {\n        const type = instance?.type;\n        const file = type?.__file;\n        return typeof file === 'string' && !!file.trim();\n      } catch {\n        return false;\n      }\n    },\n\n    /**\n     * Parse Vue inspector location attribute value.\n     * Format: \"src/components/Foo.vue:23:7\" or \"C:\\path\\file.vue:10:5\" (Windows)\n     *\n     * Uses trailing regex to safely handle Windows paths with drive letters.\n     *\n     * @param {string} value - The data-v-inspector attribute value\n     * @returns {{ file: string, line?: number, column?: number } | null}\n     */\n    parseVInspector(value) {\n      if (typeof value !== 'string') return null;\n      const raw = value.trim();\n      if (!raw) return null;\n\n      // Match only trailing :line or :line:column to avoid Windows drive letter issues\n      const match = raw.match(/:([\\d]+)(?::([\\d]+))?$/);\n      if (!match) {\n        // No line info, return file only\n        return { file: raw };\n      }\n\n      const file = raw.slice(0, match.index).trim();\n      if (!file) return null;\n\n      const line = Number.parseInt(match[1], 10);\n      const column = match[2] ? Number.parseInt(match[2], 10) : undefined;\n\n      return {\n        file,\n        line: Number.isFinite(line) && line > 0 ? line : undefined,\n        column: Number.isFinite(column) && column > 0 ? column : undefined,\n      };\n    },\n\n    /**\n     * Walk up DOM tree to find data-v-inspector attribute.\n     * This attribute is injected by @vitejs/plugin-vue-inspector.\n     *\n     * @param {Element} element - Starting DOM element\n     * @param {number} [maxDepth=15] - Maximum depth to traverse\n     * @returns {{ file: string, line?: number, column?: number } | null}\n     */\n    findInspectorLocation(element, maxDepth = 15) {\n      try {\n        let node = element;\n        for (let depth = 0; depth < maxDepth && node; depth++) {\n          if (typeof node.getAttribute === 'function') {\n            const attr = node.getAttribute('data-v-inspector');\n            if (attr) {\n              const parsed = this.parseVInspector(attr);\n              if (parsed?.file) return parsed;\n            }\n          }\n          node = node.parentElement;\n        }\n      } catch {\n        // Best-effort extraction\n      }\n      return null;\n    },\n\n    /**\n     * Get Vue component debug source.\n     * Priority: data-v-inspector (has line/column) > type.__file (file only)\n     *\n     * @param {object} instance - Vue component instance\n     * @param {Element} targetElement - DOM element for inspector lookup\n     * @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}\n     */\n    getDebugSource(instance, targetElement) {\n      try {\n        // Priority 1: data-v-inspector attribute (has precise line/column)\n        const inspector = this.findInspectorLocation(targetElement);\n        if (inspector?.file) {\n          return {\n            file: inspector.file,\n            line: inspector.line,\n            column: inspector.column,\n            componentName: this.getComponentName(instance),\n          };\n        }\n\n        // Priority 2: type.__file (file only, no line/column)\n        const typeFile = instance?.type?.__file;\n        if (typeof typeFile === 'string') {\n          const file = typeFile.trim();\n          if (file) {\n            return {\n              file,\n              componentName: this.getComponentName(instance),\n            };\n          }\n        }\n      } catch {\n        // Best-effort extraction\n      }\n      return null;\n    },\n\n    /**\n     * Get Vue 3 version from instance.\n     * Note: This adapter only supports Vue 3 (via __vueParentComponent).\n     *\n     * @param {object} instance - Vue 3 component instance\n     * @returns {string | undefined}\n     */\n    getVersion(instance) {\n      try {\n        // Vue 3: Get version from app context\n        const appVersion = instance?.appContext?.app?.version;\n        if (typeof appVersion === 'string' && appVersion.trim()) {\n          return appVersion.trim();\n        }\n      } catch {\n        // Best-effort\n      }\n      return undefined;\n    },\n\n    /**\n     * Get writable props container (vnode.props or instance.props)\n     * @deprecated Use getWriteContainers for better targeting\n     */\n    getPropsContainer(instance) {\n      try {\n        const vnodeProps = instance?.vnode?.props;\n        if (vnodeProps && typeof vnodeProps === 'object') return vnodeProps;\n      } catch {\n        // ignore\n      }\n\n      try {\n        const props = instance?.props;\n        if (props && typeof props === 'object') return props;\n      } catch {\n        // ignore\n      }\n\n      return null;\n    },\n\n    /**\n     * Check if a key is a declared prop (vs fallthrough attr).\n     * Uses component type definition and runtime props object.\n     */\n    isDeclaredProp(instance, key) {\n      // Check type.props definition first\n      try {\n        const opts = instance?.type?.props;\n        if (Array.isArray(opts)) return opts.includes(key);\n        if (isObject(opts)) return Object.prototype.hasOwnProperty.call(opts, key);\n      } catch {\n        // ignore\n      }\n\n      // Fallback: if key exists in instance.props, treat as declared\n      try {\n        const props = instance?.props;\n        if (isObject(props)) {\n          return Object.prototype.hasOwnProperty.call(props, key);\n        }\n      } catch {\n        // ignore\n      }\n\n      return false;\n    },\n\n    /**\n     * Get write container candidates for a prop kind ('props' | 'attrs').\n     * Returns array of containers to try in order.\n     */\n    getWriteContainers(instance, kind) {\n      const containers = [];\n      const seen = typeof Set === 'function' ? new Set() : null;\n\n      const addContainer = (obj) => {\n        if (!obj || typeof obj !== 'object') return;\n        if (seen) {\n          if (seen.has(obj)) return;\n          seen.add(obj);\n        }\n        containers.push(obj);\n      };\n\n      if (!instance || typeof instance !== 'object') return containers;\n\n      // Primary container based on kind\n      if (kind === 'attrs') {\n        try {\n          addContainer(instance.attrs);\n        } catch {\n          // ignore\n        }\n      } else {\n        try {\n          addContainer(instance.props);\n        } catch {\n          // ignore\n        }\n      }\n\n      // Fallback: vnode.props (often more writable)\n      try {\n        addContainer(instance?.vnode?.props);\n      } catch {\n        // ignore\n      }\n\n      return containers;\n    },\n\n    /**\n     * Get logical root for reading a prop kind.\n     */\n    getReadRoot(instance, kind) {\n      if (kind === 'attrs') {\n        try {\n          if (isObject(instance?.attrs)) return instance.attrs;\n        } catch {\n          // ignore\n        }\n      } else {\n        try {\n          if (isObject(instance?.props)) return instance.props;\n        } catch {\n          // ignore\n        }\n      }\n\n      // Fallback\n      try {\n        if (isObject(instance?.vnode?.props)) return instance.vnode.props;\n      } catch {\n        // ignore\n      }\n\n      return null;\n    },\n\n    /**\n     * Get raw vnode props object\n     */\n    getVNodeProps(instance) {\n      try {\n        const p = instance?.vnode?.props;\n        return isObject(p) ? p : null;\n      } catch {\n        return null;\n      }\n    },\n\n    /**\n     * Apply new raw props via instance.next + instance.update() so Vue runs its internal\n     * updateProps/updateSlots pipeline (closest to a parent-driven props update).\n     * This is the correct way to trigger Vue3 props update.\n     */\n    applyNextProps(instance, nextRawProps) {\n      try {\n        const vnode = instance?.vnode;\n        if (!vnode || typeof vnode !== 'object') return false;\n\n        // Vue3 PatchFlags.FULL_PROPS = 16\n        const FULL_PROPS = 16;\n        const prevFlag = typeof vnode.patchFlag === 'number' ? vnode.patchFlag : 0;\n        const patchFlag = prevFlag >= 0 ? prevFlag | FULL_PROPS : FULL_PROPS;\n\n        // Create next vnode with updated props\n        const nextVNode = Object.assign({}, vnode, {\n          props: nextRawProps,\n          patchFlag,\n          dynamicProps: null,\n          component: instance,\n        });\n\n        instance.next = nextVNode;\n\n        // Trigger update\n        if (instance && typeof instance.update === 'function') {\n          instance.update();\n          return true;\n        }\n\n        const proxy = instance?.proxy;\n        if (proxy && typeof proxy.$forceUpdate === 'function') {\n          proxy.$forceUpdate();\n          return true;\n        }\n      } catch {\n        // ignore\n      }\n      return false;\n    },\n\n    /**\n     * Trigger Vue re-render (fallback, may not work for props changes)\n     */\n    forceUpdate(instance) {\n      try {\n        const proxy = instance?.proxy;\n        if (proxy && typeof proxy.$forceUpdate === 'function') {\n          proxy.$forceUpdate();\n          return true;\n        }\n      } catch {\n        // ignore\n      }\n\n      try {\n        if (instance && typeof instance.update === 'function') {\n          instance.update();\n          return true;\n        }\n      } catch {\n        // ignore\n      }\n\n      return false;\n    },\n\n    /**\n     * Immutable update helper for nested props\n     */\n    copyWithSet(root, path, value) {\n      if (!Array.isArray(path) || path.length === 0) return value;\n\n      const seg = path[0];\n      const rest = path.slice(1);\n      const isIndex = typeof seg === 'number';\n\n      let base = root;\n      if (\n        base === null ||\n        base === undefined ||\n        (typeof base !== 'object' && !Array.isArray(base))\n      ) {\n        base = isIndex ? [] : {};\n      }\n\n      const clone = Array.isArray(base) ? base.slice() : { ...base };\n      clone[seg] = this.copyWithSet(clone[seg], rest, value);\n      return clone;\n    },\n\n    /**\n     * Record original value for reset\n     * @param {object} instance - Vue component instance\n     * @param {Array} path - Prop path\n     * @param {boolean} existed - Whether the prop existed before\n     * @param {*} value - Original value\n     * @param {'props'|'attrs'} [targetKind] - Target container kind (for accurate reset)\n     */\n    recordOriginal(instance, path, existed, value, targetKind) {\n      if (!this.overrideStore || !instance) return;\n\n      try {\n        const key = JSON.stringify(path);\n        let store = this.overrideStore.get(instance);\n\n        if (!store) {\n          store = new Map();\n          this.overrideStore.set(instance, store);\n        }\n\n        if (!store.has(key)) {\n          store.set(key, { path, existed, value, targetKind });\n        }\n      } catch {\n        // Best-effort\n      }\n    },\n\n    /**\n     * Get stored originals for instance\n     */\n    getOriginals(instance) {\n      if (!this.overrideStore || !instance) return null;\n      return this.overrideStore.get(instance) || null;\n    },\n\n    /**\n     * Clear stored originals for instance\n     */\n    clearOriginals(instance) {\n      if (!this.overrideStore || !instance) return;\n      const store = this.overrideStore.get(instance);\n      if (store) store.clear();\n    },\n  };\n\n  // =============================================================================\n  // Framework Detector\n  // =============================================================================\n\n  const FrameworkDetector = {\n    /**\n     * Detect framework for element (walks up DOM tree)\n     */\n    detect(element, maxDepth = 15) {\n      let node = element;\n\n      for (let depth = 0; depth < maxDepth && node; depth++) {\n        // React first (more common)\n        const fiber = ReactAdapter.findFiberFromDOM(node);\n        if (fiber) {\n          return { framework: 'react', node, data: fiber };\n        }\n\n        // Vue 3\n        const vue = VueAdapter.findInstanceFromDOM(node);\n        if (vue) {\n          return { framework: 'vue', node, data: vue };\n        }\n\n        node = node.parentElement;\n      }\n\n      return { framework: 'unknown', node: null, data: null };\n    },\n  };\n\n  // =============================================================================\n  // Serializer\n  // =============================================================================\n\n  const Serializer = {\n    /**\n     * Check if value is a React element\n     */\n    isReactElement(value) {\n      try {\n        if (!value || typeof value !== 'object') return false;\n        const t = value.$$typeof;\n        if (!t) return false;\n\n        if (typeof Symbol === 'function' && Symbol.for) {\n          return (\n            t === Symbol.for('react.element') ||\n            t === Symbol.for('react.transitional.element') ||\n            t === Symbol.for('react.portal')\n          );\n        }\n\n        // Fallback heuristic\n        return !!(value.type && value.props);\n      } catch {\n        return false;\n      }\n    },\n\n    /**\n     * Get React element display string\n     */\n    reactElementDisplay(value) {\n      try {\n        const type = value?.type;\n        if (typeof type === 'string') return `<${type} />`;\n        if (typeof type === 'function') {\n          return `<${safeString(type.displayName || type.name) || 'Anonymous'} />`;\n        }\n        if (type && typeof type === 'object') {\n          const name = safeString(type.displayName || type.name) || 'Anonymous';\n          return `<${name} />`;\n        }\n      } catch {\n        // ignore\n      }\n      return '<ReactElement />';\n    },\n\n    /**\n     * Check if value is an editable primitive\n     */\n    isEditablePrimitive(value) {\n      if (value === null || value === undefined) return true;\n      const t = typeof value;\n      if (t === 'string' || t === 'boolean') return true;\n      if (t === 'number') return Number.isFinite(value);\n      return false;\n    },\n\n    /**\n     * Create serialization context for cycle detection\n     */\n    createContext() {\n      return {\n        seen: typeof WeakMap === 'function' ? new WeakMap() : null,\n        nextId: 1,\n      };\n    },\n\n    /**\n     * Serialize a value with type information\n     */\n    serializeValue(value, ctx, depth = 0) {\n      try {\n        if (value === null) return { kind: 'null' };\n        if (value === undefined) return { kind: 'undefined' };\n\n        const t = typeof value;\n\n        if (t === 'string') {\n          if (value.length > SERIALIZE_LIMITS.maxStringLength) {\n            return {\n              kind: 'string',\n              value: value.slice(0, SERIALIZE_LIMITS.maxStringLength),\n              truncated: true,\n              length: value.length,\n            };\n          }\n          return { kind: 'string', value };\n        }\n\n        if (t === 'number') {\n          if (Number.isFinite(value)) return { kind: 'number', value };\n          if (Number.isNaN(value)) return { kind: 'number', special: 'NaN' };\n          return { kind: 'number', special: value > 0 ? 'Infinity' : '-Infinity' };\n        }\n\n        if (t === 'boolean') return { kind: 'boolean', value };\n        if (t === 'bigint') return { kind: 'bigint', value: value.toString() };\n        if (t === 'symbol') return { kind: 'symbol', description: safeString(value) };\n        if (t === 'function')\n          return { kind: 'function', name: safeString(value.name) || undefined };\n\n        // Object types\n        if (this.isReactElement(value)) {\n          return { kind: 'react_element', display: this.reactElementDisplay(value) };\n        }\n\n        if (typeof Element !== 'undefined' && value instanceof Element) {\n          return {\n            kind: 'dom_element',\n            tagName: safeString(value.tagName).toLowerCase(),\n            id: safeString(value.id) || undefined,\n            className: safeString(value.className) || undefined,\n          };\n        }\n\n        if (value instanceof Date) {\n          let iso = '';\n          try {\n            iso = value.toISOString();\n          } catch {\n            iso = safeString(value);\n          }\n          return { kind: 'date', value: iso };\n        }\n\n        if (value instanceof RegExp) {\n          return { kind: 'regexp', source: value.source, flags: value.flags };\n        }\n\n        if (value instanceof Error) {\n          return {\n            kind: 'error',\n            name: safeString(value.name) || 'Error',\n            message: safeString(value.message),\n          };\n        }\n\n        // Depth limit\n        if (depth >= SERIALIZE_LIMITS.maxDepth) {\n          return {\n            kind: 'max_depth',\n            type: Object.prototype.toString.call(value),\n            preview: safeString(value),\n          };\n        }\n\n        // Circular reference detection\n        if (ctx?.seen) {\n          const existingId = ctx.seen.get(value);\n          if (existingId) return { kind: 'circular', refId: existingId };\n          ctx.seen.set(value, ctx.nextId++);\n        }\n\n        // Array\n        if (Array.isArray(value)) {\n          const max = Math.min(value.length, SERIALIZE_LIMITS.maxArrayLength);\n          const items = [];\n          for (let i = 0; i < max; i++) {\n            items.push(this.serializeValue(value[i], ctx, depth + 1));\n          }\n          return {\n            kind: 'array',\n            length: value.length,\n            truncated: value.length > max,\n            items,\n          };\n        }\n\n        // Map\n        if (value instanceof Map) {\n          const entries = [];\n          let count = 0;\n          for (const [k, v] of value.entries()) {\n            if (count >= SERIALIZE_LIMITS.maxEntries) break;\n            entries.push({\n              key: this.serializeValue(k, ctx, depth + 1),\n              value: this.serializeValue(v, ctx, depth + 1),\n            });\n            count++;\n          }\n          return {\n            kind: 'map',\n            size: value.size,\n            truncated: value.size > count,\n            entries,\n          };\n        }\n\n        // Set\n        if (value instanceof Set) {\n          const items = [];\n          let count = 0;\n          for (const v of value.values()) {\n            if (count >= SERIALIZE_LIMITS.maxEntries) break;\n            items.push(this.serializeValue(v, ctx, depth + 1));\n            count++;\n          }\n          return {\n            kind: 'set',\n            size: value.size,\n            truncated: value.size > count,\n            items,\n          };\n        }\n\n        // Plain object\n        const constructorName = value?.constructor?.name;\n        const name = typeof constructorName === 'string' ? constructorName : undefined;\n        const keys = Object.keys(value);\n        const limitedKeys = keys.slice(0, SERIALIZE_LIMITS.maxEntries);\n        const entries = limitedKeys.map((k) => ({\n          key: k,\n          value: this.serializeValue(value[k], ctx, depth + 1),\n        }));\n\n        return {\n          kind: 'object',\n          name: name !== 'Object' ? name : undefined,\n          truncated: keys.length > limitedKeys.length,\n          entries,\n        };\n      } catch (err) {\n        return { kind: 'unknown', type: typeof value, preview: safeString(err) };\n      }\n    },\n\n    /**\n     * Serialize props object to structured format\n     * @param {object} props - Props object to serialize\n     * @param {Record<string, Array<string|number|boolean>>} [enumValuesByKey] - Optional enum values by prop key\n     */\n    serializeProps(props, enumValuesByKey) {\n      const ctx = this.createContext();\n      const entries = [];\n      const enumMap = isObject(enumValuesByKey) ? enumValuesByKey : null;\n\n      if (!props || (typeof props !== 'object' && typeof props !== 'function')) {\n        return { kind: 'props', entries: [] };\n      }\n\n      const keys = Object.keys(props);\n      const limited = keys.slice(0, SERIALIZE_LIMITS.maxEntries);\n\n      for (const key of limited) {\n        let raw;\n        try {\n          raw = props[key];\n        } catch {\n          raw = undefined;\n        }\n\n        const entry = {\n          key,\n          editable: this.isEditablePrimitive(raw),\n          value: this.serializeValue(raw, ctx, 0),\n        };\n\n        // Attach enum values if available\n        const enumValues = enumMap ? enumMap[key] : null;\n        if (Array.isArray(enumValues) && enumValues.length > 0) {\n          entry.enumValues = enumValues.slice(0, EnumIntrospection.MAX_ENUM_VALUES);\n        }\n\n        entries.push(entry);\n      }\n\n      const result = { kind: 'props', entries };\n      if (keys.length > limited.length) result.truncated = true;\n      return result;\n    },\n  };\n\n  // =============================================================================\n  // Enum Introspection (Best-effort)\n  // =============================================================================\n\n  /**\n   * Best-effort enum value extraction from React/Vue runtime metadata.\n   *\n   * React: Relies on __docgenInfo (Storybook/react-docgen output)\n   * Vue: Relies on explicit values/validator.values in props options\n   */\n  const EnumIntrospection = {\n    MAX_ENUM_VALUES: 50,\n\n    /**\n     * Normalize a raw enum value to primitive\n     */\n    normalizeEnumValue(raw) {\n      if (raw === null || raw === undefined) return null;\n\n      if (typeof raw === 'boolean') return raw;\n      if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;\n\n      const s = safeString(raw).trim();\n      if (!s) return null;\n\n      // Strip surrounding quotes: \"'primary'\" -> \"primary\"\n      const m = s.match(/^(['\"])(.*)\\1$/);\n      const unquoted = m ? m[2] : s;\n\n      if (unquoted === 'true') return true;\n      if (unquoted === 'false') return false;\n\n      if (/^-?(?:\\d+|\\d*\\.\\d+)$/.test(unquoted)) {\n        const n = Number(unquoted);\n        if (Number.isFinite(n)) return n;\n      }\n\n      return unquoted;\n    },\n\n    /**\n     * Normalize array of enum values, deduplicate\n     */\n    normalizeEnumList(list) {\n      if (!Array.isArray(list)) return [];\n      const out = [];\n      const seen = new Set();\n\n      for (const item of list) {\n        const v = this.normalizeEnumValue(item);\n        if (v === null) continue;\n        const key =\n          typeof v === 'string' ? `s:${v}` : typeof v === 'number' ? `n:${v}` : `b:${v ? 1 : 0}`;\n        if (seen.has(key)) continue;\n        seen.add(key);\n        out.push(v);\n        if (out.length >= this.MAX_ENUM_VALUES) break;\n      }\n\n      return out;\n    },\n\n    /**\n     * Extract enum values from React docgen prop info\n     * (e.g., from Storybook's __docgenInfo)\n     */\n    extractDocgenEnumValues(propInfo) {\n      if (!isObject(propInfo)) return [];\n\n      // Check type.name === 'enum' with type.value array\n      const t = propInfo.type;\n      if (isObject(t) && t.name === 'enum' && Array.isArray(t.value)) {\n        const rawList = t.value.map((item) =>\n          isObject(item) && 'value' in item ? item.value : item,\n        );\n        return this.normalizeEnumList(rawList);\n      }\n\n      // Check tsType for TypeScript enums\n      const ts = propInfo.tsType;\n      if (isObject(ts) && ts.name === 'union' && Array.isArray(ts.elements)) {\n        const rawList = ts.elements.map((el) =>\n          isObject(el) && 'value' in el ? el.value : el.name,\n        );\n        return this.normalizeEnumList(rawList);\n      }\n\n      return [];\n    },\n\n    /**\n     * Get enum values map for React component\n     */\n    getReactEnumValues(componentFiber) {\n      try {\n        const type = componentFiber?.type || componentFiber?.elementType;\n        if (!type) return {};\n\n        const docgen = type.__docgenInfo;\n        if (!isObject(docgen) || !isObject(docgen.props)) return {};\n\n        const result = {};\n        for (const [key, info] of Object.entries(docgen.props)) {\n          const values = this.extractDocgenEnumValues(info);\n          if (values.length > 0) result[key] = values;\n        }\n        return result;\n      } catch {\n        return {};\n      }\n    },\n\n    /**\n     * Extract enum values from Vue prop option\n     */\n    extractVuePropEnumValues(propOption) {\n      if (!isObject(propOption)) return [];\n\n      // Check explicit values array\n      if (Array.isArray(propOption.values)) {\n        return this.normalizeEnumList(propOption.values);\n      }\n\n      // Check validator with values/allowedValues\n      const validator = propOption.validator;\n      if (validator && Array.isArray(validator.values)) {\n        return this.normalizeEnumList(validator.values);\n      }\n      if (validator && Array.isArray(validator.allowedValues)) {\n        return this.normalizeEnumList(validator.allowedValues);\n      }\n\n      return [];\n    },\n\n    /**\n     * Get enum values map for Vue component\n     */\n    getVueEnumValues(instance) {\n      try {\n        const propsOptions = instance?.type?.props;\n        if (!isObject(propsOptions)) return {};\n\n        const result = {};\n        for (const [key, opt] of Object.entries(propsOptions)) {\n          const values = this.extractVuePropEnumValues(opt);\n          if (values.length > 0) result[key] = values;\n        }\n        return result;\n      } catch {\n        return {};\n      }\n    },\n  };\n\n  // =============================================================================\n  // Value Access Helpers\n  // =============================================================================\n\n  function getValueAtPath(root, path) {\n    let current = root;\n\n    for (let i = 0; i < path.length; i++) {\n      const seg = path[i];\n      if (!isObject(current) && !Array.isArray(current)) {\n        return { ok: false, existed: false, value: undefined };\n      }\n\n      const has = Object.prototype.hasOwnProperty.call(current, seg);\n      current = current[seg];\n\n      if (!has && i === path.length - 1) {\n        return { ok: true, existed: false, value: undefined };\n      }\n    }\n\n    return { ok: true, existed: true, value: current };\n  }\n\n  // Dangerous keys that could cause prototype pollution or unexpected behavior\n  const DANGEROUS_KEYS = new Set([\n    '__proto__',\n    'constructor',\n    'prototype',\n    '__defineGetter__',\n    '__defineSetter__',\n    '__lookupGetter__',\n    '__lookupSetter__',\n  ]);\n\n  function isDangerousKey(key) {\n    return typeof key === 'string' && DANGEROUS_KEYS.has(key);\n  }\n\n  function normalizePropPath(value) {\n    if (!Array.isArray(value) || value.length === 0 || value.length > 32) return null;\n\n    const result = [];\n    for (const seg of value) {\n      if (typeof seg === 'string') {\n        const s = seg.trim();\n        if (!s) return null;\n        // Reject dangerous keys to prevent prototype pollution\n        if (isDangerousKey(s)) return null;\n        result.push(s);\n      } else if (typeof seg === 'number' && Number.isInteger(seg) && seg >= 0 && seg <= 1e6) {\n        result.push(seg);\n      } else {\n        return null;\n      }\n    }\n    return result;\n  }\n\n  function decodeIncomingValue(raw) {\n    // Bridge encodes undefined as { $we: 'undefined' }\n    if (isObject(raw) && raw.$we === 'undefined') return undefined;\n    return raw;\n  }\n\n  // =============================================================================\n  // Capabilities Builder\n  // =============================================================================\n\n  function makeCapabilities(init) {\n    return {\n      canRead: Boolean(init?.canRead),\n      canWrite: Boolean(init?.canWrite),\n      canWriteHooks: Boolean(init?.canWriteHooks),\n    };\n  }\n\n  function buildResponseData(init) {\n    const data = {};\n    if (init?.hookStatus) data.hookStatus = init.hookStatus;\n    if (typeof init?.needsRefresh === 'boolean') data.needsRefresh = init.needsRefresh;\n    if (init?.framework) data.framework = init.framework;\n    if (init?.frameworkVersion) data.frameworkVersion = init.frameworkVersion;\n    if (init?.componentName) data.componentName = init.componentName;\n    if (init?.debugSource) data.debugSource = init.debugSource;\n    if (init?.props) data.props = init.props;\n    if (init?.capabilities) data.capabilities = init.capabilities;\n    if (init?.meta) data.meta = init.meta;\n    return data;\n  }\n\n  // =============================================================================\n  // Request Handlers\n  // =============================================================================\n\n  const Handlers = {\n    resolveTarget(locator) {\n      if (!locator) return null;\n      const el = Locator.locate(locator, document);\n      // Return element if connected to DOM; otherwise return null\n      return el?.isConnected ? el : null;\n    },\n\n    /**\n     * Handle 'probe' operation - Detect capabilities without reading props\n     */\n    handleProbe(req) {\n      // Check initial hook status\n      const preStatus = ReactAdapter.detectStatus();\n      const initialHookStatus = preStatus.hookStatus;\n\n      // Try to install hook if missing (only helps if React hasn't initialized)\n      if (initialHookStatus === HOOK_STATUS.HOOK_MISSING) {\n        ReactAdapter.installMinimalHook();\n      }\n\n      const hookInfo = ReactAdapter.detectStatus();\n      // Report original status if hook was missing (so UI knows refresh is needed)\n      const hookStatus =\n        initialHookStatus === HOOK_STATUS.HOOK_MISSING\n          ? HOOK_STATUS.HOOK_MISSING\n          : hookInfo.hookStatus;\n\n      const target = this.resolveTarget(req.locator);\n      const fw = target ? FrameworkDetector.detect(target) : { framework: 'unknown', data: null };\n\n      let componentName;\n      let debugSource;\n      let canRead = false;\n      let canWrite = false;\n      let needsRefresh = false;\n\n      let frameworkVersion;\n\n      if (fw.framework === 'react') {\n        const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);\n        const componentFiber = fiberInfo.fiber\n          ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)\n          : null;\n\n        componentName = componentFiber ? ReactAdapter.getComponentName(componentFiber) : undefined;\n        // Extract debug source from component fiber or raw fiber\n        const sourceFiber = componentFiber || fiberInfo.fiber;\n        debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;\n        // Pass specific renderer to prioritize its version in multi-renderer scenarios\n        frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);\n        canRead = Boolean(componentFiber);\n        canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);\n        needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;\n      } else if (fw.framework === 'vue') {\n        const instance = fw.data;\n        componentName = VueAdapter.getComponentName(instance);\n        debugSource = instance ? VueAdapter.getDebugSource(instance, target) : undefined;\n        frameworkVersion = VueAdapter.getVersion(instance);\n        canRead = Boolean(instance);\n        canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);\n        needsRefresh = false;\n      }\n\n      const data = buildResponseData({\n        hookStatus,\n        framework: fw.framework,\n        frameworkVersion,\n        componentName,\n        debugSource,\n        capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),\n        needsRefresh,\n      });\n\n      return Transport.createResponse(req.requestId, true, data);\n    },\n\n    /**\n     * Handle 'read' operation - Read component props\n     */\n    handleRead(req) {\n      const target = this.resolveTarget(req.locator);\n      if (!target) {\n        return Transport.createResponse(\n          req.requestId,\n          false,\n          undefined,\n          'Target element not found',\n        );\n      }\n\n      const preStatus = ReactAdapter.detectStatus();\n      if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {\n        ReactAdapter.installMinimalHook();\n      }\n\n      const hookInfo = ReactAdapter.detectStatus();\n      const hookStatus =\n        preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING\n          ? HOOK_STATUS.HOOK_MISSING\n          : hookInfo.hookStatus;\n\n      const fw = FrameworkDetector.detect(target);\n\n      if (fw.framework === 'react') {\n        const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);\n        const componentFiber = fiberInfo.fiber\n          ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)\n          : null;\n\n        // Extract debug source even if component fiber not found\n        const sourceFiber = componentFiber || fiberInfo.fiber;\n        const debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;\n        // Pass specific renderer to prioritize its version in multi-renderer scenarios\n        const frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);\n\n        if (!componentFiber) {\n          const data = buildResponseData({\n            hookStatus,\n            framework: 'react',\n            frameworkVersion,\n            debugSource,\n            capabilities: makeCapabilities({ canRead: false, canWrite: false }),\n            needsRefresh: false,\n          });\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            data,\n            'React component fiber not found',\n          );\n        }\n\n        const props = componentFiber.memoizedProps;\n        const enumValuesByKey = EnumIntrospection.getReactEnumValues(componentFiber);\n        const serialized = Serializer.serializeProps(props, enumValuesByKey);\n        const componentName = ReactAdapter.getComponentName(componentFiber);\n        const canWrite = hookStatus === HOOK_STATUS.READY;\n        const needsRefresh = hookStatus !== HOOK_STATUS.READY;\n\n        const data = buildResponseData({\n          hookStatus,\n          framework: 'react',\n          frameworkVersion,\n          componentName,\n          debugSource,\n          props: serialized,\n          capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),\n          needsRefresh,\n        });\n\n        return Transport.createResponse(req.requestId, true, data);\n      }\n\n      if (fw.framework === 'vue') {\n        const instance = fw.data;\n        const frameworkVersion = VueAdapter.getVersion(instance);\n\n        if (!instance) {\n          const data = buildResponseData({\n            hookStatus,\n            framework: 'vue',\n            frameworkVersion,\n            capabilities: makeCapabilities({ canRead: false, canWrite: false }),\n            needsRefresh: false,\n          });\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            data,\n            'Vue component instance not found',\n          );\n        }\n\n        const componentName = VueAdapter.getComponentName(instance);\n        const debugSource = VueAdapter.getDebugSource(instance, target);\n\n        // Read both props and attrs\n        let rootProps = null;\n        let rootAttrs = null;\n        try {\n          rootProps = instance.props;\n        } catch {\n          rootProps = null;\n        }\n        try {\n          rootAttrs = instance.attrs;\n        } catch {\n          rootAttrs = null;\n        }\n\n        // Serialize props with enum introspection\n        const enumValuesByKey = EnumIntrospection.getVueEnumValues(instance);\n        const serializedProps = Serializer.serializeProps(rootProps, enumValuesByKey);\n        const serializedAttrs = Serializer.serializeProps(rootAttrs, null);\n\n        // Merge entries with source annotation\n        const mergedEntries = [];\n        if (Array.isArray(serializedProps.entries)) {\n          for (const entry of serializedProps.entries) {\n            mergedEntries.push({ ...entry, source: 'props' });\n          }\n        }\n        if (Array.isArray(serializedAttrs.entries)) {\n          for (const entry of serializedAttrs.entries) {\n            mergedEntries.push({ ...entry, source: 'attrs' });\n          }\n        }\n\n        const serialized = {\n          kind: 'props',\n          entries: mergedEntries,\n        };\n        if (serializedProps.truncated || serializedAttrs.truncated) {\n          serialized.truncated = true;\n        }\n\n        const canWrite = VueAdapter.isDevBuild(instance);\n\n        const data = buildResponseData({\n          hookStatus,\n          framework: 'vue',\n          frameworkVersion,\n          componentName,\n          debugSource,\n          props: serialized,\n          capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),\n          needsRefresh: false,\n        });\n\n        return Transport.createResponse(req.requestId, true, data);\n      }\n\n      // Unknown framework\n      const data = buildResponseData({\n        hookStatus,\n        framework: 'unknown',\n        capabilities: makeCapabilities({ canRead: false, canWrite: false }),\n        needsRefresh: false,\n      });\n\n      return Transport.createResponse(req.requestId, false, data, 'Not a React/Vue component');\n    },\n\n    /**\n     * Handle 'write' operation - Modify component props\n     */\n    handleWrite(req) {\n      const target = this.resolveTarget(req.locator);\n      if (!target) {\n        return Transport.createResponse(\n          req.requestId,\n          false,\n          undefined,\n          'Target element not found',\n        );\n      }\n\n      const path = normalizePropPath(req.payload?.propPath);\n      if (!path) {\n        return Transport.createResponse(req.requestId, false, undefined, 'Invalid propPath');\n      }\n\n      const rawValue = req.payload?.propValue;\n      const value = decodeIncomingValue(rawValue);\n      if (!Serializer.isEditablePrimitive(value)) {\n        return Transport.createResponse(\n          req.requestId,\n          false,\n          undefined,\n          'Only primitive prop values are supported',\n        );\n      }\n\n      const preStatus = ReactAdapter.detectStatus();\n      if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {\n        ReactAdapter.installMinimalHook();\n      }\n\n      const hookInfo = ReactAdapter.detectStatus();\n      const hookStatus =\n        preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING\n          ? HOOK_STATUS.HOOK_MISSING\n          : hookInfo.hookStatus;\n\n      const fw = FrameworkDetector.detect(target);\n\n      if (fw.framework === 'react') {\n        const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);\n        const componentFiber = fiberInfo.fiber\n          ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)\n          : null;\n\n        const componentName = componentFiber\n          ? ReactAdapter.getComponentName(componentFiber)\n          : undefined;\n        const canRead = Boolean(componentFiber);\n        const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);\n        const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;\n\n        const base = buildResponseData({\n          hookStatus,\n          framework: 'react',\n          componentName,\n          capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),\n          needsRefresh,\n        });\n\n        if (!componentFiber) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'React component fiber not found',\n          );\n        }\n\n        if (hookStatus !== HOOK_STATUS.READY) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'React DevTools editing API unavailable. Use a Development build and refresh the page.',\n          );\n        }\n\n        // Check current value for editability and record original\n        const props = componentFiber.memoizedProps;\n        const read = getValueAtPath(props, path);\n        if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Target prop is not a primitive (read-only)',\n          );\n        }\n\n        // Try renderers with overrideProps\n        const candidates = (hookInfo.editableRenderers || [])\n          .map((r) => r.renderer)\n          .filter(Boolean);\n        const preferred =\n          fiberInfo.renderer && typeof fiberInfo.renderer.overrideProps === 'function'\n            ? fiberInfo.renderer\n            : null;\n        const ordered = preferred\n          ? [preferred, ...candidates.filter((r) => r !== preferred)]\n          : candidates;\n\n        let usedRenderer = null;\n        let lastErr = null;\n\n        for (const renderer of ordered) {\n          try {\n            renderer.overrideProps(componentFiber, path, value);\n            usedRenderer = renderer;\n            break;\n          } catch (err) {\n            lastErr = err;\n          }\n        }\n\n        if (!usedRenderer) {\n          base.meta = { write: { method: 'overrideProps', error: safeString(lastErr) } };\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Failed to write props via overrideProps',\n          );\n        }\n\n        ReactAdapter.recordOriginal(componentFiber, usedRenderer, path, read.existed, read.value);\n        base.meta = { write: { method: 'overrideProps' } };\n\n        return Transport.createResponse(req.requestId, true, base);\n      }\n\n      if (fw.framework === 'vue') {\n        const instance = fw.data;\n        const componentName = VueAdapter.getComponentName(instance);\n        const canRead = Boolean(instance);\n        const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);\n\n        const base = buildResponseData({\n          hookStatus,\n          framework: 'vue',\n          componentName,\n          capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),\n          needsRefresh: false,\n        });\n\n        if (!instance) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Vue component instance not found',\n          );\n        }\n\n        if (!VueAdapter.isDevBuild(instance)) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Vue dev metadata missing. Use a Development build.',\n          );\n        }\n\n        // Vue props keys must be strings at top level\n        if (typeof path[0] !== 'string') {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Vue propPath must start with a string key',\n          );\n        }\n\n        const propName = path[0];\n        const subPath = path.slice(1);\n\n        // Infer target kind based on whether key is declared prop\n        const targetKind = VueAdapter.isDeclaredProp(instance, propName) ? 'props' : 'attrs';\n\n        // Check current value from logical root\n        const readRoot = VueAdapter.getReadRoot(instance, targetKind) || {};\n        const read = getValueAtPath(readRoot, path);\n        if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Target prop is not a primitive (read-only)',\n          );\n        }\n\n        // Build next vnode props (the correct way to update Vue3 props)\n        const currentRawProps = VueAdapter.getVNodeProps(instance) || {};\n        const nextRawProps = { ...currentRawProps };\n\n        try {\n          if (subPath.length === 0) {\n            nextRawProps[propName] = value;\n          } else {\n            const prev = nextRawProps[propName];\n            nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, value);\n          }\n        } catch (err) {\n          base.meta = {\n            write: { method: 'vueNextVNode', target: targetKind, error: safeString(err) },\n          };\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Failed to build Vue props patch',\n          );\n        }\n\n        // Apply via instance.next + update() to trigger Vue's internal updateProps pipeline\n        if (!VueAdapter.applyNextProps(instance, nextRawProps)) {\n          base.meta = {\n            write: { method: 'vueNextVNode', target: targetKind, error: 'No update method' },\n          };\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Vue update method not available',\n          );\n        }\n\n        // Record original for reset only after successful write (include targetKind for accurate reset)\n        VueAdapter.recordOriginal(instance, path, read.existed, read.value, targetKind);\n\n        base.meta = { write: { method: 'vueNextVNode', target: targetKind } };\n        return Transport.createResponse(req.requestId, true, base);\n      }\n\n      return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component');\n    },\n\n    /**\n     * Handle 'reset' operation - Restore original props values\n     */\n    handleReset(req) {\n      const target = this.resolveTarget(req.locator);\n      if (!target) {\n        return Transport.createResponse(\n          req.requestId,\n          false,\n          undefined,\n          'Target element not found',\n        );\n      }\n\n      const preStatus = ReactAdapter.detectStatus();\n      if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {\n        ReactAdapter.installMinimalHook();\n      }\n\n      const hookInfo = ReactAdapter.detectStatus();\n      const hookStatus =\n        preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING\n          ? HOOK_STATUS.HOOK_MISSING\n          : hookInfo.hookStatus;\n\n      const fw = FrameworkDetector.detect(target);\n\n      if (fw.framework === 'react') {\n        const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);\n        const componentFiber = fiberInfo.fiber\n          ? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)\n          : null;\n\n        const componentName = componentFiber\n          ? ReactAdapter.getComponentName(componentFiber)\n          : undefined;\n        const canRead = Boolean(componentFiber);\n        const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);\n        const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;\n\n        const base = buildResponseData({\n          hookStatus,\n          framework: 'react',\n          componentName,\n          capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),\n          needsRefresh,\n        });\n\n        if (!componentFiber) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'React component fiber not found',\n          );\n        }\n\n        const store = ReactAdapter.getOriginals(componentFiber);\n        if (!store?.originals?.size) {\n          base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } };\n          base.needsRefresh = true;\n          return Transport.createResponse(req.requestId, true, base);\n        }\n\n        if (hookStatus !== HOOK_STATUS.READY) {\n          base.meta = { reset: { method: 'refresh', reason: 'hookNotReady' } };\n          base.needsRefresh = true;\n          return Transport.createResponse(req.requestId, true, base);\n        }\n\n        const renderer = store.renderer;\n        if (!renderer || typeof renderer.overrideProps !== 'function') {\n          base.meta = { reset: { method: 'refresh', reason: 'missingRenderer' } };\n          base.needsRefresh = true;\n          return Transport.createResponse(req.requestId, true, base);\n        }\n\n        let reverted = 0;\n        for (const entry of store.originals.values()) {\n          try {\n            renderer.overrideProps(componentFiber, entry.path, entry.value);\n            reverted++;\n          } catch {\n            // Continue reverting others\n          }\n        }\n\n        ReactAdapter.clearOriginals(componentFiber);\n        base.meta = { reset: { method: 'overrideProps', reverted } };\n\n        return Transport.createResponse(req.requestId, true, base);\n      }\n\n      if (fw.framework === 'vue') {\n        const instance = fw.data;\n        const componentName = VueAdapter.getComponentName(instance);\n        const canRead = Boolean(instance);\n        const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);\n\n        const base = buildResponseData({\n          hookStatus,\n          framework: 'vue',\n          componentName,\n          capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),\n          needsRefresh: false,\n        });\n\n        if (!instance) {\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            base,\n            'Vue component instance not found',\n          );\n        }\n\n        const store = VueAdapter.getOriginals(instance);\n        if (!store?.size) {\n          base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } };\n          base.needsRefresh = true;\n          return Transport.createResponse(req.requestId, true, base);\n        }\n\n        // Build next vnode props with all originals restored\n        const currentRawProps = VueAdapter.getVNodeProps(instance) || {};\n        const nextRawProps = { ...currentRawProps };\n\n        let reverted = 0;\n        for (const entry of store.values()) {\n          const path = entry.path;\n          if (!Array.isArray(path) || typeof path[0] !== 'string') continue;\n\n          const propName = path[0];\n          const subPath = path.slice(1);\n\n          try {\n            if (subPath.length === 0) {\n              if (entry.existed) {\n                nextRawProps[propName] = entry.value;\n              } else {\n                delete nextRawProps[propName];\n              }\n            } else {\n              const prev = nextRawProps[propName];\n              nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, entry.value);\n            }\n            reverted++;\n          } catch {\n            // Continue with other entries\n          }\n        }\n\n        // Apply via instance.next + update() to trigger Vue's internal updateProps pipeline\n        if (!VueAdapter.applyNextProps(instance, nextRawProps)) {\n          base.meta = { reset: { method: 'refresh', reason: 'noUpdate' } };\n          base.needsRefresh = true;\n          return Transport.createResponse(req.requestId, true, base);\n        }\n\n        VueAdapter.clearOriginals(instance);\n        base.meta = { reset: { method: 'vueNextVNode', reverted } };\n\n        return Transport.createResponse(req.requestId, true, base);\n      }\n\n      return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component');\n    },\n\n    /**\n     * Handle 'cleanup' operation - Dispose agent\n     */\n    handleCleanup(req) {\n      const resp = Transport.createResponse(req.requestId, true, {\n        meta: { cleanup: { ok: true } },\n      });\n      Lifecycle.dispose('request');\n      return resp;\n    },\n\n    /**\n     * Route request to appropriate handler\n     */\n    handle(req) {\n      switch (req.op) {\n        case 'probe':\n          return this.handleProbe(req);\n        case 'read':\n          return this.handleRead(req);\n        case 'write':\n          return this.handleWrite(req);\n        case 'reset':\n          return this.handleReset(req);\n        case 'cleanup':\n          return this.handleCleanup(req);\n        default:\n          return Transport.createResponse(\n            req.requestId,\n            false,\n            undefined,\n            `Unsupported op: ${safeString(req.op)}`,\n          );\n      }\n    },\n  };\n\n  // =============================================================================\n  // Lifecycle Management\n  // =============================================================================\n\n  const Lifecycle = {\n    disposed: false,\n\n    onRequestEvent(event) {\n      try {\n        if (Lifecycle.disposed) return;\n\n        const detail = event?.detail;\n        const req = Transport.normalizeRequest(detail);\n        if (!req) return;\n\n        const resp = Handlers.handle(req);\n        Transport.dispatchResponse(resp);\n      } catch (err) {\n        try {\n          const requestId = event?.detail?.requestId;\n          if (typeof requestId === 'string' && requestId) {\n            Transport.dispatchResponse(\n              Transport.createResponse(requestId, false, undefined, safeString(err)),\n            );\n          }\n        } catch {\n          // ignore\n        }\n      }\n    },\n\n    onCleanupEvent() {\n      Lifecycle.dispose('external-event');\n    },\n\n    dispose(reason) {\n      if (this.disposed) return;\n      this.disposed = true;\n\n      try {\n        window.removeEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true);\n        window.removeEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true);\n      } catch {\n        // ignore\n      }\n\n      try {\n        delete window[GLOBAL_KEY];\n      } catch {\n        // ignore\n      }\n\n      if (reason) {\n        logWarn('Disposed:', reason);\n      }\n    },\n\n    init() {\n      // Use capture phase to avoid page stopPropagation interfering\n      window.addEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true);\n      window.addEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true);\n\n      window[GLOBAL_KEY] = {\n        version: PROTOCOL_VERSION,\n        dispose: () => this.dispose('manual'),\n      };\n\n      // Early injection: install minimal hook before React loads (document_start)\n      // This is critical for capturing React renderers that initialize early\n      if (document.readyState === 'loading') {\n        try {\n          const status = ReactAdapter.detectStatus();\n          if (status.hookStatus === HOOK_STATUS.HOOK_MISSING) {\n            ReactAdapter.installMinimalHook();\n            logWarn('Installed minimal hook during early injection');\n          }\n        } catch (err) {\n          // Best-effort: early injection may fail in some environments\n          logWarn('Early hook injection failed:', err);\n        }\n      }\n    },\n  };\n\n  // Initialize\n  Lifecycle.init();\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/recorder.js",
    "content": "/* eslint-disable */\n// recorder.js - content script for recording user interactions into steps\n\n(function () {\n  if (window.__RR_RECORDER_INSTALLED__) return;\n  window.__RR_RECORDER_INSTALLED__ = true;\n\n  // ================================================================\n  // 1) CONFIG + STATELESS HELPERS (namespaced)\n  // ================================================================\n  const CONFIG = {\n    // Increase debounce to improve step merging for slow/DOM-replacing inputs\n    INPUT_DEBOUNCE_MS: 800,\n    BATCH_SEND_MS: 100,\n    SCROLL_DEBOUNCE_MS: 350,\n    SENSITIVE_INPUT_TYPES: new Set(['password']),\n    UI_MAX_STEPS: 30,\n    // Maximum time to hold flush while user is typing (prevents unbounded batch accumulation)\n    MAX_TYPING_HOLD_MS: 1500,\n  };\n  // Cross-frame event channel\n  const FRAME_EVENT = 'rr_iframe_event';\n\n  // Memoization caches for selector computations during recording\n  const __cacheUnique = new WeakMap();\n  const __cachePath = new WeakMap();\n\n  const SelectorEngine = {\n    buildTarget(el) {\n      const candidates = [];\n      const attrNames = ['data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy'];\n      for (const an of attrNames) {\n        const v = el.getAttribute && el.getAttribute(an);\n        if (v) candidates.push({ type: 'attr', value: `[${an}=\"${CSS.escape(v)}\"]` });\n      }\n      const classSel = this._uniqueClassSelector(el);\n      if (classSel) candidates.push({ type: 'css', value: classSel });\n      const css = this._generateSelector(el);\n      if (css) candidates.push({ type: 'css', value: css });\n      const name = el.getAttribute && el.getAttribute('name');\n      if (name) candidates.push({ type: 'attr', value: `[name=\"${CSS.escape(name)}\"]` });\n      const title = el.getAttribute && el.getAttribute('title');\n      if (title) candidates.push({ type: 'attr', value: `[title=\"${CSS.escape(title)}\"]` });\n      const alt = el.getAttribute && el.getAttribute('alt');\n      if (alt) candidates.push({ type: 'attr', value: `[alt=\"${CSS.escape(alt)}\"]` });\n      const aria = el.getAttribute && el.getAttribute('aria-label');\n      const role = el.getAttribute && el.getAttribute('role');\n      if (aria) {\n        if (role) candidates.push({ type: 'aria', value: `${role}[name=${aria}]` });\n        else candidates.push({ type: 'aria', value: `textbox[name=${aria}]` });\n      }\n      const tag = el.tagName?.toLowerCase?.() || '';\n      if (['button', 'a', 'summary'].includes(tag)) {\n        const text = (el.textContent || '').trim();\n        if (text) candidates.push({ type: 'text', value: text.substring(0, 64) });\n      }\n      const selector = SelectorEngine._choosePrimary(el, candidates);\n      return { selector, candidates, tag };\n    },\n\n    _choosePrimary(el, candidates) {\n      if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) {\n        return `#${CSS.escape(el.id)}`;\n      }\n      const priority = ['attr', 'css'];\n      for (const p of priority) {\n        const c = candidates.find((c) => c.type === p);\n        if (c) {\n          try {\n            const tag = el.tagName ? el.tagName.toLowerCase() : '';\n            if (p === 'attr' && (tag === 'input' || tag === 'textarea' || tag === 'select')) {\n              const val = String(c.value || '').trim();\n              if (val.startsWith('[')) return `${tag}${val}`;\n            }\n          } catch {}\n          return c.value;\n        }\n      }\n      if (candidates.length) return candidates[0].value;\n      return SelectorEngine._generateSelector(el) || '';\n    },\n\n    _uniqueClassSelector(el) {\n      if (__cacheUnique.has(el)) return __cacheUnique.get(el);\n      let result = '';\n      try {\n        const classes = Array.from(el.classList || []).filter(\n          (c) => c && /^[a-zA-Z0-9_-]+$/.test(c),\n        );\n        for (const cls of classes) {\n          const sel = `.${CSS.escape(cls)}`;\n          if (document.querySelectorAll(sel).length === 1) {\n            result = sel;\n            break;\n          }\n        }\n        if (!result) {\n          const tag = el.tagName ? el.tagName.toLowerCase() : '';\n          for (const cls of classes) {\n            const sel = `${tag}.${CSS.escape(cls)}`;\n            if (document.querySelectorAll(sel).length === 1) {\n              result = sel;\n              break;\n            }\n          }\n        }\n        if (!result) {\n          for (let i = 0; i < Math.min(classes.length, 3) && !result; i++) {\n            for (let j = i + 1; j < Math.min(classes.length, 3); j++) {\n              const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;\n              if (document.querySelectorAll(sel).length === 1) {\n                result = sel;\n                break;\n              }\n            }\n          }\n        }\n      } catch {}\n      __cacheUnique.set(el, result);\n      return result;\n    },\n\n    _generateSelector(el) {\n      if (!(el instanceof Element)) return '';\n      if (__cachePath.has(el)) return __cachePath.get(el);\n      if (el.id) {\n        const idSel = `#${CSS.escape(el.id)}`;\n        if (document.querySelectorAll(idSel).length === 1) return idSel;\n      }\n      for (const attr of ['data-testid', 'data-cy', 'name']) {\n        const attrValue = el.getAttribute(attr);\n        if (attrValue) {\n          const s = `[${attr}=\"${CSS.escape(attrValue)}\"]`;\n          if (document.querySelectorAll(s).length === 1) return s;\n        }\n      }\n      let path = '';\n      let current = el;\n      while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') {\n        let selector = current.tagName.toLowerCase();\n        const parent = current.parentElement;\n        if (parent) {\n          const siblings = Array.from(parent.children).filter(\n            (child) => child.tagName === current.tagName,\n          );\n          if (siblings.length > 1) {\n            const index = siblings.indexOf(current) + 1;\n            selector += `:nth-of-type(${index})`;\n          }\n        }\n        path = path ? `${selector} > ${path}` : selector;\n        current = parent;\n      }\n      const res = path ? `body > ${path}` : 'body';\n      __cachePath.set(el, res);\n      return res;\n    },\n  };\n  // Extend SelectorEngine with a shared ref helper (attached after declaration)\n  SelectorEngine._ensureGlobalRef = function (el) {\n    try {\n      if (!window.__claudeElementMap) window.__claudeElementMap = {};\n      if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n      for (const k in window.__claudeElementMap) {\n        const w = window.__claudeElementMap[k];\n        if (w && typeof w.deref === 'function' && w.deref() === el) return k;\n      }\n      const id = `ref_${++window.__claudeRefCounter}`;\n      window.__claudeElementMap[id] = new WeakRef(el);\n      return id;\n    } catch {\n      return null;\n    }\n  };\n\n  // ================================================================\n  // 2) UI CLASS (injected via constructor)\n  // ================================================================\n  class UI {\n    constructor(recorder) {\n      this.recorder = recorder;\n      this._box = null;\n      // Timeline elements state\n      this._timeline = null;\n      this._count = 0;\n      this._timelineBox = null;\n      this._collapsed = false;\n    }\n    ensure() {\n      const rec = this.recorder;\n      if (window !== window.top) return;\n      let root = document.getElementById('__rr_rec_overlay');\n      if (root) return;\n      root = document.createElement('div');\n      root.id = '__rr_rec_overlay';\n      Object.assign(root.style, {\n        position: 'fixed',\n        top: '10px',\n        right: '10px',\n        zIndex: 2147483646,\n        fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',\n      });\n      root.innerHTML = `\n        <div id=\"__rr_rec_panel\" style=\"background: rgba(220,38,38,0.95); color: #fff; padding:8px 10px; border-radius:8px; display:flex; align-items:center; gap:8px; box-shadow:0 4px 16px rgba(0,0,0,0.2);\">\n          <span id=\"__rr_badge\" style=\"font-weight:600;\">录制中</span>\n          <label style=\"display:inline-flex; align-items:center; gap:4px; font-size:12px;\">\n            <input id=\"__rr_hide_values\" type=\"checkbox\" style=\"vertical-align:middle;\" />隐藏输入值\n          </label>\n          <label style=\"display:inline-flex; align-items:center; gap:4px; font-size:12px;\">\n            <input id=\"__rr_enable_highlight\" type=\"checkbox\" style=\"vertical-align:middle;\" />高亮\n          </label>\n          <button id=\"__rr_toggle_timeline\" style=\"background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.5); border-radius:6px; padding:2px 6px; cursor:pointer; font-size:12px;\">折叠</button>\n          <button id=\"__rr_pause\" style=\"background:#fff; color:#111; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;\">暂停</button>\n          <button id=\"__rr_stop\" style=\"background:#111; color:#fff; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;\">停止</button>\n        </div>`;\n      document.documentElement.appendChild(root);\n      // Build timeline container just below the panel\n      const timeline = document.createElement('div');\n      timeline.id = '__rr_rec_timeline';\n      Object.assign(timeline.style, {\n        marginTop: '8px',\n        width: '360px',\n        maxHeight: '220px',\n        overflow: 'auto',\n        background: 'rgba(17,24,39,0.85)',\n        color: '#F9FAFB',\n        border: '1px solid rgba(255,255,255,0.2)',\n        borderRadius: '8px',\n        boxShadow: '0 4px 16px rgba(0,0,0,0.18)',\n        padding: '8px 10px',\n        fontSize: '12px',\n        lineHeight: '1.4',\n      });\n      const header = document.createElement('div');\n      header.textContent = '已录制步骤';\n      header.style.opacity = '0.8';\n      header.style.marginBottom = '4px';\n      const list = document.createElement('ol');\n      list.id = '__rr_rec_timeline_list';\n      list.style.listStyle = 'none';\n      list.style.margin = '0';\n      list.style.padding = '0';\n      list.style.display = 'flex';\n      list.style.flexDirection = 'column';\n      list.style.gap = '4px';\n      timeline.appendChild(header);\n      timeline.appendChild(list);\n      root.appendChild(timeline);\n      this._timeline = list;\n      this._timelineBox = timeline;\n      const btnPause = root.querySelector('#__rr_pause');\n      const btnStop = root.querySelector('#__rr_stop');\n      const hideChk = root.querySelector('#__rr_hide_values');\n      const highlightChk = root.querySelector('#__rr_enable_highlight');\n      const btnToggle = root.querySelector('#__rr_toggle_timeline');\n      hideChk.checked = !!rec.hideInputValues;\n      hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked));\n      highlightChk.checked = !!rec.highlightEnabled;\n      highlightChk.addEventListener('change', () => {\n        rec.highlightEnabled = !!highlightChk.checked;\n        rec._updateHoverListener();\n      });\n      if (btnToggle) {\n        btnToggle.addEventListener('click', () => {\n          this._collapsed = !this._collapsed;\n          if (this._timelineBox)\n            this._timelineBox.style.display = this._collapsed ? 'none' : 'block';\n          btnToggle.textContent = this._collapsed ? '展开' : '折叠';\n        });\n      }\n      btnPause.addEventListener('click', () => {\n        if (!rec.isPaused) rec.pause();\n        else rec.resume();\n      });\n      btnStop.addEventListener('click', () => {\n        chrome.runtime.sendMessage({ type: 'rr_stop_recording' });\n      });\n      this._box = document.createElement('div');\n      Object.assign(this._box.style, {\n        position: 'fixed',\n        border: '2px solid rgba(59,130,246,0.9)',\n        borderRadius: '4px',\n        background: 'rgba(59,130,246,0.15)',\n        pointerEvents: 'none',\n        zIndex: 2147483645,\n      });\n      document.documentElement.appendChild(this._box);\n      if (rec.highlightEnabled)\n        document.addEventListener('mousemove', rec._onMouseMove, { capture: true, passive: true });\n      this.updateStatus();\n    }\n    remove() {\n      if (window === window.top) {\n        const root = document.getElementById('__rr_rec_overlay');\n        if (root) root.remove();\n        if (this._box) this._box.remove();\n        this._timeline = null;\n        this._timelineBox = null;\n      }\n    }\n    updateStatus() {\n      const badge = document.getElementById('__rr_badge');\n      const pauseBtn = document.getElementById('__rr_pause');\n      if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中';\n      if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停';\n    }\n\n    // Reset the timeline list content\n    resetTimeline() {\n      this._count = 0;\n      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;\n      if (list) list.innerHTML = '';\n    }\n\n    // Append a new recorded step into the timeline UI\n    appendStep(step) {\n      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;\n      if (!list) return;\n      this._count += 1;\n      const item = document.createElement('li');\n      const text = this._formatStepText(step, this._count);\n      item.setAttribute('data-step-id', step.id || '');\n      item.style.display = 'flex';\n      item.style.alignItems = 'flex-start';\n      item.style.gap = '6px';\n      item.innerHTML = `\n        <span style=\"min-width:20px; text-align:right; opacity:0.8;\">${this._count}.</span>\n        <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;\">${text}</span>\n      `;\n      list.appendChild(item);\n      while (list.children.length > CONFIG.UI_MAX_STEPS) {\n        list.removeChild(list.firstChild);\n      }\n      const container = list.parentElement;\n      if (container) container.scrollTop = container.scrollHeight;\n    }\n\n    /**\n     * Apply a full timeline update from background.\n     * Steps can be upserted in place (same id, updated fields) during fill debouncing.\n     * Uses smart diffing to minimize DOM operations while ensuring fill values are accurate.\n     */\n    applyTimelineUpdate(steps) {\n      try {\n        if (window !== window.top) return;\n        const list = Array.isArray(steps) ? steps : [];\n        const total = list.length;\n        // Ensure UI exists\n        if (!this._timeline) this.ensure();\n        if (!this._timeline) return;\n        if (total === 0) {\n          this.resetTimeline();\n          return;\n        }\n\n        // Calculate the window of steps to display (last N steps)\n        const windowStart = Math.max(0, total - CONFIG.UI_MAX_STEPS);\n        const windowSteps = list.slice(windowStart);\n\n        // Get current displayed step IDs\n        const currentItems = this._timeline.children;\n        const currentIds = [];\n        for (let i = 0; i < currentItems.length; i++) {\n          currentIds.push(currentItems[i].getAttribute('data-step-id') || '');\n        }\n\n        // Check if we need a full rebuild or can do incremental update\n        const newIds = windowSteps.map((s) => s.id || '');\n        const needsRebuild =\n          currentIds.length !== newIds.length || currentIds.some((id, i) => id !== newIds[i]);\n\n        if (needsRebuild) {\n          // Full rebuild: either structure changed or it's simpler to rebuild\n          this.resetTimeline();\n          for (let i = 0; i < windowSteps.length; i++) {\n            this._appendStepWithIndex(windowSteps[i], windowStart + i + 1);\n          }\n        } else {\n          // Incremental update: same steps, just update values\n          for (let i = 0; i < windowSteps.length; i++) {\n            const step = windowSteps[i];\n            const item = currentItems[i];\n            if (item) {\n              // Update the text content for this step\n              const textSpan = item.querySelector('span:last-child');\n              if (textSpan) {\n                const newText = this._formatStepText(step, windowStart + i + 1);\n                if (textSpan.textContent !== newText) {\n                  textSpan.textContent = newText;\n                }\n              }\n            }\n          }\n        }\n        this._count = total;\n      } catch {}\n    }\n\n    /**\n     * Internal method to append a step with a specific display index.\n     * Used by applyTimelineUpdate for proper numbering.\n     */\n    _appendStepWithIndex(step, displayIndex) {\n      const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;\n      if (!list) return;\n      const item = document.createElement('li');\n      const text = this._formatStepText(step, displayIndex);\n      item.setAttribute('data-step-id', step.id || '');\n      item.style.display = 'flex';\n      item.style.alignItems = 'flex-start';\n      item.style.gap = '6px';\n      item.innerHTML = `\n        <span style=\"min-width:20px; text-align:right; opacity:0.8;\">${displayIndex}.</span>\n        <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;\">${text}</span>\n      `;\n      list.appendChild(item);\n      const container = list.parentElement;\n      if (container) container.scrollTop = container.scrollHeight;\n    }\n\n    // Create a short, human-readable text for a recorded step\n    _formatStepText(step, _idx) {\n      try {\n        if (!step || typeof step !== 'object') return '未知步骤';\n        const t = step.type;\n        const sel = step.target && step.target.selector ? step.target.selector : '';\n        if (t === 'click' || t === 'dblclick') {\n          return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`;\n        }\n        if (t === 'fill') {\n          const val = step.value;\n          const shown = typeof val === 'string' && val.length > 0 ? val : String(val);\n          return `输入: ${sel} = ${shown}`;\n        }\n        if (t === 'scroll') {\n          const mode = step.mode === 'container' ? '容器' : '页面';\n          const off = step.offset || {};\n          return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`;\n        }\n        if (t === 'openTab') return `打开标签页: ${step.url || ''}`;\n        if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`;\n        if (t === 'switchFrame')\n          return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`;\n        if (t === 'waitFor') return `等待: ${sel || step.until || ''}`;\n        return `${t}`;\n      } catch (_) {\n        return '步骤';\n      }\n    }\n  }\n\n  // ================================================================\n  // 3) MAIN CLASS: ContentRecorder (stateful)\n  // ================================================================\n  class ContentRecorder {\n    constructor() {\n      // State\n      this.isRecording = false;\n      this.isPaused = false;\n      this.hideInputValues = false;\n      this.highlightEnabled = true;\n      this.hoverRAF = 0;\n      this.frameSwitchPushed = false;\n      this.batch = [];\n      this.batchTimer = null;\n      this.scrollTimer = null;\n\n      // Local, content-side buffer for batching/merging steps during recording.\n      // Not the authoritative Flow (background holds the real one).\n      this.sessionBuffer = this._createSessionBuffer();\n      // lastFill tracks the most recent fill step for debounce/merge\n      // el: DOM element reference for reading final value on finalize\n      this.lastFill = { step: null, ts: 0, el: null };\n      // Input activity tracking for flush gate (separate from merge state)\n      // Updated by both local input and iframe upsert messages\n      this._lastInputActivityTs = 0;\n      // Flush gate: tracks when a typing burst started to enforce MAX_TYPING_HOLD_MS\n      this._typingBurstStartTs = 0;\n      // Force flush timer: ensures MAX_TYPING_HOLD_MS is a hard upper bound\n      // This timer is NOT reset on each input, only cleared on actual flush\n      this._forceFlushTimer = null;\n      // Recording-time element identity map (not persisted)\n      this.el2ref = new WeakMap();\n      this.refCounter = 0;\n\n      // Bind handlers\n      this._onClick = this._onClick.bind(this);\n      this._onInput = this._onInput.bind(this);\n      this._onDocInput = this._onDocInput.bind(this);\n      this._onChange = this._onChange.bind(this);\n      this._onMouseMove = this._onMouseMove.bind(this);\n      this._onScroll = this._onScroll.bind(this);\n      this._onFocusIn = this._onFocusIn.bind(this);\n      this._onFocusOut = this._onFocusOut.bind(this);\n      this._onKeyDown = this._onKeyDown.bind(this);\n      this._onKeyUp = this._onKeyUp.bind(this);\n      this._onWindowMessage = this._onWindowMessage.bind(this);\n      // Page lifecycle handlers for best-effort flush on navigation/close\n      this._onPageHide = this._onPageHide.bind(this);\n      this._onVisibilityChange = this._onVisibilityChange.bind(this);\n      this.ui = new UI(this);\n      this._scrollPending = null;\n\n      // Focus tracking for per-element input listening\n      this._focusedEl = null;\n      // Keyboard state for combo recording\n      this._pressed = new Set();\n      this._lastKeyTs = 0;\n      // Map to avoid duplicate switchFrame per iframe source (keyed by frame selector)\n      this._frameSwitchMap = new Set();\n    }\n\n    // Lifecycle\n    start(flowMeta) {\n      // Idempotent start: if already recording (and not paused), just ensure UI and listeners\n      if (this.isRecording && !this.isPaused) {\n        this.ui.ensure();\n        this._updateHoverListener();\n        return;\n      }\n      // If paused, treat start as resume to avoid resetting local buffer/UI timeline\n      if (this.isPaused) {\n        this.resume();\n        return;\n      }\n      this._reset(flowMeta || {});\n      this.isRecording = true;\n      this.isPaused = false;\n      this._attach();\n      this.ui.ensure();\n      this.ui.resetTimeline();\n    }\n\n    /**\n     * Stop recording and flush all pending data.\n     * This is the reliable stop that ensures no data is lost.\n     * Waits for background to acknowledge receipt of all data before returning.\n     * @returns {Promise<{ack: boolean, steps: number, variables: number}>}\n     */\n    async stop() {\n      if (!this.isRecording) {\n        return { ack: true, steps: 0, variables: 0 };\n      }\n\n      this.isRecording = false;\n      // Stop should clear paused state so detach fully cleans up (and barrier works consistently)\n      this.isPaused = false;\n\n      // Step 1: Finalize pending click (dblclick detector)\n      this._finalizePendingClick();\n\n      // Step 2: Finalize any pending input (draft mode)\n      this._finalizePendingInput();\n\n      // Step 3: Finalize any pending scroll\n      this._finalizePendingScroll();\n\n      // Step 4: In iframes, ensure the top-frame aggregator has processed our final postMessages\n      // before we ACK the background stop (prevents missing iframe steps)\n      let topSyncOk = true;\n      if (window !== window.top) {\n        topSyncOk = await this._syncStopBarrierToTop();\n      }\n\n      // Step 5: Clear timers BEFORE flush (prevent race conditions)\n      if (this.batchTimer) clearTimeout(this.batchTimer);\n      this.batchTimer = null;\n      if (this.scrollTimer) clearTimeout(this.scrollTimer);\n      this.scrollTimer = null;\n      if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);\n      this.hoverRAF = 0;\n\n      // Step 6: Flush any remaining batched steps and WAIT for ack\n      const stepsCount = this.batch.length;\n      let stepsAck = true;\n      if (stepsCount > 0) {\n        stepsAck = await this._flush();\n      }\n\n      // Step 7: Send all collected variables and WAIT for ack\n      const variablesCount = this.sessionBuffer.variables?.length || 0;\n      let variablesAck = true;\n      if (variablesCount > 0) {\n        variablesAck = await this._sendVariables();\n      }\n\n      // Step 8: Detach listeners and clean up UI\n      this._detach();\n      this.ui.remove();\n\n      // Step 9: Reset state\n      this.lastFill = { step: null, ts: 0, el: null };\n      this._lastInputActivityTs = 0;\n      this._typingBurstStartTs = 0;\n      if (this._forceFlushTimer) {\n        clearTimeout(this._forceFlushTimer);\n        this._forceFlushTimer = null;\n      }\n      this.sessionBuffer.steps = [];\n\n      // Return acknowledgment with stats\n      // ack is true only if all sends were acknowledged\n      return {\n        ack: stepsAck && variablesAck && topSyncOk,\n        steps: stepsCount,\n        variables: variablesCount,\n      };\n    }\n\n    /**\n     * Finalize a pending click that hasn't been emitted yet.\n     * The dblclick detector holds single clicks temporarily to detect double-clicks.\n     * This ensures stop/pause flush includes the last single click.\n     */\n    _finalizePendingClick() {\n      try {\n        if (this._pendingClickTimer) clearTimeout(this._pendingClickTimer);\n      } catch {}\n      this._pendingClickTimer = null;\n\n      try {\n        if (this._pendingClick) this._pushStep(this._pendingClick);\n      } catch {}\n      this._pendingClick = null;\n    }\n\n    /**\n     * Finalize any pending input that hasn't been flushed yet.\n     * This ensures the last input value is captured before stop/pause/navigation.\n     * Uses lastFill.el (DOM reference) to read the current value.\n     */\n    _finalizePendingInput() {\n      const last = this.lastFill;\n      if (!last || !last.step) return;\n\n      // Commit the latest value from the DOM element\n      try {\n        const el = last.el;\n        if (el) {\n          const freshValue = this._getElementValue(el, last.step.value);\n          if (freshValue !== last.step.value) {\n            last.step.value = freshValue;\n            this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n          }\n        }\n      } catch {\n        // Element may no longer exist, that's OK - we keep the last known value\n      }\n\n      // Enqueue for upsert to ensure background gets the final value\n      try {\n        this._enqueueForUpsert(last.step);\n      } catch {}\n\n      // Reset state\n      this.lastFill = { step: null, ts: 0, el: null };\n      this._typingBurstStartTs = 0;\n    }\n\n    /**\n     * Get the current value from an element, handling sensitive fields and contenteditable.\n     * @param {Element} el - The element to read from\n     * @param {string} existingValue - The existing recorded value (may be a variable placeholder)\n     * @returns {string} The value to record\n     */\n    _getElementValue(el, existingValue) {\n      if (!el) return existingValue || '';\n\n      const isContentEditable =\n        el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;\n\n      // If existing value is already a variable placeholder, preserve it\n      // Use strict pattern to avoid false positives for user input like \"{abc}\"\n      const existing = typeof existingValue === 'string' ? existingValue : '';\n      const varPlaceholderPattern =\n        /^\\{(?:var_[a-z0-9]{4}|file_[a-z0-9]{4}|[a-zA-Z_][a-zA-Z0-9_]*)\\}$/;\n      if (varPlaceholderPattern.test(existing)) {\n        return existing;\n      }\n\n      // Check if this is a sensitive field\n      const isSensitive =\n        this.hideInputValues ||\n        (!isContentEditable &&\n          CONFIG.SENSITIVE_INPUT_TYPES.has(\n            ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),\n          ));\n\n      if (isSensitive) {\n        // Return existing variable or create new one (should already exist from initial capture)\n        return existing;\n      }\n\n      // Read fresh value from DOM\n      try {\n        if (isContentEditable) {\n          return /** @type {HTMLElement} */ (el).innerText || '';\n        }\n        if (\n          el instanceof HTMLInputElement ||\n          el instanceof HTMLTextAreaElement ||\n          el instanceof HTMLSelectElement\n        ) {\n          return el.value || '';\n        }\n      } catch {}\n\n      return existing || '';\n    }\n\n    /**\n     * Finalize any pending scroll that hasn't been committed yet.\n     * Converts the pending scroll data into a proper scroll step.\n     */\n    _finalizePendingScroll() {\n      if (!this._scrollPending) return;\n\n      const pending = this._scrollPending;\n      this._scrollPending = null;\n\n      const { isDoc, target, top, left } = pending;\n\n      // Try merge with last step (same logic as _onScroll timer callback)\n      const steps = this.sessionBuffer.steps;\n      const last = steps.length ? steps[steps.length - 1] : null;\n      if (last && last.type === 'scroll') {\n        const sameDoc = isDoc && !last.target && last.mode === 'offset';\n        const sameEl =\n          !isDoc &&\n          last.target &&\n          last.target.selector &&\n          target &&\n          last.target.selector === target.selector &&\n          last.mode === 'container';\n        if (sameDoc || sameEl) {\n          last.offset = { y: top, x: left };\n          this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n          return;\n        }\n      }\n\n      // Create new scroll step\n      if (isDoc) {\n        this._pushStep({\n          type: 'scroll',\n          mode: 'offset',\n          offset: { y: top, x: left },\n          screenshotOnFail: false,\n        });\n      } else {\n        this._pushStep({\n          type: 'scroll',\n          mode: 'container',\n          target: target,\n          offset: { y: top, x: left },\n          screenshotOnFail: false,\n        });\n      }\n    }\n\n    /**\n     * Send all collected variables to background.\n     * @returns {Promise<boolean>} - Resolves when background acknowledges receipt\n     */\n    async _sendVariables() {\n      if (!this.sessionBuffer.variables || this.sessionBuffer.variables.length === 0) {\n        return true;\n      }\n      return this._send({ kind: 'variables', variables: this.sessionBuffer.variables });\n    }\n\n    /**\n     * Pause recording. Flushes pending data before pausing.\n     */\n    pause() {\n      if (!this.isRecording || this.isPaused) return;\n\n      // Finalize pending data before pausing\n      this._finalizePendingClick();\n      this._finalizePendingInput();\n      this._finalizePendingScroll();\n\n      // Flush batched steps\n      if (this.batch.length > 0) {\n        this._flush();\n      }\n\n      // Clear timers\n      if (this.batchTimer) clearTimeout(this.batchTimer);\n      this.batchTimer = null;\n      if (this.scrollTimer) clearTimeout(this.scrollTimer);\n      this.scrollTimer = null;\n\n      this.isPaused = true;\n      this._detach();\n      this.ui.updateStatus();\n    }\n\n    /**\n     * Resume recording after pause.\n     */\n    resume() {\n      if (!this.isPaused) return;\n\n      this.isRecording = true;\n      this.isPaused = false;\n      this._attach();\n      this.ui.ensure();\n      this.ui.updateStatus();\n    }\n\n    // DOM listeners\n    _attach() {\n      document.addEventListener('click', this._onClick, true);\n      // Use focusin/out to attach input listener only to focused element\n      document.addEventListener('focusin', this._onFocusIn, true);\n      document.addEventListener('focusout', this._onFocusOut, true);\n      // Document-level input capture to support Shadow DOM (custom elements)\n      // Use capture phase + composedPath to find inner editable control\n      document.addEventListener('input', this._onDocInput, true);\n      document.addEventListener('change', this._onChange, true);\n      // capture-phase scroll to catch non-bubbling events on any container (passive to avoid jank)\n      document.addEventListener('scroll', this._onScroll, { capture: true, passive: true });\n      // Keyboard: record Enter and modifier combos\n      document.addEventListener('keydown', this._onKeyDown, true);\n      document.addEventListener('keyup', this._onKeyUp, true);\n      // Page lifecycle: best-effort flush on navigation/close\n      window.addEventListener('pagehide', this._onPageHide, true);\n      document.addEventListener('visibilitychange', this._onVisibilityChange, true);\n      // Cross-frame: top window aggregates iframe-recorded steps\n      if (window === window.top) window.addEventListener('message', this._onWindowMessage, true);\n      this._updateHoverListener();\n    }\n\n    _detach() {\n      document.removeEventListener('click', this._onClick, true);\n      document.removeEventListener('focusin', this._onFocusIn, true);\n      document.removeEventListener('focusout', this._onFocusOut, true);\n      document.removeEventListener('input', this._onDocInput, true);\n      document.removeEventListener('change', this._onChange, true);\n      document.removeEventListener('scroll', this._onScroll, { capture: true });\n      document.removeEventListener('keydown', this._onKeyDown, true);\n      document.removeEventListener('keyup', this._onKeyUp, true);\n      window.removeEventListener('pagehide', this._onPageHide, true);\n      document.removeEventListener('visibilitychange', this._onVisibilityChange, true);\n      document.removeEventListener('mousemove', this._onMouseMove, { capture: true });\n      // Keep top-frame aggregator alive during pause; stop() clears isPaused and will remove it\n      if (window === window.top && !this.isPaused)\n        window.removeEventListener('message', this._onWindowMessage, true);\n      // Detach per-element input listener if any\n      if (this._focusedEl) this._focusedEl.removeEventListener('input', this._onInput, true);\n      this._focusedEl = null;\n      // Best-effort cleanup for timers/raf when detaching\n      if (this.batchTimer) clearTimeout(this.batchTimer);\n      this.batchTimer = null;\n      if (this.scrollTimer) clearTimeout(this.scrollTimer);\n      this.scrollTimer = null;\n      if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);\n      this.hoverRAF = 0;\n      // Clear pending click state (stop/pause flush it before detach)\n      if (this._pendingClickTimer) {\n        clearTimeout(this._pendingClickTimer);\n      }\n      this._pendingClickTimer = null;\n      this._pendingClick = null;\n    }\n\n    _updateHoverListener() {\n      if (window !== window.top) return;\n      document.removeEventListener('mousemove', this._onMouseMove, { capture: true });\n      if (this.isRecording && !this.isPaused && this.highlightEnabled) {\n        document.addEventListener('mousemove', this._onMouseMove, { capture: true, passive: true });\n      }\n    }\n\n    // Flow helpers (content-side buffer only)\n    _createSessionBuffer() {\n      const nowIso = new Date().toISOString();\n      return {\n        id: `flow_${Date.now()}`,\n        name: '未命名录制',\n        version: 1,\n        steps: [],\n        variables: [],\n        meta: { createdAt: nowIso, updatedAt: nowIso },\n      };\n    }\n\n    _reset(meta) {\n      this.sessionBuffer = this._createSessionBuffer();\n      try {\n        if (meta && typeof meta === 'object') {\n          if (meta.id) this.sessionBuffer.id = String(meta.id);\n          if (meta.name) this.sessionBuffer.name = String(meta.name);\n          if (meta.description) this.sessionBuffer.description = String(meta.description);\n        }\n      } catch {}\n      this.lastFill = { step: null, ts: 0, el: null };\n      this._lastInputActivityTs = 0;\n      this._typingBurstStartTs = 0;\n      if (this._forceFlushTimer) {\n        clearTimeout(this._forceFlushTimer);\n        this._forceFlushTimer = null;\n      }\n      this.frameSwitchPushed = false;\n    }\n\n    /**\n     * Update input activity timestamp (used for flush gate).\n     * Called on local input and iframe upsert messages.\n     */\n    _updateInputActivity() {\n      const now = Date.now();\n      const prevActivityTs = this._lastInputActivityTs || 0;\n      this._lastInputActivityTs = now;\n      // Start a new burst if previous one expired (or this is first input)\n      if (!this._typingBurstStartTs || now - prevActivityTs > CONFIG.INPUT_DEBOUNCE_MS) {\n        this._typingBurstStartTs = now;\n        // Start force flush timer (hard upper bound for MAX_TYPING_HOLD_MS)\n        this._startForceFlushTimer();\n      }\n    }\n\n    /**\n     * Start the force flush timer.\n     * This timer ensures MAX_TYPING_HOLD_MS is a hard upper bound.\n     * Unlike batchTimer, this timer is NOT reset on each input.\n     */\n    _startForceFlushTimer() {\n      // Don't restart if already running\n      if (this._forceFlushTimer) return;\n      this._forceFlushTimer = setTimeout(() => {\n        this._forceFlushTimer = null;\n        // Force flush regardless of current input state\n        if (this.batch.length > 0) {\n          this._flush();\n        }\n      }, CONFIG.MAX_TYPING_HOLD_MS);\n    }\n\n    /**\n     * Clear the force flush timer (called on actual flush).\n     */\n    _clearForceFlushTimer() {\n      if (this._forceFlushTimer) {\n        clearTimeout(this._forceFlushTimer);\n        this._forceFlushTimer = null;\n      }\n      this._typingBurstStartTs = 0;\n    }\n\n    /**\n     * Unified commit and flush logic.\n     * Called at commit points: focusout, Enter key, pagehide, visibilitychange.\n     * @param {Object} options\n     * @param {boolean} [options.bestEffort=false] - If true, don't await (for unload events)\n     */\n    _commitAndFlush(options = {}) {\n      if (!this.isRecording || this.isPaused) return;\n\n      try {\n        this._finalizePendingInput();\n        this._finalizePendingScroll();\n      } catch {}\n\n      // Reset flush gate to allow immediate flush\n      this._lastInputActivityTs = 0;\n      this._typingBurstStartTs = 0;\n      this._clearForceFlushTimer();\n\n      // Flush (best-effort for unload events)\n      try {\n        if (this.batch.length > 0) this._flush();\n      } catch {}\n      try {\n        const variablesCount = this.sessionBuffer.variables?.length || 0;\n        if (variablesCount > 0) this._sendVariables();\n      } catch {}\n\n      // If in iframe, ask top to flush too\n      this._requestTopFlush();\n    }\n\n    _pushStep(step) {\n      step.id = step.id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;\n      // In iframes, forward to top for aggregation (compute frame selector there)\n      if (window !== window.top) {\n        try {\n          const payload = {\n            kind: 'iframeStep',\n            href: String(location && location.href ? location.href : ''),\n            step,\n          };\n          window.top.postMessage({ type: FRAME_EVENT, payload }, '*');\n          return; // Do not push locally in subframe\n        } catch {}\n      }\n      // Top window: optionally insert a switchFrame if this step originated from an iframe message\n      this.sessionBuffer.steps.push(step);\n      this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n      this.batch.push(step);\n\n      // Track input activity for fill steps (to enforce flush gate)\n      if (step && step.type === 'fill') {\n        this._updateInputActivity();\n      }\n\n      this._scheduleFlush();\n    }\n\n    /**\n     * Calculate the appropriate flush delay based on typing activity.\n     * During active typing, delay flush to avoid sending incomplete values.\n     * Note: MAX_TYPING_HOLD_MS is enforced by _forceFlushTimer, not here.\n     * @returns {number} Delay in milliseconds before next flush\n     */\n    _getFlushDelayMs() {\n      const now = Date.now();\n      const lastInputTs = this._lastInputActivityTs || 0;\n\n      // If no recent input activity, use default batch delay\n      if (!lastInputTs || now - lastInputTs >= CONFIG.INPUT_DEBOUNCE_MS) {\n        return CONFIG.BATCH_SEND_MS;\n      }\n\n      // Wait for input debounce to complete\n      const notBefore = lastInputTs + CONFIG.INPUT_DEBOUNCE_MS;\n      const delay = Math.max(CONFIG.BATCH_SEND_MS, notBefore - now);\n\n      return delay;\n    }\n\n    /**\n     * Schedule a batch flush with appropriate delay.\n     * Respects typing gate to avoid flushing incomplete fill values.\n     */\n    _scheduleFlush() {\n      if (this.batchTimer) {\n        clearTimeout(this.batchTimer);\n      }\n      const delay = this._getFlushDelayMs();\n      this.batchTimer = setTimeout(() => {\n        this.batchTimer = null;\n        this._flush();\n      }, delay);\n    }\n\n    /**\n     * Request top frame to immediately flush its aggregated buffer.\n     * Used by iframes on commit points (focusout, navigation) to ensure\n     * their updates are sent to background promptly.\n     */\n    _requestTopFlush() {\n      if (window === window.top) return;\n      try {\n        const payload = {\n          kind: 'iframeFlush',\n          href: String(location && location.href ? location.href : ''),\n        };\n        window.top.postMessage({ type: FRAME_EVENT, payload }, '*');\n      } catch {}\n    }\n\n    /**\n     * Iframe -> top stop barrier sync.\n     * Ensures the top frame has processed all prior iframe postMessages (steps/upserts)\n     * before this iframe responds to background STOP.\n     * @returns {Promise<boolean>}\n     */\n    _syncStopBarrierToTop() {\n      if (window === window.top) return Promise.resolve(true);\n      const id = `sb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;\n      const href = String(location && location.href ? location.href : '');\n      const timeoutMs = 400;\n\n      return new Promise((resolve) => {\n        let done = false;\n        const cleanup = (ok) => {\n          if (done) return;\n          done = true;\n          try {\n            window.removeEventListener('message', onMessage, true);\n          } catch {}\n          try {\n            clearTimeout(t);\n          } catch {}\n          resolve(!!ok);\n        };\n\n        const onMessage = (ev) => {\n          try {\n            if (ev.source !== window.top) return;\n            const d = ev && ev.data;\n            if (!d || d.type !== FRAME_EVENT || !d.payload) return;\n            const p = d.payload || {};\n            if (p.kind !== 'iframeStopBarrierAck' || p.id !== id) return;\n            cleanup(true);\n          } catch {}\n        };\n\n        const t = setTimeout(() => cleanup(false), timeoutMs);\n        try {\n          window.addEventListener('message', onMessage, true);\n          window.top.postMessage(\n            { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrier', id, href } },\n            '*',\n          );\n        } catch {\n          cleanup(false);\n        }\n      });\n    }\n\n    /**\n     * Best-effort drain and flush on page navigation/close.\n     * Called by pagehide/visibilitychange handlers.\n     * Does not await - unload events are time-constrained.\n     */\n    _bestEffortDrainAndFlush() {\n      if (!this.isRecording || this.isPaused) return;\n\n      // Flush pending single click (dblclick detector) before we may be unloaded\n      this._finalizePendingClick();\n\n      // Cancel timers (unload may not wait for them)\n      try {\n        if (this.batchTimer) clearTimeout(this.batchTimer);\n        this.batchTimer = null;\n        if (this.scrollTimer) clearTimeout(this.scrollTimer);\n        this.scrollTimer = null;\n      } catch {}\n\n      // Use unified commit and flush\n      this._commitAndFlush({ bestEffort: true });\n    }\n\n    /**\n     * Handle pagehide event - best-effort flush before navigation/close.\n     */\n    _onPageHide() {\n      this._bestEffortDrainAndFlush();\n    }\n\n    /**\n     * Handle visibilitychange event - flush when page becomes hidden.\n     * This catches some cases that pagehide misses (e.g., tab switch before navigation).\n     */\n    _onVisibilityChange() {\n      try {\n        if (document.visibilityState === 'hidden') {\n          this._bestEffortDrainAndFlush();\n        }\n      } catch {}\n    }\n\n    /**\n     * Flush batched steps to background.\n     * @returns {Promise<boolean>} - Resolves when background acknowledges receipt\n     */\n    async _flush() {\n      if (!this.batch.length) return true;\n\n      // Clear force flush timer since we're flushing now\n      this._clearForceFlushTimer();\n\n      const steps = this.batch.map((s) => {\n        // sanitize internal fields before sending to background\n        const { _recordingRef, ...rest } = s || {};\n        return rest;\n      });\n      this.batch.length = 0;\n      return this._send({ kind: 'steps', steps });\n    }\n\n    /**\n     * Send payload to background and wait for acknowledgment.\n     * @param {Object} payload - The payload to send\n     * @returns {Promise<boolean>} - Resolves true if background acknowledged, false otherwise\n     */\n    _send(payload) {\n      return new Promise((resolve) => {\n        try {\n          chrome.runtime.sendMessage({ type: 'rr_recorder_event', payload }, (response) => {\n            // Check for runtime error (e.g., no receiver)\n            if (chrome.runtime.lastError) {\n              console.warn('Recorder: send failed', chrome.runtime.lastError.message);\n              resolve(false);\n              return;\n            }\n            resolve(response && response.ok);\n          });\n        } catch (e) {\n          console.warn('Recorder: send exception', e);\n          resolve(false);\n        }\n      });\n    }\n\n    _addVariable(key, sensitive, defVal) {\n      if (!this.sessionBuffer.variables) this.sessionBuffer.variables = [];\n      if (this.sessionBuffer.variables.find((v) => v.key === key)) return;\n      this.sessionBuffer.variables.push({ key, sensitive: !!sensitive, default: defVal || '' });\n    }\n\n    // Handlers\n    // Pending click state for dblclick detection\n    _pendingClick = null;\n    _pendingClickTimer = null;\n    _DBLCLICK_THRESHOLD_MS = 300;\n\n    _onClick(e) {\n      if (!this.isRecording || this.isPaused) return;\n      const el = e.target instanceof Element ? e.target : null;\n      if (!el) return;\n      try {\n        if (el instanceof HTMLInputElement) {\n          const t = (el.getAttribute && el.getAttribute('type')) || '';\n          const tt = String(t).toLowerCase();\n          if (tt === 'checkbox' || tt === 'radio') return; // avoid duplicate with change\n        }\n        const overlay = document.getElementById('__rr_rec_overlay');\n        if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return;\n        const a = el.closest && el.closest('a[href]');\n        const href = a && a.getAttribute && a.getAttribute('href');\n        const tgt = a && a.getAttribute && a.getAttribute('target');\n        if (a && href && tgt && tgt.toLowerCase() === '_blank') {\n          try {\n            const abs = new URL(href, location.href).href;\n            this._pushStep({ type: 'openTab', url: abs });\n            this._pushStep({ type: 'switchTab', urlContains: abs });\n            return;\n          } catch (_) {\n            this._pushStep({ type: 'openTab', url: href });\n            this._pushStep({ type: 'switchTab', urlContains: href });\n            return;\n          }\n        }\n      } catch {}\n\n      const target = SelectorEngine.buildTarget(el);\n      try {\n        const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);\n        if (gref) target.ref = gref;\n      } catch {}\n\n      // Double-click detection: if e.detail >= 2 means this is the second click of a dblclick\n      if (e.detail >= 2) {\n        // Cancel pending single click and record dblclick instead\n        if (this._pendingClickTimer) {\n          clearTimeout(this._pendingClickTimer);\n          this._pendingClickTimer = null;\n        }\n        this._pendingClick = null;\n        this._pushStep({\n          type: 'dblclick',\n          target,\n          screenshotOnFail: true,\n        });\n        return;\n      }\n\n      // Single click: wait briefly to see if it becomes a dblclick\n      // Cancel any previous pending click first\n      if (this._pendingClickTimer) {\n        clearTimeout(this._pendingClickTimer);\n        // Flush previous pending click before starting new one\n        if (this._pendingClick) {\n          this._pushStep(this._pendingClick);\n        }\n      }\n\n      this._pendingClick = {\n        type: 'click',\n        target,\n        screenshotOnFail: true,\n      };\n\n      this._pendingClickTimer = setTimeout(() => {\n        if (this._pendingClick) {\n          this._pushStep(this._pendingClick);\n          this._pendingClick = null;\n        }\n        this._pendingClickTimer = null;\n      }, this._DBLCLICK_THRESHOLD_MS);\n    }\n\n    // Per-element input handler (attached on focusin for native inputs/textarea/contenteditable)\n    _onInput(e) {\n      if (!this.isRecording || this.isPaused) return;\n      // Avoid mid-composition spam (IME): handle final committed value\n      try {\n        if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;\n      } catch {}\n      const target = e.target;\n      // Support input/textarea and contenteditable elements\n      const el =\n        target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement\n          ? target\n          : target &&\n              target.nodeType === 1 &&\n              /** @type {HTMLElement} */ (target).isContentEditable === true\n            ? /** @type {HTMLElement} */ (target)\n            : null;\n      if (!el) return;\n      this._handleInputForElement(el);\n    }\n\n    // Document-level input handler: supports composed events from Shadow DOM (custom elements)\n    _onDocInput(e) {\n      if (!this.isRecording || this.isPaused) return;\n      try {\n        if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;\n      } catch {}\n      // Avoid double handling when per-element listener already attached to same element\n      if (this._focusedEl && e.target === this._focusedEl) return;\n      // Find the innermost editable element from composedPath\n      const path = typeof e.composedPath === 'function' ? e.composedPath() : [];\n      let el = null;\n      for (let i = 0; i < path.length; i++) {\n        const n = path[i];\n        if (n instanceof HTMLInputElement || n instanceof HTMLTextAreaElement) {\n          el = n;\n          break;\n        }\n        // Also check for contenteditable\n        if (n && n.nodeType === 1 && /** @type {HTMLElement} */ (n).isContentEditable === true) {\n          el = /** @type {HTMLElement} */ (n);\n          break;\n        }\n      }\n      // As a fallback, walk down activeElement chain (deep active element via shadow roots)\n      if (!el) {\n        try {\n          let ae = document.activeElement;\n          let guard = 0;\n          while (ae && guard++ < 10) {\n            if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement) {\n              el = ae;\n              break;\n            }\n            // Check contenteditable in shadow DOM traversal\n            if (\n              ae &&\n              ae.nodeType === 1 &&\n              /** @type {HTMLElement} */ (ae).isContentEditable === true\n            ) {\n              el = /** @type {HTMLElement} */ (ae);\n              break;\n            }\n            const anyAe = ae;\n            if (anyAe && anyAe.shadowRoot && anyAe.shadowRoot.activeElement) {\n              ae = anyAe.shadowRoot.activeElement;\n              continue;\n            }\n            break;\n          }\n        } catch {}\n      }\n      if (!el) return;\n      this._handleInputForElement(el);\n    }\n\n    // Shared input processing logic (debounce/merge/sensitivity)\n    // Uses Draft/Upsert model: updates are re-enqueued to ensure background gets final value\n    _handleInputForElement(el) {\n      try {\n        const t = (el.getAttribute && el.getAttribute('type')) || '';\n        const tt = String(t).toLowerCase();\n        if (tt === 'checkbox' || tt === 'radio' || tt === 'file') return;\n      } catch {}\n      const elRef = this._getElRef(el);\n      const target = SelectorEngine.buildTarget(el);\n\n      // Check if element is contenteditable\n      const isContentEditable =\n        el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;\n\n      const isSensitive =\n        this.hideInputValues ||\n        (!isContentEditable &&\n          CONFIG.SENSITIVE_INPUT_TYPES.has(\n            ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),\n          ));\n\n      // Get value: use .value for input/textarea, .innerText for contenteditable\n      let value = isContentEditable\n        ? /** @type {HTMLElement} */ (el).innerText || ''\n        : el.value || '';\n      if (isSensitive) {\n        const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`;\n        this._addVariable(varKey, true, '');\n        value = `{${varKey}}`;\n      }\n      const nowTs = Date.now();\n      const last = this.lastFill.step;\n      const sameRef = !!(last && last._recordingRef === elRef);\n      const sameSelector = !!(\n        last &&\n        last.target &&\n        last.target.selector &&\n        target &&\n        target.selector &&\n        last.target.selector === target.selector\n      );\n      const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;\n      if ((sameRef || sameSelector) && within) {\n        // Update existing step's value\n        this.lastFill.step.value = value;\n        this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n        this.lastFill.ts = nowTs;\n        this.lastFill.el = el; // Keep DOM reference updated for finalize\n        // Keep flush gate aligned to the latest keystroke\n        this._updateInputActivity();\n        // Re-enqueue the updated step for upsert (ensures background gets final value)\n        this._enqueueForUpsert(this.lastFill.step);\n        return;\n      }\n      const newStep = { type: 'fill', target, value, screenshotOnFail: true };\n      newStep._recordingRef = elRef;\n      this._pushStep(newStep);\n      this.lastFill = { step: newStep, ts: nowTs, el: el };\n    }\n\n    /**\n     * Enqueue a step for upsert - if step with same id exists in batch, update it.\n     * This ensures the background receives the final value for fill steps.\n     * In iframes, forwards to top window to maintain selector composition consistency.\n     */\n    _enqueueForUpsert(step) {\n      if (!step || !step.id) return;\n\n      // In iframes, forward upsert updates to top so we don't lose composed selectors.\n      // The top window aggregates iframe steps and computes \"frame |> inner\" selectors.\n      // If iframe sends directly to background, it would overwrite the composed selector.\n      if (window !== window.top) {\n        try {\n          const payload = {\n            kind: 'iframeStepUpsert',\n            href: String(location && location.href ? location.href : ''),\n            step,\n          };\n          window.top.postMessage({ type: FRAME_EVENT, payload }, '*');\n        } catch {}\n        return;\n      }\n\n      // Check if step already in batch\n      const existingIdx = this.batch.findIndex((s) => s.id === step.id);\n      if (existingIdx >= 0) {\n        // Update existing entry in batch\n        this.batch[existingIdx] = step;\n      } else {\n        // Add to batch (step was already flushed, so we need to send update)\n        this.batch.push(step);\n      }\n\n      // Schedule flush with appropriate delay (respects typing gate)\n      this._scheduleFlush();\n    }\n\n    _onChange(e) {\n      if (!this.isRecording || this.isPaused) return;\n      const el = e.target;\n      if (el instanceof HTMLSelectElement) {\n        const val = el.value;\n        const nowTs = Date.now();\n        const elRef = this._getElRef(el);\n        const sameRef = !!(this.lastFill.step && this.lastFill.step._recordingRef === elRef);\n        const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;\n        if (sameRef && within) {\n          this.lastFill.step.value = val;\n          this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n          this.lastFill.ts = nowTs;\n          this.lastFill.el = el; // Keep DOM reference updated\n          // Re-enqueue for upsert\n          this._enqueueForUpsert(this.lastFill.step);\n          return;\n        }\n        const target = SelectorEngine.buildTarget(el);\n        try {\n          const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);\n          if (gref) target.ref = gref;\n        } catch {}\n        const st = { type: 'fill', target, value: val, screenshotOnFail: true };\n        st._recordingRef = elRef;\n        this._pushStep(st);\n        this.lastFill = { step: st, ts: nowTs, el: el };\n        return;\n      }\n      if (el instanceof HTMLInputElement) {\n        const t = (el.getAttribute && el.getAttribute('type')) || '';\n        const tt = String(t).toLowerCase();\n        const target = SelectorEngine.buildTarget(el);\n        try {\n          const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);\n          if (gref) target.ref = gref;\n        } catch {}\n        const elRef = this._getElRef(el);\n        if (tt === 'checkbox') {\n          const st = { type: 'fill', target, value: !!el.checked, screenshotOnFail: true };\n          st._recordingRef = elRef;\n          this._pushStep(st);\n          return;\n        }\n        if (tt === 'radio') {\n          const st = { type: 'fill', target, value: true, screenshotOnFail: true };\n          st._recordingRef = elRef;\n          this._pushStep(st);\n          return;\n        }\n        if (tt === 'file') {\n          const varKey = el.name ? el.name : `file_${Math.random().toString(36).slice(2, 6)}`;\n          this._addVariable(varKey, false, '');\n          this._pushStep({ type: 'fill', target, value: `{${varKey}}`, screenshotOnFail: true });\n          return;\n        }\n      }\n    }\n\n    _getElRef(el) {\n      try {\n        let ref = this.el2ref.get(el);\n        if (ref) return ref;\n        ref = `ref_${++this.refCounter}`;\n        this.el2ref.set(el, ref);\n        return ref;\n      } catch {\n        // Fallback to timestamp-based ref if WeakMap fails (should not happen)\n        return `ref_${Date.now()}`;\n      }\n    }\n\n    // UI handled by injected UI class\n\n    _onFocusIn(e) {\n      if (!this.isRecording || this.isPaused) return;\n      const el = e.target;\n      const isEditable =\n        el instanceof HTMLInputElement ||\n        el instanceof HTMLTextAreaElement ||\n        (el && el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true);\n      if (!isEditable) return;\n      if (this._focusedEl && this._focusedEl !== el)\n        this._focusedEl.removeEventListener('input', this._onInput, true);\n      el.addEventListener('input', this._onInput, true);\n      this._focusedEl = el;\n    }\n\n    _onFocusOut(e) {\n      const el = e.target;\n      if (!el) return;\n      if (this._focusedEl === el) {\n        // Commit point: leaving an input field - finalize and flush pending input\n        // This ensures we don't lose values when user tabs away or clicks elsewhere\n        this._commitAndFlush();\n        el.removeEventListener('input', this._onInput, true);\n        this._focusedEl = null;\n      }\n    }\n\n    _onMouseMove(e) {\n      if (!this.highlightEnabled || !this.ui._box || !this.isRecording || this.isPaused) return;\n      if (this.hoverRAF) return;\n      const el = e.target instanceof Element ? e.target : null;\n      if (!el) return;\n      this.hoverRAF = requestAnimationFrame(() => {\n        try {\n          const r = el.getBoundingClientRect();\n          Object.assign(this.ui._box.style, {\n            left: `${Math.round(r.left)}px`,\n            top: `${Math.round(r.top)}px`,\n            width: `${Math.round(Math.max(0, r.width))}px`,\n            height: `${Math.round(Math.max(0, r.height))}px`,\n            display: r.width > 0 && r.height > 0 ? 'block' : 'none',\n          });\n        } catch {}\n        this.hoverRAF = 0;\n      });\n    }\n\n    _onScroll(e) {\n      if (!this.isRecording || this.isPaused) return;\n      try {\n        const overlay = document.getElementById('__rr_rec_overlay');\n        if (overlay) {\n          // Use composedPath for shadow DOM compatibility, fallback to target\n          const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];\n          for (const element of path) {\n            // If the event path contains our overlay, ignore this scroll event\n            if (element === overlay) {\n              return;\n            }\n          }\n        }\n      } catch {\n        // ignore\n      }\n      // Determine scroll source and positions\n      const isDoc = e.target === document;\n      const el = isDoc ? document.documentElement : e.target instanceof Element ? e.target : null;\n      if (!el) return;\n      let top = 0,\n        left = 0;\n      try {\n        if (isDoc) {\n          top =\n            typeof window.scrollY === 'number'\n              ? window.scrollY\n              : document.documentElement.scrollTop || 0;\n          left =\n            typeof window.scrollX === 'number'\n              ? window.scrollX\n              : document.documentElement.scrollLeft || 0;\n        } else {\n          top = el.scrollTop || 0;\n          left = el.scrollLeft || 0;\n        }\n      } catch {}\n      const target = isDoc ? null : SelectorEngine.buildTarget(el);\n      // Debounce/coalesce\n      this._scrollPending = { isDoc, target, top, left };\n      if (this.scrollTimer) {\n        clearTimeout(this.scrollTimer);\n      }\n      this.scrollTimer = setTimeout(() => {\n        this.scrollTimer = null;\n        const pending = this._scrollPending;\n        this._scrollPending = null;\n        if (!pending) return;\n        const { isDoc: pDoc, target: pTarget, top: pTop, left: pLeft } = pending;\n        // Try merge with last step\n        const steps = this.sessionBuffer.steps;\n        const last = steps.length ? steps[steps.length - 1] : null;\n        if (last && last.type === 'scroll') {\n          const sameDoc = pDoc && !last.target && last.mode === 'offset';\n          const sameEl =\n            !pDoc &&\n            last.target &&\n            last.target.selector &&\n            pTarget &&\n            last.target.selector === pTarget.selector &&\n            last.mode === 'container';\n          if (sameDoc || sameEl) {\n            last.offset = { y: pTop, x: pLeft };\n            this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n            return;\n          }\n        }\n        // New scroll step\n        if (pDoc) {\n          this._pushStep({\n            type: 'scroll',\n            mode: 'offset',\n            offset: { y: pTop, x: pLeft },\n            screenshotOnFail: false,\n          });\n        } else {\n          this._pushStep({\n            type: 'scroll',\n            mode: 'container',\n            target: pTarget,\n            offset: { y: pTop, x: pLeft },\n            screenshotOnFail: false,\n          });\n        }\n      }, CONFIG.SCROLL_DEBOUNCE_MS);\n    }\n\n    // Minimal key recorder: record Enter and modifier combos; avoid plain typing\n    _onKeyDown(e) {\n      if (!this.isRecording || this.isPaused) return;\n      try {\n        // Ignore autorepeat to prevent spam\n        if (e.repeat) return;\n        const key = String(e.key || '').toLowerCase();\n        const isModifier = key === 'shift' || key === 'control' || key === 'meta' || key === 'alt';\n        const isEditable =\n          e.target instanceof HTMLInputElement ||\n          e.target instanceof HTMLTextAreaElement ||\n          (e.target &&\n            e.target.nodeType === 1 &&\n            /** @type {HTMLElement} */ (e.target).isContentEditable === true);\n        const enterKey = key === 'enter';\n\n        // Track pressed modifiers\n        if (isModifier) this._pressed.add(key);\n\n        // Handle Enter in editable contexts (including contenteditable)\n        if (isEditable && enterKey) {\n          // Commit point: Enter may trigger form submission/navigation\n          // Record explicit key action with target first\n          const target = SelectorEngine.buildTarget(/** @type {Element} */ (e.target));\n          const combo = this._formatKeysCombo(e, 'Enter');\n          this._pushStep({ type: 'key', keys: combo, target, screenshotOnFail: false });\n\n          // Then commit and flush (form submit may navigate away)\n          this._commitAndFlush();\n\n          this._lastKeyTs = Date.now();\n          return;\n        }\n\n        // For non-text fields: record modifier combos and special keys\n        const special = enterKey || key === 'escape' || key === 'tab';\n        if (special || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {\n          const comboName = this._formatKeysCombo(e, e.key);\n          this._pushStep({ type: 'key', keys: comboName, screenshotOnFail: false });\n          this._lastKeyTs = Date.now();\n        }\n      } catch {}\n    }\n\n    _onKeyUp(e) {\n      const key = String(e.key || '').toLowerCase();\n      if (key === 'shift' || key === 'control' || key === 'meta' || key === 'alt')\n        this._pressed.delete(key);\n    }\n\n    _formatKeysCombo(e, mainKey) {\n      const parts = [];\n      if (e.ctrlKey) parts.push('Ctrl');\n      if (e.altKey) parts.push('Alt');\n      if (e.shiftKey) parts.push('Shift');\n      if (e.metaKey) parts.push('Meta');\n      const mk = String(mainKey || '').trim();\n      // Normalize common names to match keyboard-helper parsing\n      const norm = (s) => {\n        const k = s.toLowerCase();\n        if (k === 'escape') return 'Esc';\n        if (k === ' ') return 'Space';\n        if (k.length === 1) return k.toUpperCase();\n        return s;\n      };\n      parts.push(norm(mk));\n      return parts.join('+');\n    }\n\n    // Top-level aggregator: receives iframe events and merges into session\n    _onWindowMessage(ev) {\n      try {\n        const d = ev && ev.data;\n        if (!d || d.type !== FRAME_EVENT || !d.payload) return;\n\n        // Security: validate message source is from a known iframe in our page\n        // ev.source must match contentWindow of an iframe element we control\n        let frameEl = null;\n        try {\n          const frames = document.querySelectorAll('iframe,frame');\n          for (let i = 0; i < frames.length; i++) {\n            const f = frames[i];\n            if (f && f.contentWindow === ev.source) {\n              frameEl = f;\n              break;\n            }\n          }\n        } catch {}\n\n        // Reject messages not from a recognized iframe in our document\n        if (!frameEl) {\n          // Message source is not from a child iframe we control - ignore\n          return;\n        }\n\n        // Additional origin check: only accept from same origin or about:blank iframes\n        // (cross-origin iframes legitimately send from their origin)\n        try {\n          const selfOrigin = window.location.origin;\n          const msgOrigin = ev.origin;\n          // Allow same-origin, null (for sandboxed iframes), or if iframe src is same-origin\n          const frameSrc = frameEl.getAttribute('src') || '';\n          let iframeSameOrigin = false;\n          try {\n            if (!frameSrc || frameSrc === 'about:blank') {\n              iframeSameOrigin = true;\n            } else {\n              const frameUrl = new URL(frameSrc, selfOrigin);\n              iframeSameOrigin = frameUrl.origin === selfOrigin;\n            }\n          } catch {\n            // Invalid URL - assume cross-origin\n          }\n          // If iframe is same-origin, message origin should match\n          if (iframeSameOrigin && msgOrigin !== selfOrigin && msgOrigin !== 'null') {\n            return; // Origin mismatch for same-origin iframe - suspicious\n          }\n        } catch {}\n\n        const payload = d.payload || {};\n        const kind = payload.kind;\n\n        // Stop barrier sync: ACK back to the iframe so it can finish stop only after\n        // its final postMessages have been processed by the top aggregator\n        if (kind === 'iframeStopBarrier') {\n          try {\n            const id = payload.id;\n            if (id && ev.source && typeof ev.source.postMessage === 'function') {\n              ev.source.postMessage(\n                { type: FRAME_EVENT, payload: { kind: 'iframeStopBarrierAck', id } },\n                '*',\n              );\n            }\n          } catch {}\n          return;\n        }\n\n        // Handle iframe flush request: immediately flush top's aggregated buffer\n        if (kind === 'iframeFlush') {\n          this._lastInputActivityTs = 0;\n          this._typingBurstStartTs = 0;\n          this._clearForceFlushTimer();\n          if (this.batchTimer) clearTimeout(this.batchTimer);\n          this.batchTimer = null;\n          if (this.batch.length > 0) this._flush();\n          return;\n        }\n\n        const { step, href } = payload;\n        if (!step || typeof step !== 'object') return;\n\n        // Compose frame selector for iframe steps\n        const frameTarget = SelectorEngine.buildTarget(frameEl);\n        const frameSel = frameTarget?.selector || '';\n\n        // For upsert: find existing step in session and update it\n        if (kind === 'iframeStepUpsert') {\n          // Update input activity for iframe fills (enables flush gate for iframe input)\n          if (step.type === 'fill') {\n            this._updateInputActivity();\n          }\n\n          // Find step by id in session buffer and update its value\n          const existingIdx = this.sessionBuffer.steps.findIndex((s) => s.id === step.id);\n          if (existingIdx >= 0) {\n            // Update value but preserve the composed selector\n            this.sessionBuffer.steps[existingIdx].value = step.value;\n            this.sessionBuffer.meta.updatedAt = new Date().toISOString();\n            // Also update in batch if present\n            const batchIdx = this.batch.findIndex((s) => s.id === step.id);\n            if (batchIdx >= 0) {\n              this.batch[batchIdx].value = step.value;\n            } else {\n              // Step was already flushed, add updated version to batch\n              const updatedStep = { ...this.sessionBuffer.steps[existingIdx] };\n              this.batch.push(updatedStep);\n            }\n            this._scheduleFlush();\n          }\n          return;\n        }\n\n        // Regular iframe step: compose composite selector and push\n        if (step.target) {\n          const inner = String(step.target.selector || '').trim();\n          if (frameSel && inner) {\n            const composite = `${frameSel} |> ${inner}`;\n            step.target.selector = composite;\n            if (Array.isArray(step.target.candidates)) {\n              step.target.candidates.unshift({ type: 'css', value: composite });\n            }\n          }\n        }\n        this._pushStep(step);\n      } catch {}\n    }\n  }\n\n  // ================================================================\n  // 3) SINGLETON + MESSAGE HANDLERS\n  // ================================================================\n  let recorderInstance = null;\n  function getRecorder() {\n    if (!recorderInstance) recorderInstance = new ContentRecorder();\n    return recorderInstance;\n  }\n\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    try {\n      if (!request || !request.action) return false;\n      if (request.action === 'rr_timeline_update') {\n        const rec = getRecorder();\n        // Only respond to timeline updates when recording is active\n        if (!rec.isRecording) {\n          sendResponse({ ok: true, ignored: true });\n          return true;\n        }\n        // Replace entire timeline to avoid divergence across tabs\n        const steps = Array.isArray(request.steps) ? request.steps : [];\n        rec.ui.applyTimelineUpdate(steps);\n        sendResponse({ ok: true });\n        return true;\n      }\n      if (request.action === 'rr_recorder_control') {\n        const rec = getRecorder();\n        const cmd = request.cmd;\n        if (cmd === 'start') {\n          rec.start(request.meta || {});\n          sendResponse({ success: true });\n          return true;\n        }\n        if (cmd === 'pause') {\n          rec.pause();\n          sendResponse({ success: true });\n          return true;\n        }\n        if (cmd === 'resume') {\n          rec.resume();\n          sendResponse({ success: true });\n          return true;\n        }\n        if (cmd === 'stop') {\n          // Stop is now async - flush all data and wait for ack before responding\n          rec\n            .stop()\n            .then((result) => {\n              sendResponse({ success: true, ack: result.ack, stats: result });\n            })\n            .catch((err) => {\n              sendResponse({ success: false, ack: false, error: String(err) });\n            });\n          return true; // Keep channel open for async response\n        }\n        sendResponse({ success: false, error: 'Unknown command' });\n        return true;\n      }\n      // Handle direct stop message with ack (sent by recorder-manager)\n      if (request.action === 'stop' && request.requireAck) {\n        const rec = getRecorder();\n        rec\n          .stop()\n          .then((result) => {\n            sendResponse({ ack: result.ack, stats: result });\n          })\n          .catch(() => {\n            sendResponse({ ack: false });\n          });\n        return true;\n      }\n      if (request.action === 'rr_recorder_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n      return true;\n    }\n    return false;\n  });\n\n  console.log('Record & Replay recorder.js loaded');\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/screenshot-helper.js",
    "content": "/* eslint-disable */\n/**\n * Screenshot helper content script\n * Handles page preparation, scrolling, element positioning, etc.\n */\n\nif (window.__SCREENSHOT_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__SCREENSHOT_HELPER_INITIALIZED__ = true;\n\n  // Save original styles\n  let originalOverflowStyle = '';\n  let hiddenFixedElements = [];\n\n  /**\n   * Get fixed/sticky positioned elements\n   * @returns Array of fixed/sticky elements\n   */\n  function getFixedElements() {\n    const fixed = [];\n\n    document.querySelectorAll('*').forEach((el) => {\n      const htmlEl = el;\n      const style = window.getComputedStyle(htmlEl);\n      if (style.position === 'fixed' || style.position === 'sticky') {\n        // Filter out tiny or invisible elements, and elements that are part of the extension UI\n        if (\n          htmlEl.offsetWidth > 1 &&\n          htmlEl.offsetHeight > 1 &&\n          !htmlEl.id.startsWith('chrome-mcp-')\n        ) {\n          fixed.push({\n            element: htmlEl,\n            originalDisplay: htmlEl.style.display,\n            originalVisibility: htmlEl.style.visibility,\n          });\n        }\n      }\n    });\n    return fixed;\n  }\n\n  /**\n   * Hide fixed/sticky elements\n   */\n  function hideFixedElements() {\n    hiddenFixedElements = getFixedElements();\n    hiddenFixedElements.forEach((item) => {\n      item.element.style.display = 'none';\n    });\n  }\n\n  /**\n   * Restore fixed/sticky elements\n   */\n  function showFixedElements() {\n    hiddenFixedElements.forEach((item) => {\n      item.element.style.display = item.originalDisplay || '';\n    });\n    hiddenFixedElements = [];\n  }\n\n  // Listen for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    // Respond to ping message\n    if (request.action === 'chrome_screenshot_ping') {\n      sendResponse({ status: 'pong' });\n      return false; // Synchronous response\n    }\n\n    // Prepare page for capture\n    else if (request.action === 'preparePageForCapture') {\n      originalOverflowStyle = document.documentElement.style.overflow;\n      document.documentElement.style.overflow = 'hidden'; // Hide main scrollbar\n      if (request.options?.fullPage) {\n        // Only hide fixed elements for full page to avoid flicker\n        hideFixedElements();\n      }\n      // Give styles a moment to apply\n      setTimeout(() => {\n        sendResponse({ success: true });\n      }, 50);\n      return true; // Async response\n    }\n\n    // Get page details\n    else if (request.action === 'getPageDetails') {\n      const body = document.body;\n      const html = document.documentElement;\n      sendResponse({\n        totalWidth: Math.max(\n          body.scrollWidth,\n          body.offsetWidth,\n          html.clientWidth,\n          html.scrollWidth,\n          html.offsetWidth,\n        ),\n        totalHeight: Math.max(\n          body.scrollHeight,\n          body.offsetHeight,\n          html.clientHeight,\n          html.scrollHeight,\n          html.offsetHeight,\n        ),\n        viewportWidth: window.innerWidth,\n        viewportHeight: window.innerHeight,\n        devicePixelRatio: window.devicePixelRatio || 1,\n        currentScrollX: window.scrollX,\n        currentScrollY: window.scrollY,\n      });\n    }\n\n    // Get element details\n    else if (request.action === 'getElementDetails') {\n      const element = document.querySelector(request.selector);\n      if (element) {\n        element.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });\n        setTimeout(() => {\n          // Wait for scroll\n          const rect = element.getBoundingClientRect();\n          sendResponse({\n            rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },\n            devicePixelRatio: window.devicePixelRatio || 1,\n          });\n        }, 200); // Increased delay for scrollIntoView\n        return true; // Async response\n      } else {\n        sendResponse({ error: `Element with selector \"${request.selector}\" not found.` });\n      }\n      return true; // Async response\n    }\n\n    // Scroll page\n    else if (request.action === 'scrollPage') {\n      window.scrollTo({ left: request.x, top: request.y, behavior: 'instant' });\n      // Wait for scroll and potential reflows/lazy-loading\n      setTimeout(() => {\n        sendResponse({\n          success: true,\n          newScrollX: window.scrollX,\n          newScrollY: window.scrollY,\n        });\n      }, request.scrollDelay || 300); // Configurable delay\n      return true; // Async response\n    }\n\n    // Reset page\n    else if (request.action === 'resetPageAfterCapture') {\n      document.documentElement.style.overflow = originalOverflowStyle;\n      showFixedElements();\n      if (typeof request.scrollX !== 'undefined' && typeof request.scrollY !== 'undefined') {\n        window.scrollTo({ left: request.scrollX, top: request.scrollY, behavior: 'instant' });\n      }\n      sendResponse({ success: true });\n    }\n\n    return false; // Synchronous response\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/wait-helper.js",
    "content": "/* eslint-disable */\n// wait-helper.js\n// Listen for text appearance/disappearance in the current document using MutationObserver.\n// Returns a stable ref (compatible with accessibility-tree-helper) for the first matching element.\n\n(function () {\n  if (window.__WAIT_HELPER_INITIALIZED__) return;\n  window.__WAIT_HELPER_INITIALIZED__ = true;\n\n  // Ensure ref mapping infra exists (compatible with accessibility-tree-helper.js)\n  if (!window.__claudeElementMap) window.__claudeElementMap = {};\n  if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;\n\n  function isVisible(el) {\n    try {\n      if (!(el instanceof Element)) return false;\n      const style = getComputedStyle(el);\n      if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')\n        return false;\n      const rect = el.getBoundingClientRect();\n      if (rect.width <= 0 || rect.height <= 0) return false;\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  function normalize(str) {\n    return String(str || '')\n      .replace(/\\s+/g, ' ')\n      .trim()\n      .toLowerCase();\n  }\n\n  function matchesText(el, needle) {\n    const t = normalize(needle);\n    if (!t) return false;\n    try {\n      if (!isVisible(el)) return false;\n      const aria = el.getAttribute('aria-label');\n      if (aria && normalize(aria).includes(t)) return true;\n      const title = el.getAttribute('title');\n      if (title && normalize(title).includes(t)) return true;\n      const alt = el.getAttribute('alt');\n      if (alt && normalize(alt).includes(t)) return true;\n      const placeholder = el.getAttribute('placeholder');\n      if (placeholder && normalize(placeholder).includes(t)) return true;\n      // input/textarea value\n      if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n        const value = el.value || el.getAttribute('value');\n        if (value && normalize(value).includes(t)) return true;\n      }\n      const text = el.innerText || el.textContent || '';\n      if (normalize(text).includes(t)) return true;\n    } catch {}\n    return false;\n  }\n\n  function findElementByText(text) {\n    // Fast path: query common interactive elements first\n    const prioritized = Array.from(\n      document.querySelectorAll('a,button,input,textarea,select,label,summary,[role]'),\n    );\n    for (const el of prioritized) if (matchesText(el, text)) return el;\n\n    // Fallback: broader scan with cap to avoid blocking on huge pages\n    const walker = document.createTreeWalker(\n      document.body || document.documentElement,\n      NodeFilter.SHOW_ELEMENT,\n    );\n    let count = 0;\n    while (walker.nextNode()) {\n      const el = /** @type {Element} */ (walker.currentNode);\n      if (matchesText(el, text)) return el;\n      if (++count > 5000) break; // Hard cap to avoid long scans\n    }\n    return null;\n  }\n\n  function ensureRefForElement(el) {\n    // Try to reuse an existing ref\n    for (const k in window.__claudeElementMap) {\n      const weak = window.__claudeElementMap[k];\n      if (weak && typeof weak.deref === 'function' && weak.deref() === el) return k;\n    }\n    const refId = `ref_${++window.__claudeRefCounter}`;\n    window.__claudeElementMap[refId] = new WeakRef(el);\n    return refId;\n  }\n\n  function centerOf(el) {\n    const r = el.getBoundingClientRect();\n    return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };\n  }\n\n  function waitFor({ text, appear = true, timeout = 5000 }) {\n    return new Promise((resolve) => {\n      const start = Date.now();\n      let resolved = false;\n\n      const check = () => {\n        try {\n          const match = findElementByText(text);\n          if (appear) {\n            if (match) {\n              const ref = ensureRefForElement(match);\n              const center = centerOf(match);\n              done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });\n            }\n          } else {\n            // wait for disappearance\n            if (!match) {\n              done({ success: true, matched: null, tookMs: Date.now() - start });\n            }\n          }\n        } catch {}\n      };\n\n      const done = (result) => {\n        if (resolved) return;\n        resolved = true;\n        obs && obs.disconnect();\n        clearTimeout(timer);\n        resolve(result);\n      };\n\n      const obs = new MutationObserver(() => check());\n      try {\n        obs.observe(document.documentElement || document.body, {\n          subtree: true,\n          childList: true,\n          characterData: true,\n          attributes: true,\n        });\n      } catch {}\n\n      // Initial check\n      check();\n      const timer = setTimeout(\n        () => {\n          done({ success: false, reason: 'timeout', tookMs: Date.now() - start });\n        },\n        Math.max(0, timeout),\n      );\n    });\n  }\n\n  function waitForSelector({ selector, visible = true, timeout = 5000 }) {\n    return new Promise((resolve) => {\n      const start = Date.now();\n      let resolved = false;\n\n      const isMatch = () => {\n        try {\n          const el = document.querySelector(selector);\n          if (!el) return null;\n          if (!visible) return el;\n          return isVisible(el) ? el : null;\n        } catch {\n          return null;\n        }\n      };\n\n      const done = (result) => {\n        if (resolved) return;\n        resolved = true;\n        obs && obs.disconnect();\n        clearTimeout(timer);\n        resolve(result);\n      };\n\n      const check = () => {\n        const el = isMatch();\n        if (el) {\n          const ref = ensureRefForElement(el);\n          const center = centerOf(el);\n          done({ success: true, matched: { ref, center }, tookMs: Date.now() - start });\n        }\n      };\n\n      const obs = new MutationObserver(check);\n      try {\n        obs.observe(document.documentElement || document.body, {\n          subtree: true,\n          childList: true,\n          characterData: true,\n          attributes: true,\n        });\n      } catch {}\n\n      // initial check\n      check();\n      const timer = setTimeout(\n        () => done({ success: false, reason: 'timeout', tookMs: Date.now() - start }),\n        Math.max(0, timeout),\n      );\n    });\n  }\n\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    try {\n      if (request && request.action === 'wait_helper_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n      if (request && request.action === 'waitForText') {\n        const text = String(request.text || '').trim();\n        const appear = request.appear !== false; // default true\n        const timeout = Number(request.timeout || 5000);\n        if (!text) {\n          sendResponse({ success: false, error: 'text is required' });\n          return true;\n        }\n        waitFor({ text, appear, timeout }).then((res) => sendResponse(res));\n        return true; // async\n      }\n      if (request && request.action === 'waitForSelector') {\n        const selector = String(request.selector || '').trim();\n        const visible = request.visible !== false; // default true\n        const timeout = Number(request.timeout || 5000);\n        if (!selector) {\n          sendResponse({ success: false, error: 'selector is required' });\n          return true;\n        }\n        waitForSelector({ selector, visible, timeout }).then((res) => sendResponse(res));\n        return true; // async\n      }\n    } catch (e) {\n      sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n      return true;\n    }\n    return false;\n  });\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/web-editor.js",
    "content": "/* eslint-disable */\n\n(() => {\n  const GLOBAL_KEY = '__MCP_WEB_EDITOR__';\n  if (window[GLOBAL_KEY]) return;\n\n  const IS_MAIN = window === window.top;\n  const COLORS = {\n    hover: '#3b82f6', // blue-500\n    selected: '#22c55e', // green-500\n    backdrop: 'rgba(15, 23, 42, 0.15)', // slate-900 @ 15%\n  };\n\n  const clamp = (v, min, max) => Math.min(max, Math.max(min, v));\n\n  const normalizeTextSnippet = (value, maxLen) => {\n    return String(value || '')\n      .replace(/\\s+/g, ' ')\n      .trim()\n      .slice(0, maxLen || 80);\n  };\n\n  const containsPoint = (rect, x, y) => {\n    if (!rect) return false;\n    return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;\n  };\n\n  const getElementLabel = (el) => {\n    if (!(el instanceof Element)) return '';\n    const tag = String(el.tagName || '').toLowerCase();\n    const id = el.id ? `#${el.id}` : '';\n    const classes =\n      el.classList && el.classList.length\n        ? `.${Array.from(el.classList).slice(0, 3).join('.')}`\n        : '';\n    return `${tag}${id}${classes}`;\n  };\n\n  const detectTailwind = (classes) => {\n    try {\n      const patterns = [\n        /^bg-/,\n        /^text-/,\n        /^p[trblxy]?-/,\n        /^m[trblxy]?-/,\n        /^flex$/,\n        /^grid$/,\n        /^items-/,\n        /^justify-/,\n        /^gap-/,\n        /^rounded/,\n        /^shadow/,\n        /^border/,\n      ];\n      for (const cls of classes || []) {\n        if (patterns.some((p) => p.test(cls))) return true;\n      }\n    } catch {}\n    return false;\n  };\n\n  const findReactFileFromFiber = (fiber) => {\n    try {\n      let current = fiber;\n      for (let i = 0; i < 40 && current; i++) {\n        const src = current._debugSource;\n        if (src && src.fileName && typeof src.fileName === 'string') return src.fileName;\n        const owner = current._debugOwner;\n        const ownerSrc = owner && owner._debugSource;\n        if (ownerSrc && ownerSrc.fileName && typeof ownerSrc.fileName === 'string')\n          return ownerSrc.fileName;\n        current = current.return;\n      }\n    } catch {}\n    return '';\n  };\n\n  const findReactSourceFile = (el) => {\n    try {\n      let node = el;\n      for (let depth = 0; depth < 15 && node; depth++) {\n        const keys = Object.keys(node);\n        for (const k of keys) {\n          if (k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')) {\n            const fiber = node[k];\n            const found = findReactFileFromFiber(fiber);\n            if (found) return found;\n          }\n        }\n        node = node.parentElement;\n      }\n    } catch {}\n    return '';\n  };\n\n  const findVueSourceFile = (el) => {\n    try {\n      let node = el;\n      for (let depth = 0; depth < 15 && node; depth++) {\n        const inst = node.__vueParentComponent;\n        if (inst && inst.type && inst.type.__file) return String(inst.type.__file);\n        node = node.parentElement;\n      }\n    } catch {}\n    return '';\n  };\n\n  const resolveTargetFile = (el) => {\n    try {\n      let node = el;\n      for (let depth = 0; depth < 20 && node; depth++) {\n        const reactFile = findReactSourceFile(node);\n        if (reactFile && !reactFile.includes('node_modules')) return reactFile;\n        const vueFile = findVueSourceFile(node);\n        if (vueFile && !vueFile.includes('node_modules')) return vueFile;\n        node = node.parentElement;\n      }\n    } catch {}\n    return '';\n  };\n\n  const findMeaningfulElement = (el, clientX, clientY) => {\n    try {\n      let current = el instanceof Element ? el : null;\n      for (let i = 0; i < 8 && current; i++) {\n        const tag = String(current.tagName || '').toUpperCase();\n        if (tag === 'HTML' || tag === 'BODY') {\n          const deeper = document.elementFromPoint(clientX, clientY);\n          if (deeper && deeper !== current && deeper instanceof Element) {\n            current = deeper;\n            continue;\n          }\n          return current;\n        }\n\n        let style;\n        try {\n          style = window.getComputedStyle(current);\n        } catch {\n          return current;\n        }\n\n        const bg = String(style.backgroundColor || '').toLowerCase();\n        const isTransparentBg = bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)';\n        const borderWidth = [\n          style.borderTopWidth,\n          style.borderRightWidth,\n          style.borderBottomWidth,\n          style.borderLeftWidth,\n        ]\n          .map((x) => String(x || '0px'))\n          .join(',');\n        const hasBorder = borderWidth !== '0px,0px,0px,0px';\n\n        if (!isTransparentBg || hasBorder) return current;\n\n        const rect = current.getBoundingClientRect();\n        if (!rect || rect.width <= 0 || rect.height <= 0) return current;\n\n        let bestChild = null;\n        let bestArea = Infinity;\n        const children = Array.from(current.children || []);\n        for (const child of children) {\n          if (!(child instanceof Element)) continue;\n          const r = child.getBoundingClientRect();\n          if (!r || r.width <= 0 || r.height <= 0) continue;\n          if (!containsPoint(r, clientX, clientY)) continue;\n          const area = r.width * r.height;\n          if (area < bestArea) {\n            bestArea = area;\n            bestChild = child;\n          }\n        }\n\n        if (!bestChild) return current;\n\n        const childRect = bestChild.getBoundingClientRect();\n        const sameSize =\n          Math.abs(rect.width - childRect.width) < 2 &&\n          Math.abs(rect.height - childRect.height) < 2;\n        if (!sameSize) return current;\n\n        current = bestChild;\n      }\n    } catch {}\n    return el instanceof Element ? el : null;\n  };\n\n  const createToastHost = () => {\n    const host = document.createElement('div');\n    Object.assign(host.style, {\n      position: 'fixed',\n      left: '12px',\n      bottom: '12px',\n      zIndex: 2147483647,\n      display: 'flex',\n      flexDirection: 'column',\n      gap: '8px',\n      pointerEvents: 'none',\n    });\n    return host;\n  };\n\n  const showToast = (state, message, kind) => {\n    try {\n      if (!state.toastHost) return;\n      const item = document.createElement('div');\n      const bg =\n        kind === 'error'\n          ? 'rgba(220, 38, 38, 0.92)'\n          : kind === 'success'\n            ? 'rgba(22, 163, 74, 0.92)'\n            : 'rgba(15, 23, 42, 0.92)';\n      Object.assign(item.style, {\n        background: bg,\n        color: '#fff',\n        padding: '8px 10px',\n        borderRadius: '10px',\n        fontSize: '12px',\n        fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',\n        boxShadow: '0 6px 18px rgba(0,0,0,0.22)',\n        maxWidth: '340px',\n        lineHeight: '1.35',\n      });\n      item.textContent = String(message || '');\n      state.toastHost.appendChild(item);\n      setTimeout(() => {\n        try {\n          item.remove();\n        } catch {}\n      }, 2800);\n    } catch {}\n  };\n\n  const buildStyleMapFromInput = (raw) => {\n    const out = {};\n    const text = String(raw || '').trim();\n    if (!text) return out;\n    const parts = text\n      .split(';')\n      .map((s) => s.trim())\n      .filter(Boolean);\n    for (const part of parts) {\n      const idx = part.indexOf(':');\n      if (idx <= 0) continue;\n      const key = part.slice(0, idx).trim();\n      const value = part.slice(idx + 1).trim();\n      if (!key || !value) continue;\n      out[key] = value;\n    }\n    return out;\n  };\n\n  const applyInlineStyleMap = (el, styles) => {\n    try {\n      if (!(el instanceof Element)) return;\n      const entries = Object.entries(styles || {});\n      for (const [key, value] of entries) {\n        if (!key || !value) continue;\n        try {\n          el.style.setProperty(key, value);\n        } catch {}\n      }\n    } catch {}\n  };\n\n  const state = {\n    active: false,\n    root: null,\n    canvas: null,\n    ctx: null,\n    raf: 0,\n    dpr: 1,\n    viewport: { w: 0, h: 0 },\n    hoveredEl: null,\n    selectedEl: null,\n    hoverRect: null,\n    selectedRect: null,\n    toolbar: null,\n    toastHost: null,\n    inputText: '',\n    inputStyle: '',\n    lastPointer: { x: 0, y: 0 },\n  };\n\n  const ensureCanvas = () => {\n    if (!state.canvas || !state.ctx) return;\n    const dpr = window.devicePixelRatio || 1;\n    const w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);\n    const h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);\n    if (state.viewport.w === w && state.viewport.h === h && Math.abs(state.dpr - dpr) < 0.01)\n      return;\n    state.dpr = dpr;\n    state.viewport = { w, h };\n    state.canvas.width = Math.round(w * dpr);\n    state.canvas.height = Math.round(h * dpr);\n    state.canvas.style.width = `${w}px`;\n    state.canvas.style.height = `${h}px`;\n    state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n  };\n\n  const drawRect = (rect, color, dashed) => {\n    if (!rect || !state.ctx) return;\n    const ctx = state.ctx;\n    const x = Math.round(rect.left) + 0.5;\n    const y = Math.round(rect.top) + 0.5;\n    const w = Math.max(0, Math.round(rect.width));\n    const h = Math.max(0, Math.round(rect.height));\n    if (w <= 0 || h <= 0) return;\n    ctx.save();\n    ctx.lineWidth = 2;\n    ctx.strokeStyle = color;\n    ctx.fillStyle = `${color}22`;\n    if (dashed) ctx.setLineDash([6, 4]);\n    ctx.beginPath();\n    ctx.rect(x, y, w, h);\n    ctx.fill();\n    ctx.stroke();\n    ctx.restore();\n  };\n\n  const draw = () => {\n    if (!state.active || !state.ctx) return;\n    ensureCanvas();\n    const ctx = state.ctx;\n    ctx.clearRect(0, 0, state.viewport.w, state.viewport.h);\n\n    // Keep selected rect fresh in case HMR/layout changes.\n    try {\n      if (state.selectedEl && state.selectedEl instanceof Element) {\n        state.selectedRect = state.selectedEl.getBoundingClientRect();\n      }\n    } catch {}\n    try {\n      if (state.hoveredEl && state.hoveredEl instanceof Element) {\n        state.hoverRect = state.hoveredEl.getBoundingClientRect();\n      }\n    } catch {}\n\n    drawRect(state.hoverRect, COLORS.hover, true);\n    drawRect(state.selectedRect, COLORS.selected, false);\n\n    // Keep toolbar anchored to the selected element.\n    positionToolbar();\n  };\n\n  const tick = () => {\n    if (!state.active) return;\n    draw();\n    state.raf = requestAnimationFrame(tick);\n  };\n\n  const isInToolbar = (target) => {\n    try {\n      if (!target || !(target instanceof Node)) return false;\n      if (!state.toolbar) return false;\n      return state.toolbar.contains(target);\n    } catch {\n      return false;\n    }\n  };\n\n  const updateHover = (el, clientX, clientY) => {\n    const picked = findMeaningfulElement(el, clientX, clientY);\n    state.hoveredEl = picked;\n    try {\n      state.hoverRect = picked ? picked.getBoundingClientRect() : null;\n    } catch {\n      state.hoverRect = null;\n    }\n  };\n\n  const positionToolbar = () => {\n    try {\n      if (!state.toolbar || !state.selectedRect) return;\n      const pad = 10;\n      const maxW = 420;\n      const rect = state.selectedRect;\n      const preferredLeft = clamp(\n        Math.round(rect.left),\n        pad,\n        Math.max(pad, window.innerWidth - maxW - pad),\n      );\n      const preferredTop = Math.round(rect.top - 12);\n      const top = preferredTop < 80 ? Math.round(rect.bottom + 12) : preferredTop;\n      Object.assign(state.toolbar.style, {\n        left: `${preferredLeft}px`,\n        top: `${clamp(top, pad, Math.max(pad, window.innerHeight - 180))}px`,\n      });\n    } catch {}\n  };\n\n  const updateToolbarHeader = () => {\n    try {\n      if (!state.toolbar) return;\n      const label = state.toolbar.querySelector('[data-role=\"label\"]');\n      if (!label) return;\n      label.textContent = state.selectedEl ? getElementLabel(state.selectedEl) : 'No selection';\n    } catch {}\n  };\n\n  const buildApplyPayload = (instruction) => {\n    const el = state.selectedEl;\n    const tag = el && el.tagName ? String(el.tagName || '').toLowerCase() : 'unknown';\n    const id = el && el.id ? String(el.id) : undefined;\n    const classes = el && el.classList ? Array.from(el.classList).slice(0, 24) : [];\n    const text = normalizeTextSnippet(el ? el.textContent : '', 96);\n    const fingerprint = { tag, id, classes, text };\n    const targetFile = el ? resolveTargetFile(el) : '';\n    const hints = [];\n    try {\n      if (el) {\n        const r = findReactSourceFile(el);\n        const v = findVueSourceFile(el);\n        if (r) hints.push('React');\n        if (v) hints.push('Vue');\n      }\n      if (detectTailwind(classes)) hints.push('Tailwind');\n    } catch {}\n    return {\n      pageUrl: String(location && location.href ? location.href : ''),\n      targetFile: targetFile || undefined,\n      fingerprint,\n      techStackHint: hints.length ? hints : undefined,\n      instruction,\n    };\n  };\n\n  const onMouseMove = (e) => {\n    if (!state.active) return;\n    if (isInToolbar(e.target)) return;\n    state.lastPointer = { x: e.clientX, y: e.clientY };\n    const el = e.target instanceof Element ? e.target : null;\n    if (!el) return;\n    updateHover(el, e.clientX, e.clientY);\n  };\n\n  const onClick = (e) => {\n    if (!state.active) return;\n    if (isInToolbar(e.target)) return;\n    try {\n      e.preventDefault();\n      e.stopPropagation();\n    } catch {}\n    const el = state.hoveredEl;\n    if (!el) return;\n    state.selectedEl = el;\n    try {\n      state.selectedRect = el.getBoundingClientRect();\n    } catch {\n      state.selectedRect = null;\n    }\n    updateToolbarHeader();\n    positionToolbar();\n  };\n\n  const intercept = (e) => {\n    if (!state.active) return;\n    if (isInToolbar(e.target)) return;\n    // Allow scroll/wheel to keep navigation usable in edit mode.\n    if (e.type === 'wheel') return;\n    try {\n      e.preventDefault();\n      e.stopPropagation();\n    } catch {}\n  };\n\n  const onKeyDown = (e) => {\n    if (!state.active) return;\n    if (isInToolbar(e.target)) return;\n    if (e.key === 'Escape') {\n      try {\n        e.preventDefault();\n        e.stopPropagation();\n      } catch {}\n      stop();\n      return;\n    }\n  };\n\n  const buildToolbar = () => {\n    const box = document.createElement('div');\n    state.toolbar = box;\n    Object.assign(box.style, {\n      position: 'fixed',\n      left: '12px',\n      top: '12px',\n      zIndex: 2147483647,\n      pointerEvents: 'auto',\n      width: 'min(420px, calc(100vw - 24px))',\n      background: 'rgba(255,255,255,0.96)',\n      border: '1px solid rgba(148, 163, 184, 0.6)',\n      borderRadius: '12px',\n      boxShadow: '0 10px 30px rgba(0,0,0,0.18)',\n      fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',\n      color: '#0f172a',\n      overflow: 'hidden',\n    });\n\n    const header = document.createElement('div');\n    Object.assign(header.style, {\n      display: 'flex',\n      alignItems: 'center',\n      justifyContent: 'space-between',\n      gap: '10px',\n      padding: '10px 12px',\n      background: 'rgba(248,250,252,0.9)',\n      borderBottom: '1px solid rgba(148, 163, 184, 0.35)',\n    });\n    const label = document.createElement('div');\n    label.setAttribute('data-role', 'label');\n    Object.assign(label.style, {\n      fontSize: '12px',\n      fontWeight: '600',\n      whiteSpace: 'nowrap',\n      overflow: 'hidden',\n      textOverflow: 'ellipsis',\n      maxWidth: '280px',\n    });\n    label.textContent = 'Select an element';\n\n    const btnExit = document.createElement('button');\n    btnExit.textContent = 'Exit (Esc)';\n    Object.assign(btnExit.style, {\n      fontSize: '12px',\n      padding: '6px 10px',\n      borderRadius: '10px',\n      border: '1px solid rgba(148,163,184,0.6)',\n      background: '#fff',\n      cursor: 'pointer',\n    });\n    btnExit.addEventListener('click', () => stop());\n\n    header.appendChild(label);\n    header.appendChild(btnExit);\n\n    const body = document.createElement('div');\n    Object.assign(body.style, {\n      padding: '10px 12px 12px',\n      display: 'flex',\n      flexDirection: 'column',\n      gap: '10px',\n    });\n\n    const mkRow = (titleText) => {\n      const row = document.createElement('div');\n      Object.assign(row.style, {\n        display: 'flex',\n        flexDirection: 'column',\n        gap: '6px',\n      });\n      const title = document.createElement('div');\n      title.textContent = titleText;\n      Object.assign(title.style, { fontSize: '12px', fontWeight: '600', color: '#334155' });\n      row.appendChild(title);\n      return { row, title };\n    };\n\n    const mkActions = () => {\n      const actions = document.createElement('div');\n      Object.assign(actions.style, {\n        display: 'flex',\n        gap: '8px',\n        alignItems: 'center',\n        flexWrap: 'wrap',\n      });\n      return actions;\n    };\n\n    const mkButton = (text, variant) => {\n      const btn = document.createElement('button');\n      btn.textContent = text;\n      const bg = variant === 'primary' ? '#0f172a' : '#fff';\n      const color = variant === 'primary' ? '#fff' : '#0f172a';\n      Object.assign(btn.style, {\n        fontSize: '12px',\n        padding: '7px 10px',\n        borderRadius: '10px',\n        border: '1px solid rgba(148,163,184,0.6)',\n        background: bg,\n        color,\n        cursor: 'pointer',\n      });\n      return btn;\n    };\n\n    // Text edit\n    const textRow = mkRow('Text');\n    const textInput = document.createElement('input');\n    textInput.type = 'text';\n    textInput.placeholder = 'New text…';\n    Object.assign(textInput.style, {\n      width: '100%',\n      padding: '8px 10px',\n      borderRadius: '10px',\n      border: '1px solid rgba(148,163,184,0.6)',\n      fontSize: '12px',\n      outline: 'none',\n    });\n    textInput.addEventListener('input', () => {\n      state.inputText = textInput.value;\n    });\n    const textActions = mkActions();\n    const btnApplyText = mkButton('Apply (DOM)', 'secondary');\n    btnApplyText.addEventListener('click', () => {\n      if (!state.selectedEl) return showToast(state, 'No selection', 'error');\n      const v = String(state.inputText || '').trim();\n      if (!v) return showToast(state, 'Text is empty', 'error');\n      try {\n        state.selectedEl.textContent = v;\n        showToast(state, 'Text applied (DOM)', 'success');\n      } catch {\n        showToast(state, 'Failed to apply text', 'error');\n      }\n    });\n    const btnSyncText = mkButton('Sync to Code', 'primary');\n    btnSyncText.addEventListener('click', async () => {\n      if (!state.selectedEl) return showToast(state, 'No selection', 'error');\n      const v = String(state.inputText || '').trim();\n      if (!v) return showToast(state, 'Text is empty', 'error');\n      const payload = buildApplyPayload({\n        type: 'update_text',\n        description: `Set the element text to: ${JSON.stringify(v)}`,\n        text: v,\n      });\n      try {\n        const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload });\n        if (resp && resp.success) {\n          showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success');\n        } else {\n          showToast(state, resp?.error || 'Agent request failed', 'error');\n        }\n      } catch (err) {\n        showToast(state, String(err && err.message ? err.message : err), 'error');\n      }\n    });\n    textActions.appendChild(btnApplyText);\n    textActions.appendChild(btnSyncText);\n    textRow.row.appendChild(textInput);\n    textRow.row.appendChild(textActions);\n\n    // Style edit\n    const styleRow = mkRow('Style (CSS declarations)');\n    const styleInput = document.createElement('input');\n    styleInput.type = 'text';\n    styleInput.placeholder = 'e.g. background-color: #f3f4f6; padding: 12px';\n    Object.assign(styleInput.style, {\n      width: '100%',\n      padding: '8px 10px',\n      borderRadius: '10px',\n      border: '1px solid rgba(148,163,184,0.6)',\n      fontSize: '12px',\n      outline: 'none',\n    });\n    styleInput.addEventListener('input', () => {\n      state.inputStyle = styleInput.value;\n    });\n    const styleActions = mkActions();\n    const btnApplyStyle = mkButton('Apply (DOM)', 'secondary');\n    btnApplyStyle.addEventListener('click', () => {\n      if (!state.selectedEl) return showToast(state, 'No selection', 'error');\n      const map = buildStyleMapFromInput(state.inputStyle);\n      const keys = Object.keys(map);\n      if (!keys.length) return showToast(state, 'No valid declarations', 'error');\n      applyInlineStyleMap(state.selectedEl, map);\n      showToast(state, 'Style applied (DOM)', 'success');\n    });\n    const btnSyncStyle = mkButton('Sync to Code', 'primary');\n    btnSyncStyle.addEventListener('click', async () => {\n      if (!state.selectedEl) return showToast(state, 'No selection', 'error');\n      const map = buildStyleMapFromInput(state.inputStyle);\n      const keys = Object.keys(map);\n      if (!keys.length) return showToast(state, 'No valid declarations', 'error');\n      const decl = keys.map((k) => `${k}: ${map[k]}`).join('; ');\n      const payload = buildApplyPayload({\n        type: 'update_style',\n        description: `Apply CSS declarations: ${decl}`,\n        style: map,\n      });\n      try {\n        const resp = await chrome.runtime.sendMessage({ type: 'web_editor_apply', payload });\n        if (resp && resp.success) {\n          showToast(state, `Agent accepted (requestId=${resp.requestId || 'n/a'})`, 'success');\n        } else {\n          showToast(state, resp?.error || 'Agent request failed', 'error');\n        }\n      } catch (err) {\n        showToast(state, String(err && err.message ? err.message : err), 'error');\n      }\n    });\n    styleActions.appendChild(btnApplyStyle);\n    styleActions.appendChild(btnSyncStyle);\n    styleRow.row.appendChild(styleInput);\n    styleRow.row.appendChild(styleActions);\n\n    body.appendChild(textRow.row);\n    body.appendChild(styleRow.row);\n    box.appendChild(header);\n    box.appendChild(body);\n    return box;\n  };\n\n  const start = () => {\n    if (!IS_MAIN) return;\n    if (state.active) return;\n    state.active = true;\n\n    const root = document.createElement('div');\n    state.root = root;\n    root.id = '__mcp_web_editor_root';\n    Object.assign(root.style, {\n      position: 'fixed',\n      inset: '0',\n      zIndex: 2147483647,\n      pointerEvents: 'none',\n    });\n\n    const canvas = document.createElement('canvas');\n    state.canvas = canvas;\n    Object.assign(canvas.style, {\n      position: 'fixed',\n      inset: '0',\n      width: '100%',\n      height: '100%',\n      pointerEvents: 'none',\n    });\n    root.appendChild(canvas);\n\n    try {\n      const ctx = canvas.getContext('2d');\n      state.ctx = ctx;\n    } catch {\n      state.ctx = null;\n    }\n\n    const toolbar = buildToolbar();\n    root.appendChild(toolbar);\n\n    const toastHost = createToastHost();\n    state.toastHost = toastHost;\n    root.appendChild(toastHost);\n\n    document.documentElement.appendChild(root);\n\n    document.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });\n    document.addEventListener('click', onClick, true);\n    document.addEventListener('mousedown', intercept, true);\n    document.addEventListener('mouseup', intercept, true);\n    document.addEventListener('dblclick', intercept, true);\n    document.addEventListener('contextmenu', intercept, true);\n    document.addEventListener('submit', intercept, true);\n    document.addEventListener('keydown', onKeyDown, true);\n\n    // Visual cue\n    showToast(state, 'Web Editor: ON (Esc to exit)', 'info');\n\n    // Start RAF\n    state.raf = requestAnimationFrame(tick);\n  };\n\n  const stop = () => {\n    if (!IS_MAIN) return;\n    if (!state.active) return;\n    state.active = false;\n\n    try {\n      if (state.raf) cancelAnimationFrame(state.raf);\n    } catch {}\n    state.raf = 0;\n\n    try {\n      document.removeEventListener('mousemove', onMouseMove, true);\n      document.removeEventListener('click', onClick, true);\n      document.removeEventListener('mousedown', intercept, true);\n      document.removeEventListener('mouseup', intercept, true);\n      document.removeEventListener('dblclick', intercept, true);\n      document.removeEventListener('contextmenu', intercept, true);\n      document.removeEventListener('submit', intercept, true);\n      document.removeEventListener('keydown', onKeyDown, true);\n    } catch {}\n\n    try {\n      state.root && state.root.remove();\n    } catch {}\n\n    state.root = null;\n    state.canvas = null;\n    state.ctx = null;\n    state.hoveredEl = null;\n    state.selectedEl = null;\n    state.hoverRect = null;\n    state.selectedRect = null;\n    state.toolbar = null;\n    state.toastHost = null;\n    state.inputText = '';\n    state.inputStyle = '';\n  };\n\n  const toggle = () => {\n    if (!IS_MAIN) return false;\n    if (state.active) {\n      stop();\n      return false;\n    }\n    start();\n    return true;\n  };\n\n  // Expose minimal API for debugging\n  window[GLOBAL_KEY] = {\n    start,\n    stop,\n    toggle,\n    getState: () => ({ active: state.active }),\n  };\n\n  // Message handler (background -> tab)\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    try {\n      if (!IS_MAIN) return false;\n      if (request && request.action === 'web_editor_ping') {\n        sendResponse({ status: 'pong' });\n        return false;\n      }\n      if (request && request.action === 'web_editor_toggle') {\n        const active = toggle();\n        sendResponse({ active });\n        return true;\n      }\n      if (request && request.action === 'web_editor_start') {\n        start();\n        sendResponse({ active: true });\n        return true;\n      }\n      if (request && request.action === 'web_editor_stop') {\n        stop();\n        sendResponse({ active: false });\n        return true;\n      }\n    } catch (e) {\n      try {\n        sendResponse({ success: false, error: String(e && e.message ? e.message : e) });\n      } catch {}\n      return true;\n    }\n    return false;\n  });\n})();\n"
  },
  {
    "path": "app/chrome-extension/inject-scripts/web-fetcher-helper.js",
    "content": "/* eslint-disable */\n\nif (window.__WEB_FETCHER_HELPER_INITIALIZED__) {\n  // Already initialized, skip\n} else {\n  window.__WEB_FETCHER_HELPER_INITIALIZED__ = true;\n\n  /*\n   * Copyright (c) 2010 Arc90 Inc\n   *\n   * Licensed under the Apache License, Version 2.0 (the \"License\");\n   * you may not use this file except in compliance with the License.\n   * You may obtain a copy of the License at\n   *\n   *     http://www.apache.org/licenses/LICENSE-2.0\n   *\n   * Unless required by applicable law or agreed to in writing, software\n   * distributed under the License is distributed on an \"AS IS\" BASIS,\n   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   * See the License for the specific language governing permissions and\n   * limitations under the License.\n   */\n\n  /*\n   * This code is heavily based on Arc90's readability.js (1.7.1) script\n   * available at: http://code.google.com/p/arc90labs-readability\n   */\n\n  /**\n   * Public constructor.\n   * @param {HTMLDocument} doc     The document to parse.\n   * @param {Object}       options The options object.\n   */\n  function Readability(doc, options) {\n    // In some older versions, people passed a URI as the first argument. Cope:\n    if (options && options.documentElement) {\n      doc = options;\n      options = arguments[2];\n    } else if (!doc || !doc.documentElement) {\n      throw new Error('First argument to Readability constructor should be a document object.');\n    }\n    options = options || {};\n\n    this._doc = doc;\n    this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__;\n    this._articleTitle = null;\n    this._articleByline = null;\n    this._articleDir = null;\n    this._articleSiteName = null;\n    this._attempts = [];\n    this._metadata = {};\n\n    // Configurable options\n    this._debug = !!options.debug;\n    this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE;\n    this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES;\n    this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD;\n    this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(options.classesToPreserve || []);\n    this._keepClasses = !!options.keepClasses;\n    this._serializer =\n      options.serializer ||\n      function (el) {\n        return el.innerHTML;\n      };\n    this._disableJSONLD = !!options.disableJSONLD;\n    this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos;\n    this._linkDensityModifier = options.linkDensityModifier || 0;\n\n    // Start with all flags set\n    this._flags =\n      this.FLAG_STRIP_UNLIKELYS | this.FLAG_WEIGHT_CLASSES | this.FLAG_CLEAN_CONDITIONALLY;\n\n    // Control whether log messages are sent to the console\n    if (this._debug) {\n      let logNode = function (node) {\n        if (node.nodeType == node.TEXT_NODE) {\n          return `${node.nodeName} (\"${node.textContent}\")`;\n        }\n        let attrPairs = Array.from(node.attributes || [], function (attr) {\n          return `${attr.name}=\"${attr.value}\"`;\n        }).join(' ');\n        return `<${node.localName} ${attrPairs}>`;\n      };\n      this.log = function () {\n        if (typeof console !== 'undefined') {\n          let args = Array.from(arguments, (arg) => {\n            if (arg && arg.nodeType == this.ELEMENT_NODE) {\n              return logNode(arg);\n            }\n            return arg;\n          });\n          args.unshift('Reader: (Readability)');\n\n          // Debug logging removed\n        } else if (typeof dump !== 'undefined') {\n          /* global dump */\n          var msg = Array.prototype.map\n            .call(arguments, function (x) {\n              return x && x.nodeName ? logNode(x) : x;\n            })\n            .join(' ');\n          dump('Reader: (Readability) ' + msg + '\\n');\n        }\n      };\n    } else {\n      this.log = function () {};\n    }\n  }\n\n  Readability.prototype = {\n    FLAG_STRIP_UNLIKELYS: 0x1,\n    FLAG_WEIGHT_CLASSES: 0x2,\n    FLAG_CLEAN_CONDITIONALLY: 0x4,\n\n    // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\n    ELEMENT_NODE: 1,\n    TEXT_NODE: 3,\n\n    // Max number of nodes supported by this parser. Default: 0 (no limit)\n    DEFAULT_MAX_ELEMS_TO_PARSE: 0,\n\n    // The number of top candidates to consider when analysing how\n    // tight the competition is among candidates.\n    DEFAULT_N_TOP_CANDIDATES: 5,\n\n    // Element tags to score by default.\n    DEFAULT_TAGS_TO_SCORE: 'section,h2,h3,h4,h5,h6,p,td,pre'.toUpperCase().split(','),\n\n    // The default number of chars an article must have in order to return a result\n    DEFAULT_CHAR_THRESHOLD: 500,\n\n    // All of the regular expressions in use within readability.\n    // Defined up here so we don't instantiate them repeatedly in loops.\n    REGEXPS: {\n      // NOTE: These two regular expressions are duplicated in\n      // Readability-readerable.js. Please keep both copies in sync.\n      unlikelyCandidates:\n        /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i,\n      okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i,\n\n      positive:\n        /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i,\n      negative:\n        /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i,\n      extraneous:\n        /print|archive|comment|discuss|e[\\-]?mail|share|reply|all|login|sign|single|utility/i,\n      byline: /byline|author|dateline|writtenby|p-author/i,\n      replaceFonts: /<(\\/?)font[^>]*>/gi,\n      normalize: /\\s{2,}/g,\n      videos:\n        /\\/\\/(www\\.)?((dailymotion|youtube|youtube-nocookie|player\\.vimeo|v\\.qq)\\.com|(archive|upload\\.wikimedia)\\.org|player\\.twitch\\.tv)/i,\n      shareElements: /(\\b|_)(share|sharedaddy)(\\b|_)/i,\n      nextLink: /(next|weiter|continue|>([^\\|]|$)|»([^\\|]|$))/i,\n      prevLink: /(prev|earl|old|new|<|«)/i,\n      tokenize: /\\W+/g,\n      whitespace: /^\\s*$/,\n      hasContent: /\\S$/,\n      hashUrl: /^#.+/,\n      srcsetUrl: /(\\S+)(\\s+[\\d.]+[xw])?(\\s*(?:,|$))/g,\n      b64DataUrl: /^data:\\s*([^\\s;,]+)\\s*;\\s*base64\\s*,/i,\n      // Commas as used in Latin, Sindhi, Chinese and various other scripts.\n      // see: https://en.wikipedia.org/wiki/Comma#Comma_variants\n      commas: /\\u002C|\\u060C|\\uFE50|\\uFE10|\\uFE11|\\u2E41|\\u2E34|\\u2E32|\\uFF0C/g,\n      // See: https://schema.org/Article\n      jsonLdArticleTypes:\n        /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/,\n      // used to see if a node's content matches words commonly used for ad blocks or loading indicators\n      adWords: /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu,\n      loadingWords: /^((loading|正在加载|Загрузка|chargement|cargando)(…|\\.\\.\\.)?)$/iu,\n    },\n\n    UNLIKELY_ROLES: [\n      'menu',\n      'menubar',\n      'complementary',\n      'navigation',\n      'alert',\n      'alertdialog',\n      'dialog',\n    ],\n\n    DIV_TO_P_ELEMS: new Set(['BLOCKQUOTE', 'DL', 'DIV', 'IMG', 'OL', 'P', 'PRE', 'TABLE', 'UL']),\n\n    ALTER_TO_DIV_EXCEPTIONS: ['DIV', 'ARTICLE', 'SECTION', 'P', 'OL', 'UL'],\n\n    PRESENTATIONAL_ATTRIBUTES: [\n      'align',\n      'background',\n      'bgcolor',\n      'border',\n      'cellpadding',\n      'cellspacing',\n      'frame',\n      'hspace',\n      'rules',\n      'style',\n      'valign',\n      'vspace',\n    ],\n\n    DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ['TABLE', 'TH', 'TD', 'HR', 'PRE'],\n\n    // The commented out elements qualify as phrasing content but tend to be\n    // removed by readability when put into paragraphs, so we ignore them here.\n    PHRASING_ELEMS: [\n      // \"CANVAS\", \"IFRAME\", \"SVG\", \"VIDEO\",\n      'ABBR',\n      'AUDIO',\n      'B',\n      'BDO',\n      'BR',\n      'BUTTON',\n      'CITE',\n      'CODE',\n      'DATA',\n      'DATALIST',\n      'DFN',\n      'EM',\n      'EMBED',\n      'I',\n      'IMG',\n      'INPUT',\n      'KBD',\n      'LABEL',\n      'MARK',\n      'MATH',\n      'METER',\n      'NOSCRIPT',\n      'OBJECT',\n      'OUTPUT',\n      'PROGRESS',\n      'Q',\n      'RUBY',\n      'SAMP',\n      'SCRIPT',\n      'SELECT',\n      'SMALL',\n      'SPAN',\n      'STRONG',\n      'SUB',\n      'SUP',\n      'TEXTAREA',\n      'TIME',\n      'VAR',\n      'WBR',\n    ],\n\n    // These are the classes that readability sets itself.\n    CLASSES_TO_PRESERVE: ['page'],\n\n    // These are the list of HTML entities that need to be escaped.\n    HTML_ESCAPE_MAP: {\n      lt: '<',\n      gt: '>',\n      amp: '&',\n      quot: '\"',\n      apos: \"'\",\n    },\n\n    /**\n     * Run any post-process modifications to article content as necessary.\n     *\n     * @param Element\n     * @return void\n     **/\n    _postProcessContent(articleContent) {\n      // Readability cannot open relative uris so we convert them to absolute uris.\n      this._fixRelativeUris(articleContent);\n\n      this._simplifyNestedElements(articleContent);\n\n      if (!this._keepClasses) {\n        // Remove classes.\n        this._cleanClasses(articleContent);\n      }\n    },\n\n    /**\n     * Iterates over a NodeList, calls `filterFn` for each node and removes node\n     * if function returned `true`.\n     *\n     * If function is not passed, removes all the nodes in node list.\n     *\n     * @param NodeList nodeList The nodes to operate on\n     * @param Function filterFn the function to use as a filter\n     * @return void\n     */\n    _removeNodes(nodeList, filterFn) {\n      // Avoid ever operating on live node lists.\n      if (this._docJSDOMParser && nodeList._isLiveNodeList) {\n        throw new Error('Do not pass live node lists to _removeNodes');\n      }\n      for (var i = nodeList.length - 1; i >= 0; i--) {\n        var node = nodeList[i];\n        var parentNode = node.parentNode;\n        if (parentNode) {\n          if (!filterFn || filterFn.call(this, node, i, nodeList)) {\n            parentNode.removeChild(node);\n          }\n        }\n      }\n    },\n\n    /**\n     * Iterates over a NodeList, and calls _setNodeTag for each node.\n     *\n     * @param NodeList nodeList The nodes to operate on\n     * @param String newTagName the new tag name to use\n     * @return void\n     */\n    _replaceNodeTags(nodeList, newTagName) {\n      // Avoid ever operating on live node lists.\n      if (this._docJSDOMParser && nodeList._isLiveNodeList) {\n        throw new Error('Do not pass live node lists to _replaceNodeTags');\n      }\n      for (const node of nodeList) {\n        this._setNodeTag(node, newTagName);\n      }\n    },\n\n    /**\n     * Iterate over a NodeList, which doesn't natively fully implement the Array\n     * interface.\n     *\n     * For convenience, the current object context is applied to the provided\n     * iterate function.\n     *\n     * @param  NodeList nodeList The NodeList.\n     * @param  Function fn       The iterate function.\n     * @return void\n     */\n    _forEachNode(nodeList, fn) {\n      Array.prototype.forEach.call(nodeList, fn, this);\n    },\n\n    /**\n     * Iterate over a NodeList, and return the first node that passes\n     * the supplied test function\n     *\n     * For convenience, the current object context is applied to the provided\n     * test function.\n     *\n     * @param  NodeList nodeList The NodeList.\n     * @param  Function fn       The test function.\n     * @return void\n     */\n    _findNode(nodeList, fn) {\n      return Array.prototype.find.call(nodeList, fn, this);\n    },\n\n    /**\n     * Iterate over a NodeList, return true if any of the provided iterate\n     * function calls returns true, false otherwise.\n     *\n     * For convenience, the current object context is applied to the\n     * provided iterate function.\n     *\n     * @param  NodeList nodeList The NodeList.\n     * @param  Function fn       The iterate function.\n     * @return Boolean\n     */\n    _someNode(nodeList, fn) {\n      return Array.prototype.some.call(nodeList, fn, this);\n    },\n\n    /**\n     * Iterate over a NodeList, return true if all of the provided iterate\n     * function calls return true, false otherwise.\n     *\n     * For convenience, the current object context is applied to the\n     * provided iterate function.\n     *\n     * @param  NodeList nodeList The NodeList.\n     * @param  Function fn       The iterate function.\n     * @return Boolean\n     */\n    _everyNode(nodeList, fn) {\n      return Array.prototype.every.call(nodeList, fn, this);\n    },\n\n    _getAllNodesWithTag(node, tagNames) {\n      if (node.querySelectorAll) {\n        return node.querySelectorAll(tagNames.join(','));\n      }\n      return [].concat.apply(\n        [],\n        tagNames.map(function (tag) {\n          var collection = node.getElementsByTagName(tag);\n          return Array.isArray(collection) ? collection : Array.from(collection);\n        }),\n      );\n    },\n\n    /**\n     * Removes the class=\"\" attribute from every element in the given\n     * subtree, except those that match CLASSES_TO_PRESERVE and\n     * the classesToPreserve array from the options object.\n     *\n     * @param Element\n     * @return void\n     */\n    _cleanClasses(node) {\n      var classesToPreserve = this._classesToPreserve;\n      var className = (node.getAttribute('class') || '')\n        .split(/\\s+/)\n        .filter((cls) => classesToPreserve.includes(cls))\n        .join(' ');\n\n      if (className) {\n        node.setAttribute('class', className);\n      } else {\n        node.removeAttribute('class');\n      }\n\n      for (node = node.firstElementChild; node; node = node.nextElementSibling) {\n        this._cleanClasses(node);\n      }\n    },\n\n    /**\n     * Tests whether a string is a URL or not.\n     *\n     * @param {string} str The string to test\n     * @return {boolean} true if str is a URL, false if not\n     */\n    _isUrl(str) {\n      try {\n        new URL(str);\n        return true;\n      } catch {\n        return false;\n      }\n    },\n    /**\n     * Converts each <a> and <img> uri in the given element to an absolute URI,\n     * ignoring #ref URIs.\n     *\n     * @param Element\n     * @return void\n     */\n    _fixRelativeUris(articleContent) {\n      var baseURI = this._doc.baseURI;\n      var documentURI = this._doc.documentURI;\n      function toAbsoluteURI(uri) {\n        // Leave hash links alone if the base URI matches the document URI:\n        if (baseURI == documentURI && uri.charAt(0) == '#') {\n          return uri;\n        }\n\n        // Otherwise, resolve against base URI:\n        try {\n          return new URL(uri, baseURI).href;\n        } catch (ex) {\n          // Something went wrong, just return the original:\n        }\n        return uri;\n      }\n\n      var links = this._getAllNodesWithTag(articleContent, ['a']);\n      this._forEachNode(links, function (link) {\n        var href = link.getAttribute('href');\n        if (href) {\n          // Remove links with javascript: URIs, since\n          // they won't work after scripts have been removed from the page.\n          if (href.indexOf('javascript:') === 0) {\n            // if the link only contains simple text content, it can be converted to a text node\n            if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) {\n              var text = this._doc.createTextNode(link.textContent);\n              link.parentNode.replaceChild(text, link);\n            } else {\n              // if the link has multiple children, they should all be preserved\n              var container = this._doc.createElement('span');\n              while (link.firstChild) {\n                container.appendChild(link.firstChild);\n              }\n              link.parentNode.replaceChild(container, link);\n            }\n          } else {\n            link.setAttribute('href', toAbsoluteURI(href));\n          }\n        }\n      });\n\n      var medias = this._getAllNodesWithTag(articleContent, [\n        'img',\n        'picture',\n        'figure',\n        'video',\n        'audio',\n        'source',\n      ]);\n\n      this._forEachNode(medias, function (media) {\n        var src = media.getAttribute('src');\n        var poster = media.getAttribute('poster');\n        var srcset = media.getAttribute('srcset');\n\n        if (src) {\n          media.setAttribute('src', toAbsoluteURI(src));\n        }\n\n        if (poster) {\n          media.setAttribute('poster', toAbsoluteURI(poster));\n        }\n\n        if (srcset) {\n          var newSrcset = srcset.replace(this.REGEXPS.srcsetUrl, function (_, p1, p2, p3) {\n            return toAbsoluteURI(p1) + (p2 || '') + p3;\n          });\n\n          media.setAttribute('srcset', newSrcset);\n        }\n      });\n    },\n\n    _simplifyNestedElements(articleContent) {\n      var node = articleContent;\n\n      while (node) {\n        if (\n          node.parentNode &&\n          ['DIV', 'SECTION'].includes(node.tagName) &&\n          !(node.id && node.id.startsWith('readability'))\n        ) {\n          if (this._isElementWithoutContent(node)) {\n            node = this._removeAndGetNext(node);\n            continue;\n          } else if (\n            this._hasSingleTagInsideElement(node, 'DIV') ||\n            this._hasSingleTagInsideElement(node, 'SECTION')\n          ) {\n            var child = node.children[0];\n            for (var i = 0; i < node.attributes.length; i++) {\n              child.setAttributeNode(node.attributes[i].cloneNode());\n            }\n            node.parentNode.replaceChild(child, node);\n            node = child;\n            continue;\n          }\n        }\n\n        node = this._getNextNode(node);\n      }\n    },\n\n    /**\n     * Get the article title as an H1.\n     *\n     * @return string\n     **/\n    _getArticleTitle() {\n      var doc = this._doc;\n      var curTitle = '';\n      var origTitle = '';\n\n      try {\n        curTitle = origTitle = doc.title.trim();\n\n        // If they had an element with id \"title\" in their HTML\n        if (typeof curTitle !== 'string') {\n          curTitle = origTitle = this._getInnerText(doc.getElementsByTagName('title')[0]);\n        }\n      } catch (e) {\n        /* ignore exceptions setting the title. */\n      }\n\n      var titleHadHierarchicalSeparators = false;\n      function wordCount(str) {\n        return str.split(/\\s+/).length;\n      }\n\n      // If there's a separator in the title, first remove the final part\n      if (/ [\\|\\-\\\\\\/>»] /.test(curTitle)) {\n        titleHadHierarchicalSeparators = / [\\\\\\/>»] /.test(curTitle);\n        let allSeparators = Array.from(origTitle.matchAll(/ [\\|\\-\\\\\\/>»] /gi));\n        curTitle = origTitle.substring(0, allSeparators.pop().index);\n\n        // If the resulting title is too short, remove the first part instead:\n        if (wordCount(curTitle) < 3) {\n          curTitle = origTitle.replace(/^[^\\|\\-\\\\\\/>»]*[\\|\\-\\\\\\/>»]/gi, '');\n        }\n      } else if (curTitle.includes(': ')) {\n        // Check if we have an heading containing this exact string, so we\n        // could assume it's the full title.\n        var headings = this._getAllNodesWithTag(doc, ['h1', 'h2']);\n        var trimmedTitle = curTitle.trim();\n        var match = this._someNode(headings, function (heading) {\n          return heading.textContent.trim() === trimmedTitle;\n        });\n\n        // If we don't, let's extract the title out of the original title string.\n        if (!match) {\n          curTitle = origTitle.substring(origTitle.lastIndexOf(':') + 1);\n\n          // If the title is now too short, try the first colon instead:\n          if (wordCount(curTitle) < 3) {\n            curTitle = origTitle.substring(origTitle.indexOf(':') + 1);\n            // But if we have too many words before the colon there's something weird\n            // with the titles and the H tags so let's just use the original title instead\n          } else if (wordCount(origTitle.substr(0, origTitle.indexOf(':'))) > 5) {\n            curTitle = origTitle;\n          }\n        }\n      } else if (curTitle.length > 150 || curTitle.length < 15) {\n        var hOnes = doc.getElementsByTagName('h1');\n\n        if (hOnes.length === 1) {\n          curTitle = this._getInnerText(hOnes[0]);\n        }\n      }\n\n      curTitle = curTitle.trim().replace(this.REGEXPS.normalize, ' ');\n      // If we now have 4 words or fewer as our title, and either no\n      // 'hierarchical' separators (\\, /, > or ») were found in the original\n      // title or we decreased the number of words by more than 1 word, use\n      // the original title.\n      var curTitleWordCount = wordCount(curTitle);\n      if (\n        curTitleWordCount <= 4 &&\n        (!titleHadHierarchicalSeparators ||\n          curTitleWordCount != wordCount(origTitle.replace(/[\\|\\-\\\\\\/>»]+/g, '')) - 1)\n      ) {\n        curTitle = origTitle;\n      }\n\n      return curTitle;\n    },\n\n    /**\n     * Prepare the HTML document for readability to scrape it.\n     * This includes things like stripping javascript, CSS, and handling terrible markup.\n     *\n     * @return void\n     **/\n    _prepDocument() {\n      var doc = this._doc;\n\n      // Remove all style tags in head\n      this._removeNodes(this._getAllNodesWithTag(doc, ['style']));\n\n      if (doc.body) {\n        this._replaceBrs(doc.body);\n      }\n\n      this._replaceNodeTags(this._getAllNodesWithTag(doc, ['font']), 'SPAN');\n    },\n\n    /**\n     * Finds the next node, starting from the given node, and ignoring\n     * whitespace in between. If the given node is an element, the same node is\n     * returned.\n     */\n    _nextNode(node) {\n      var next = node;\n      while (\n        next &&\n        next.nodeType != this.ELEMENT_NODE &&\n        this.REGEXPS.whitespace.test(next.textContent)\n      ) {\n        next = next.nextSibling;\n      }\n      return next;\n    },\n\n    /**\n     * Replaces 2 or more successive <br> elements with a single <p>.\n     * Whitespace between <br> elements are ignored. For example:\n     *   <div>foo<br>bar<br> <br><br>abc</div>\n     * will become:\n     *   <div>foo<br>bar<p>abc</p></div>\n     */\n    _replaceBrs(elem) {\n      this._forEachNode(this._getAllNodesWithTag(elem, ['br']), function (br) {\n        var next = br.nextSibling;\n\n        // Whether 2 or more <br> elements have been found and replaced with a\n        // <p> block.\n        var replaced = false;\n\n        // If we find a <br> chain, remove the <br>s until we hit another node\n        // or non-whitespace. This leaves behind the first <br> in the chain\n        // (which will be replaced with a <p> later).\n        while ((next = this._nextNode(next)) && next.tagName == 'BR') {\n          replaced = true;\n          var brSibling = next.nextSibling;\n          next.remove();\n          next = brSibling;\n        }\n\n        // If we removed a <br> chain, replace the remaining <br> with a <p>. Add\n        // all sibling nodes as children of the <p> until we hit another <br>\n        // chain.\n        if (replaced) {\n          var p = this._doc.createElement('p');\n          br.parentNode.replaceChild(p, br);\n\n          next = p.nextSibling;\n          while (next) {\n            // If we've hit another <br><br>, we're done adding children to this <p>.\n            if (next.tagName == 'BR') {\n              var nextElem = this._nextNode(next.nextSibling);\n              if (nextElem && nextElem.tagName == 'BR') {\n                break;\n              }\n            }\n\n            if (!this._isPhrasingContent(next)) {\n              break;\n            }\n\n            // Otherwise, make this node a child of the new <p>.\n            var sibling = next.nextSibling;\n            p.appendChild(next);\n            next = sibling;\n          }\n\n          while (p.lastChild && this._isWhitespace(p.lastChild)) {\n            p.lastChild.remove();\n          }\n\n          if (p.parentNode.tagName === 'P') {\n            this._setNodeTag(p.parentNode, 'DIV');\n          }\n        }\n      });\n    },\n\n    _setNodeTag(node, tag) {\n      this.log('_setNodeTag', node, tag);\n      if (this._docJSDOMParser) {\n        node.localName = tag.toLowerCase();\n        node.tagName = tag.toUpperCase();\n        return node;\n      }\n\n      var replacement = node.ownerDocument.createElement(tag);\n      while (node.firstChild) {\n        replacement.appendChild(node.firstChild);\n      }\n      node.parentNode.replaceChild(replacement, node);\n      if (node.readability) {\n        replacement.readability = node.readability;\n      }\n\n      for (var i = 0; i < node.attributes.length; i++) {\n        replacement.setAttributeNode(node.attributes[i].cloneNode());\n      }\n      return replacement;\n    },\n\n    /**\n     * Prepare the article node for display. Clean out any inline styles,\n     * iframes, forms, strip extraneous <p> tags, etc.\n     *\n     * @param Element\n     * @return void\n     **/\n    _prepArticle(articleContent) {\n      this._cleanStyles(articleContent);\n\n      // Check for data tables before we continue, to avoid removing items in\n      // those tables, which will often be isolated even though they're\n      // visually linked to other content-ful elements (text, images, etc.).\n      this._markDataTables(articleContent);\n\n      this._fixLazyImages(articleContent);\n\n      // Clean out junk from the article content\n      this._cleanConditionally(articleContent, 'form');\n      this._cleanConditionally(articleContent, 'fieldset');\n      this._clean(articleContent, 'object');\n      this._clean(articleContent, 'embed');\n      this._clean(articleContent, 'footer');\n      this._clean(articleContent, 'link');\n      this._clean(articleContent, 'aside');\n\n      // Clean out elements with little content that have \"share\" in their id/class combinations from final top candidates,\n      // which means we don't remove the top candidates even they have \"share\".\n\n      var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD;\n\n      this._forEachNode(articleContent.children, function (topCandidate) {\n        this._cleanMatchedNodes(topCandidate, function (node, matchString) {\n          return (\n            this.REGEXPS.shareElements.test(matchString) &&\n            node.textContent.length < shareElementThreshold\n          );\n        });\n      });\n\n      this._clean(articleContent, 'iframe');\n      this._clean(articleContent, 'input');\n      this._clean(articleContent, 'textarea');\n      this._clean(articleContent, 'select');\n      this._clean(articleContent, 'button');\n      this._cleanHeaders(articleContent);\n\n      // Do these last as the previous stuff may have removed junk\n      // that will affect these\n      this._cleanConditionally(articleContent, 'table');\n      this._cleanConditionally(articleContent, 'ul');\n      this._cleanConditionally(articleContent, 'div');\n\n      // replace H1 with H2 as H1 should be only title that is displayed separately\n      this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ['h1']), 'h2');\n\n      // Remove extra paragraphs\n      this._removeNodes(this._getAllNodesWithTag(articleContent, ['p']), function (paragraph) {\n        // At this point, nasty iframes have been removed; only embedded video\n        // ones remain.\n        var contentElementCount = this._getAllNodesWithTag(paragraph, [\n          'img',\n          'embed',\n          'object',\n          'iframe',\n        ]).length;\n        return contentElementCount === 0 && !this._getInnerText(paragraph, false);\n      });\n\n      this._forEachNode(this._getAllNodesWithTag(articleContent, ['br']), function (br) {\n        var next = this._nextNode(br.nextSibling);\n        if (next && next.tagName == 'P') {\n          br.remove();\n        }\n      });\n\n      // Remove single-cell tables\n      this._forEachNode(this._getAllNodesWithTag(articleContent, ['table']), function (table) {\n        var tbody = this._hasSingleTagInsideElement(table, 'TBODY')\n          ? table.firstElementChild\n          : table;\n        if (this._hasSingleTagInsideElement(tbody, 'TR')) {\n          var row = tbody.firstElementChild;\n          if (this._hasSingleTagInsideElement(row, 'TD')) {\n            var cell = row.firstElementChild;\n            cell = this._setNodeTag(\n              cell,\n              this._everyNode(cell.childNodes, this._isPhrasingContent) ? 'P' : 'DIV',\n            );\n            table.parentNode.replaceChild(cell, table);\n          }\n        }\n      });\n    },\n\n    /**\n     * Initialize a node with the readability object. Also checks the\n     * className/id for special names to add to its score.\n     *\n     * @param Element\n     * @return void\n     **/\n    _initializeNode(node) {\n      node.readability = { contentScore: 0 };\n\n      switch (node.tagName) {\n        case 'DIV':\n          node.readability.contentScore += 5;\n          break;\n\n        case 'PRE':\n        case 'TD':\n        case 'BLOCKQUOTE':\n          node.readability.contentScore += 3;\n          break;\n\n        case 'ADDRESS':\n        case 'OL':\n        case 'UL':\n        case 'DL':\n        case 'DD':\n        case 'DT':\n        case 'LI':\n        case 'FORM':\n          node.readability.contentScore -= 3;\n          break;\n\n        case 'H1':\n        case 'H2':\n        case 'H3':\n        case 'H4':\n        case 'H5':\n        case 'H6':\n        case 'TH':\n          node.readability.contentScore -= 5;\n          break;\n      }\n\n      node.readability.contentScore += this._getClassWeight(node);\n    },\n\n    _removeAndGetNext(node) {\n      var nextNode = this._getNextNode(node, true);\n      node.remove();\n      return nextNode;\n    },\n\n    /**\n     * Traverse the DOM from node to node, starting at the node passed in.\n     * Pass true for the second parameter to indicate this node itself\n     * (and its kids) are going away, and we want the next node over.\n     *\n     * Calling this in a loop will traverse the DOM depth-first.\n     *\n     * @param {Element} node\n     * @param {boolean} ignoreSelfAndKids\n     * @return {Element}\n     */\n    _getNextNode(node, ignoreSelfAndKids) {\n      // First check for kids if those aren't being ignored\n      if (!ignoreSelfAndKids && node.firstElementChild) {\n        return node.firstElementChild;\n      }\n      // Then for siblings...\n      if (node.nextElementSibling) {\n        return node.nextElementSibling;\n      }\n      // And finally, move up the parent chain *and* find a sibling\n      // (because this is depth-first traversal, we will have already\n      // seen the parent nodes themselves).\n      do {\n        node = node.parentNode;\n      } while (node && !node.nextElementSibling);\n      return node && node.nextElementSibling;\n    },\n\n    // compares second text to first one\n    // 1 = same text, 0 = completely different text\n    // works the way that it splits both texts into words and then finds words that are unique in second text\n    // the result is given by the lower length of unique parts\n    _textSimilarity(textA, textB) {\n      var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean);\n      var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean);\n      if (!tokensA.length || !tokensB.length) {\n        return 0;\n      }\n      var uniqTokensB = tokensB.filter((token) => !tokensA.includes(token));\n      var distanceB = uniqTokensB.join(' ').length / tokensB.join(' ').length;\n      return 1 - distanceB;\n    },\n\n    /**\n     * Checks whether an element node contains a valid byline\n     *\n     * @param node {Element}\n     * @param matchString {string}\n     * @return boolean\n     */\n    _isValidByline(node, matchString) {\n      var rel = node.getAttribute('rel');\n      var itemprop = node.getAttribute('itemprop');\n      var bylineLength = node.textContent.trim().length;\n\n      return (\n        (rel === 'author' ||\n          (itemprop && itemprop.includes('author')) ||\n          this.REGEXPS.byline.test(matchString)) &&\n        !!bylineLength &&\n        bylineLength < 100\n      );\n    },\n\n    _getNodeAncestors(node, maxDepth) {\n      maxDepth = maxDepth || 0;\n      var i = 0,\n        ancestors = [];\n      while (node.parentNode) {\n        ancestors.push(node.parentNode);\n        if (maxDepth && ++i === maxDepth) {\n          break;\n        }\n        node = node.parentNode;\n      }\n      return ancestors;\n    },\n\n    /***\n     * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is\n     *         most likely to be the stuff a user wants to read. Then return it wrapped up in a div.\n     *\n     * @param page a document to run upon. Needs to be a full document, complete with body.\n     * @return Element\n     **/\n\n    _grabArticle(page) {\n      this.log('**** grabArticle ****');\n      var doc = this._doc;\n      var isPaging = page !== null;\n      page = page ? page : this._doc.body;\n\n      // We can't grab an article if we don't have a page!\n      if (!page) {\n        this.log('No body found in document. Abort.');\n        return null;\n      }\n\n      var pageCacheHtml = page.innerHTML;\n\n      while (true) {\n        this.log('Starting grabArticle loop');\n        var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS);\n\n        // First, node prepping. Trash nodes that look cruddy (like ones with the\n        // class name \"comment\", etc), and turn divs into P tags where they have been\n        // used inappropriately (as in, where they contain no other block level elements.)\n        var elementsToScore = [];\n        var node = this._doc.documentElement;\n\n        let shouldRemoveTitleHeader = true;\n\n        while (node) {\n          if (node.tagName === 'HTML') {\n            this._articleLang = node.getAttribute('lang');\n          }\n\n          var matchString = node.className + ' ' + node.id;\n\n          if (!this._isProbablyVisible(node)) {\n            this.log('Removing hidden node - ' + matchString);\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          // User is not able to see elements applied with both \"aria-modal = true\" and \"role = dialog\"\n          if (node.getAttribute('aria-modal') == 'true' && node.getAttribute('role') == 'dialog') {\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          // If we don't have a byline yet check to see if this node is a byline; if it is store the byline and remove the node.\n          if (\n            !this._articleByline &&\n            !this._metadata.byline &&\n            this._isValidByline(node, matchString)\n          ) {\n            // Find child node matching [itemprop=\"name\"] and use that if it exists for a more accurate author name byline\n            var endOfSearchMarkerNode = this._getNextNode(node, true);\n            var next = this._getNextNode(node);\n            var itemPropNameNode = null;\n            while (next && next != endOfSearchMarkerNode) {\n              var itemprop = next.getAttribute('itemprop');\n              if (itemprop && itemprop.includes('name')) {\n                itemPropNameNode = next;\n                break;\n              } else {\n                next = this._getNextNode(next);\n              }\n            }\n            this._articleByline = (itemPropNameNode ?? node).textContent.trim();\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) {\n            this.log('Removing header: ', node.textContent.trim(), this._articleTitle.trim());\n            shouldRemoveTitleHeader = false;\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          // Remove unlikely candidates\n          if (stripUnlikelyCandidates) {\n            if (\n              this.REGEXPS.unlikelyCandidates.test(matchString) &&\n              !this.REGEXPS.okMaybeItsACandidate.test(matchString) &&\n              !this._hasAncestorTag(node, 'table') &&\n              !this._hasAncestorTag(node, 'code') &&\n              node.tagName !== 'BODY' &&\n              node.tagName !== 'A'\n            ) {\n              this.log('Removing unlikely candidate - ' + matchString);\n              node = this._removeAndGetNext(node);\n              continue;\n            }\n\n            if (this.UNLIKELY_ROLES.includes(node.getAttribute('role'))) {\n              this.log(\n                'Removing content with role ' + node.getAttribute('role') + ' - ' + matchString,\n              );\n              node = this._removeAndGetNext(node);\n              continue;\n            }\n          }\n\n          // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe).\n          if (\n            (node.tagName === 'DIV' ||\n              node.tagName === 'SECTION' ||\n              node.tagName === 'HEADER' ||\n              node.tagName === 'H1' ||\n              node.tagName === 'H2' ||\n              node.tagName === 'H3' ||\n              node.tagName === 'H4' ||\n              node.tagName === 'H5' ||\n              node.tagName === 'H6') &&\n            this._isElementWithoutContent(node)\n          ) {\n            node = this._removeAndGetNext(node);\n            continue;\n          }\n\n          if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) {\n            elementsToScore.push(node);\n          }\n\n          // Turn all divs that don't have children block level elements into p's\n          if (node.tagName === 'DIV') {\n            // Put phrasing content into paragraphs.\n            var p = null;\n            var childNode = node.firstChild;\n            while (childNode) {\n              var nextSibling = childNode.nextSibling;\n              if (this._isPhrasingContent(childNode)) {\n                if (p !== null) {\n                  p.appendChild(childNode);\n                } else if (!this._isWhitespace(childNode)) {\n                  p = doc.createElement('p');\n                  node.replaceChild(p, childNode);\n                  p.appendChild(childNode);\n                }\n              } else if (p !== null) {\n                while (p.lastChild && this._isWhitespace(p.lastChild)) {\n                  p.lastChild.remove();\n                }\n                p = null;\n              }\n              childNode = nextSibling;\n            }\n\n            // Sites like http://mobile.slate.com encloses each paragraph with a DIV\n            // element. DIVs with only a P element inside and no text content can be\n            // safely converted into plain P elements to avoid confusing the scoring\n            // algorithm with DIVs with are, in practice, paragraphs.\n            if (this._hasSingleTagInsideElement(node, 'P') && this._getLinkDensity(node) < 0.25) {\n              var newNode = node.children[0];\n              node.parentNode.replaceChild(newNode, node);\n              node = newNode;\n              elementsToScore.push(node);\n            } else if (!this._hasChildBlockElement(node)) {\n              node = this._setNodeTag(node, 'P');\n              elementsToScore.push(node);\n            }\n          }\n          node = this._getNextNode(node);\n        }\n\n        /**\n         * Loop through all paragraphs, and assign a score to them based on how content-y they look.\n         * Then add their score to their parent node.\n         *\n         * A score is determined by things like number of commas, class names, etc. Maybe eventually link density.\n         **/\n        var candidates = [];\n        this._forEachNode(elementsToScore, function (elementToScore) {\n          if (\n            !elementToScore.parentNode ||\n            typeof elementToScore.parentNode.tagName === 'undefined'\n          ) {\n            return;\n          }\n\n          // If this paragraph is less than 25 characters, don't even count it.\n          var innerText = this._getInnerText(elementToScore);\n          if (innerText.length < 25) {\n            return;\n          }\n\n          // Exclude nodes with no ancestor.\n          var ancestors = this._getNodeAncestors(elementToScore, 5);\n          if (ancestors.length === 0) {\n            return;\n          }\n\n          var contentScore = 0;\n\n          // Add a point for the paragraph itself as a base.\n          contentScore += 1;\n\n          // Add points for any commas within this paragraph.\n          contentScore += innerText.split(this.REGEXPS.commas).length;\n\n          // For every 100 characters in this paragraph, add another point. Up to 3 points.\n          contentScore += Math.min(Math.floor(innerText.length / 100), 3);\n\n          // Initialize and score ancestors.\n          this._forEachNode(ancestors, function (ancestor, level) {\n            if (\n              !ancestor.tagName ||\n              !ancestor.parentNode ||\n              typeof ancestor.parentNode.tagName === 'undefined'\n            ) {\n              return;\n            }\n\n            if (typeof ancestor.readability === 'undefined') {\n              this._initializeNode(ancestor);\n              candidates.push(ancestor);\n            }\n\n            // Node score divider:\n            // - parent:             1 (no division)\n            // - grandparent:        2\n            // - great grandparent+: ancestor level * 3\n            if (level === 0) {\n              var scoreDivider = 1;\n            } else if (level === 1) {\n              scoreDivider = 2;\n            } else {\n              scoreDivider = level * 3;\n            }\n            ancestor.readability.contentScore += contentScore / scoreDivider;\n          });\n        });\n\n        // After we've calculated scores, loop through all of the possible\n        // candidate nodes we found and find the one with the highest score.\n        var topCandidates = [];\n        for (var c = 0, cl = candidates.length; c < cl; c += 1) {\n          var candidate = candidates[c];\n\n          // Scale the final candidates score based on link density. Good content\n          // should have a relatively small link density (5% or less) and be mostly\n          // unaffected by this operation.\n          var candidateScore =\n            candidate.readability.contentScore * (1 - this._getLinkDensity(candidate));\n          candidate.readability.contentScore = candidateScore;\n\n          this.log('Candidate:', candidate, 'with score ' + candidateScore);\n\n          for (var t = 0; t < this._nbTopCandidates; t++) {\n            var aTopCandidate = topCandidates[t];\n\n            if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) {\n              topCandidates.splice(t, 0, candidate);\n              if (topCandidates.length > this._nbTopCandidates) {\n                topCandidates.pop();\n              }\n              break;\n            }\n          }\n        }\n\n        var topCandidate = topCandidates[0] || null;\n        var neededToCreateTopCandidate = false;\n        var parentOfTopCandidate;\n\n        // If we still have no top candidate, just use the body as a last resort.\n        // We also have to copy the body node so it is something we can modify.\n        if (topCandidate === null || topCandidate.tagName === 'BODY') {\n          // Move all of the page's children into topCandidate\n          topCandidate = doc.createElement('DIV');\n          neededToCreateTopCandidate = true;\n          // Move everything (not just elements, also text nodes etc.) into the container\n          // so we even include text directly in the body:\n          while (page.firstChild) {\n            this.log('Moving child out:', page.firstChild);\n            topCandidate.appendChild(page.firstChild);\n          }\n\n          page.appendChild(topCandidate);\n\n          this._initializeNode(topCandidate);\n        } else if (topCandidate) {\n          // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array\n          // and whose scores are quite closed with current `topCandidate` node.\n          var alternativeCandidateAncestors = [];\n          for (var i = 1; i < topCandidates.length; i++) {\n            if (\n              topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >=\n              0.75\n            ) {\n              alternativeCandidateAncestors.push(this._getNodeAncestors(topCandidates[i]));\n            }\n          }\n          var MINIMUM_TOPCANDIDATES = 3;\n          if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) {\n            parentOfTopCandidate = topCandidate.parentNode;\n            while (parentOfTopCandidate && parentOfTopCandidate.tagName !== 'BODY') {\n              var listsContainingThisAncestor = 0;\n              for (\n                var ancestorIndex = 0;\n                ancestorIndex < alternativeCandidateAncestors.length &&\n                listsContainingThisAncestor < MINIMUM_TOPCANDIDATES;\n                ancestorIndex++\n              ) {\n                listsContainingThisAncestor += Number(\n                  alternativeCandidateAncestors[ancestorIndex].includes(parentOfTopCandidate),\n                );\n              }\n              if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) {\n                topCandidate = parentOfTopCandidate;\n                break;\n              }\n              parentOfTopCandidate = parentOfTopCandidate.parentNode;\n            }\n          }\n          if (!topCandidate.readability) {\n            this._initializeNode(topCandidate);\n          }\n\n          // Because of our bonus system, parents of candidates might have scores\n          // themselves. They get half of the node. There won't be nodes with higher\n          // scores than our topCandidate, but if we see the score going *up* in the first\n          // few steps up the tree, that's a decent sign that there might be more content\n          // lurking in other places that we want to unify in. The sibling stuff\n          // below does some of that - but only if we've looked high enough up the DOM\n          // tree.\n          parentOfTopCandidate = topCandidate.parentNode;\n          var lastScore = topCandidate.readability.contentScore;\n          // The scores shouldn't get too low.\n          var scoreThreshold = lastScore / 3;\n          while (parentOfTopCandidate && parentOfTopCandidate.tagName !== 'BODY') {\n            if (!parentOfTopCandidate.readability) {\n              parentOfTopCandidate = parentOfTopCandidate.parentNode;\n              continue;\n            }\n            var parentScore = parentOfTopCandidate.readability.contentScore;\n            if (parentScore < scoreThreshold) {\n              break;\n            }\n            if (parentScore > lastScore) {\n              // Alright! We found a better parent to use.\n              topCandidate = parentOfTopCandidate;\n              break;\n            }\n            lastScore = parentOfTopCandidate.readability.contentScore;\n            parentOfTopCandidate = parentOfTopCandidate.parentNode;\n          }\n\n          // If the top candidate is the only child, use parent instead. This will help sibling\n          // joining logic when adjacent content is actually located in parent's sibling node.\n          parentOfTopCandidate = topCandidate.parentNode;\n          while (\n            parentOfTopCandidate &&\n            parentOfTopCandidate.tagName != 'BODY' &&\n            parentOfTopCandidate.children.length == 1\n          ) {\n            topCandidate = parentOfTopCandidate;\n            parentOfTopCandidate = topCandidate.parentNode;\n          }\n          if (!topCandidate.readability) {\n            this._initializeNode(topCandidate);\n          }\n        }\n\n        // Now that we have the top candidate, look through its siblings for content\n        // that might also be related. Things like preambles, content split by ads\n        // that we removed, etc.\n        var articleContent = doc.createElement('DIV');\n        if (isPaging) {\n          articleContent.id = 'readability-content';\n        }\n\n        var siblingScoreThreshold = Math.max(10, topCandidate.readability.contentScore * 0.2);\n        // Keep potential top candidate's parent node to try to get text direction of it later.\n        parentOfTopCandidate = topCandidate.parentNode;\n        var siblings = parentOfTopCandidate.children;\n\n        for (var s = 0, sl = siblings.length; s < sl; s++) {\n          var sibling = siblings[s];\n          var append = false;\n\n          this.log(\n            'Looking at sibling node:',\n            sibling,\n            sibling.readability ? 'with score ' + sibling.readability.contentScore : '',\n          );\n          this.log(\n            'Sibling has score',\n            sibling.readability ? sibling.readability.contentScore : 'Unknown',\n          );\n\n          if (sibling === topCandidate) {\n            append = true;\n          } else {\n            var contentBonus = 0;\n\n            // Give a bonus if sibling nodes and top candidates have the example same classname\n            if (sibling.className === topCandidate.className && topCandidate.className !== '') {\n              contentBonus += topCandidate.readability.contentScore * 0.2;\n            }\n\n            if (\n              sibling.readability &&\n              sibling.readability.contentScore + contentBonus >= siblingScoreThreshold\n            ) {\n              append = true;\n            } else if (sibling.nodeName === 'P') {\n              var linkDensity = this._getLinkDensity(sibling);\n              var nodeContent = this._getInnerText(sibling);\n              var nodeLength = nodeContent.length;\n\n              if (nodeLength > 80 && linkDensity < 0.25) {\n                append = true;\n              } else if (\n                nodeLength < 80 &&\n                nodeLength > 0 &&\n                linkDensity === 0 &&\n                nodeContent.search(/\\.( |$)/) !== -1\n              ) {\n                append = true;\n              }\n            }\n          }\n\n          if (append) {\n            this.log('Appending node:', sibling);\n\n            if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) {\n              // We have a node that isn't a common block level element, like a form or td tag.\n              // Turn it into a div so it doesn't get filtered out later by accident.\n              this.log('Altering sibling:', sibling, 'to div.');\n\n              sibling = this._setNodeTag(sibling, 'DIV');\n            }\n\n            articleContent.appendChild(sibling);\n            // Fetch children again to make it compatible\n            // with DOM parsers without live collection support.\n            siblings = parentOfTopCandidate.children;\n            // siblings is a reference to the children array, and\n            // sibling is removed from the array when we call appendChild().\n            // As a result, we must revisit this index since the nodes\n            // have been shifted.\n            s -= 1;\n            sl -= 1;\n          }\n        }\n\n        if (this._debug) {\n          this.log('Article content pre-prep: ' + articleContent.innerHTML);\n        }\n        // So we have all of the content that we need. Now we clean it up for presentation.\n        this._prepArticle(articleContent);\n        if (this._debug) {\n          this.log('Article content post-prep: ' + articleContent.innerHTML);\n        }\n\n        if (neededToCreateTopCandidate) {\n          // We already created a fake div thing, and there wouldn't have been any siblings left\n          // for the previous loop, so there's no point trying to create a new div, and then\n          // move all the children over. Just assign IDs and class names here. No need to append\n          // because that already happened anyway.\n          topCandidate.id = 'readability-page-1';\n          topCandidate.className = 'page';\n        } else {\n          var div = doc.createElement('DIV');\n          div.id = 'readability-page-1';\n          div.className = 'page';\n          while (articleContent.firstChild) {\n            div.appendChild(articleContent.firstChild);\n          }\n          articleContent.appendChild(div);\n        }\n\n        if (this._debug) {\n          this.log('Article content after paging: ' + articleContent.innerHTML);\n        }\n\n        var parseSuccessful = true;\n\n        // Now that we've gone through the full algorithm, check to see if\n        // we got any meaningful content. If we didn't, we may need to re-run\n        // grabArticle with different flags set. This gives us a higher likelihood of\n        // finding the content, and the sieve approach gives us a higher likelihood of\n        // finding the -right- content.\n        var textLength = this._getInnerText(articleContent, true).length;\n        if (textLength < this._charThreshold) {\n          parseSuccessful = false;\n          // eslint-disable-next-line no-unsanitized/property\n          page.innerHTML = pageCacheHtml;\n\n          this._attempts.push({\n            articleContent,\n            textLength,\n          });\n\n          if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) {\n            this._removeFlag(this.FLAG_STRIP_UNLIKELYS);\n          } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) {\n            this._removeFlag(this.FLAG_WEIGHT_CLASSES);\n          } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) {\n            this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY);\n          } else {\n            // No luck after removing flags, just return the longest text we found during the different loops\n            this._attempts.sort(function (a, b) {\n              return b.textLength - a.textLength;\n            });\n\n            // But first check if we actually have something\n            if (!this._attempts[0].textLength) {\n              return null;\n            }\n\n            articleContent = this._attempts[0].articleContent;\n            parseSuccessful = true;\n          }\n        }\n\n        if (parseSuccessful) {\n          // Find out text direction from ancestors of final top candidate.\n          var ancestors = [parentOfTopCandidate, topCandidate].concat(\n            this._getNodeAncestors(parentOfTopCandidate),\n          );\n          this._someNode(ancestors, function (ancestor) {\n            if (!ancestor.tagName) {\n              return false;\n            }\n            var articleDir = ancestor.getAttribute('dir');\n            if (articleDir) {\n              this._articleDir = articleDir;\n              return true;\n            }\n            return false;\n          });\n          return articleContent;\n        }\n      }\n    },\n\n    /**\n     * Converts some of the common HTML entities in string to their corresponding characters.\n     *\n     * @param str {string} - a string to unescape.\n     * @return string without HTML entity.\n     */\n    _unescapeHtmlEntities(str) {\n      if (!str) {\n        return str;\n      }\n\n      var htmlEscapeMap = this.HTML_ESCAPE_MAP;\n      return str\n        .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) {\n          return htmlEscapeMap[tag];\n        })\n        .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) {\n          var num = parseInt(hex || numStr, hex ? 16 : 10);\n\n          // these character references are replaced by a conforming HTML parser\n          if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) {\n            num = 0xfffd;\n          }\n\n          return String.fromCodePoint(num);\n        });\n    },\n\n    /**\n     * Try to extract metadata from JSON-LD object.\n     * For now, only Schema.org objects of type Article or its subtypes are supported.\n     * @return Object with any metadata that could be extracted (possibly none)\n     */\n    _getJSONLD(doc) {\n      var scripts = this._getAllNodesWithTag(doc, ['script']);\n\n      var metadata;\n\n      this._forEachNode(scripts, function (jsonLdElement) {\n        if (!metadata && jsonLdElement.getAttribute('type') === 'application/ld+json') {\n          try {\n            // Strip CDATA markers if present\n            var content = jsonLdElement.textContent.replace(/^\\s*<!\\[CDATA\\[|\\]\\]>\\s*$/g, '');\n            var parsed = JSON.parse(content);\n\n            if (Array.isArray(parsed)) {\n              parsed = parsed.find((it) => {\n                return it['@type'] && it['@type'].match(this.REGEXPS.jsonLdArticleTypes);\n              });\n              if (!parsed) {\n                return;\n              }\n            }\n\n            var schemaDotOrgRegex = /^https?\\:\\/\\/schema\\.org\\/?$/;\n            var matches =\n              (typeof parsed['@context'] === 'string' &&\n                parsed['@context'].match(schemaDotOrgRegex)) ||\n              (typeof parsed['@context'] === 'object' &&\n                typeof parsed['@context']['@vocab'] == 'string' &&\n                parsed['@context']['@vocab'].match(schemaDotOrgRegex));\n\n            if (!matches) {\n              return;\n            }\n\n            if (!parsed['@type'] && Array.isArray(parsed['@graph'])) {\n              parsed = parsed['@graph'].find((it) => {\n                return (it['@type'] || '').match(this.REGEXPS.jsonLdArticleTypes);\n              });\n            }\n\n            if (\n              !parsed ||\n              !parsed['@type'] ||\n              !parsed['@type'].match(this.REGEXPS.jsonLdArticleTypes)\n            ) {\n              return;\n            }\n\n            metadata = {};\n\n            if (\n              typeof parsed.name === 'string' &&\n              typeof parsed.headline === 'string' &&\n              parsed.name !== parsed.headline\n            ) {\n              // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz\n              // put their own name into \"name\" and the article title to \"headline\" which confuses Readability. So we try to check if either\n              // \"name\" or \"headline\" closely matches the html title, and if so, use that one. If not, then we use \"name\" by default.\n\n              var title = this._getArticleTitle();\n              var nameMatches = this._textSimilarity(parsed.name, title) > 0.75;\n              var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75;\n\n              if (headlineMatches && !nameMatches) {\n                metadata.title = parsed.headline;\n              } else {\n                metadata.title = parsed.name;\n              }\n            } else if (typeof parsed.name === 'string') {\n              metadata.title = parsed.name.trim();\n            } else if (typeof parsed.headline === 'string') {\n              metadata.title = parsed.headline.trim();\n            }\n            if (parsed.author) {\n              if (typeof parsed.author.name === 'string') {\n                metadata.byline = parsed.author.name.trim();\n              } else if (\n                Array.isArray(parsed.author) &&\n                parsed.author[0] &&\n                typeof parsed.author[0].name === 'string'\n              ) {\n                metadata.byline = parsed.author\n                  .filter(function (author) {\n                    return author && typeof author.name === 'string';\n                  })\n                  .map(function (author) {\n                    return author.name.trim();\n                  })\n                  .join(', ');\n              }\n            }\n            if (typeof parsed.description === 'string') {\n              metadata.excerpt = parsed.description.trim();\n            }\n            if (parsed.publisher && typeof parsed.publisher.name === 'string') {\n              metadata.siteName = parsed.publisher.name.trim();\n            }\n            if (typeof parsed.datePublished === 'string') {\n              metadata.datePublished = parsed.datePublished.trim();\n            }\n          } catch (err) {\n            this.log(err.message);\n          }\n        }\n      });\n      return metadata ? metadata : {};\n    },\n\n    /**\n     * Attempts to get excerpt and byline metadata for the article.\n     *\n     * @param {Object} jsonld — object containing any metadata that\n     * could be extracted from JSON-LD object.\n     *\n     * @return Object with optional \"excerpt\" and \"byline\" properties\n     */\n    _getArticleMetadata(jsonld) {\n      var metadata = {};\n      var values = {};\n      var metaElements = this._doc.getElementsByTagName('meta');\n\n      // property is a space-separated list of values\n      var propertyPattern =\n        /\\s*(article|dc|dcterm|og|twitter)\\s*:\\s*(author|creator|description|published_time|title|site_name)\\s*/gi;\n\n      // name is a single value\n      var namePattern =\n        /^\\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\\s*[-\\.:]\\s*)?(author|creator|pub-date|description|title|site_name)\\s*$/i;\n\n      // Find description tags.\n      this._forEachNode(metaElements, function (element) {\n        var elementName = element.getAttribute('name');\n        var elementProperty = element.getAttribute('property');\n        var content = element.getAttribute('content');\n        if (!content) {\n          return;\n        }\n        var matches = null;\n        var name = null;\n\n        if (elementProperty) {\n          matches = elementProperty.match(propertyPattern);\n          if (matches) {\n            // Convert to lowercase, and remove any whitespace\n            // so we can match below.\n            name = matches[0].toLowerCase().replace(/\\s/g, '');\n            // multiple authors\n            values[name] = content.trim();\n          }\n        }\n        if (!matches && elementName && namePattern.test(elementName)) {\n          name = elementName;\n          if (content) {\n            // Convert to lowercase, remove any whitespace, and convert dots\n            // to colons so we can match below.\n            name = name.toLowerCase().replace(/\\s/g, '').replace(/\\./g, ':');\n            values[name] = content.trim();\n          }\n        }\n      });\n\n      // get title\n      metadata.title =\n        jsonld.title ||\n        values['dc:title'] ||\n        values['dcterm:title'] ||\n        values['og:title'] ||\n        values['weibo:article:title'] ||\n        values['weibo:webpage:title'] ||\n        values.title ||\n        values['twitter:title'] ||\n        values['parsely-title'];\n\n      if (!metadata.title) {\n        metadata.title = this._getArticleTitle();\n      }\n\n      const articleAuthor =\n        typeof values['article:author'] === 'string' && !this._isUrl(values['article:author'])\n          ? values['article:author']\n          : undefined;\n\n      // get author\n      metadata.byline =\n        jsonld.byline ||\n        values['dc:creator'] ||\n        values['dcterm:creator'] ||\n        values.author ||\n        values['parsely-author'] ||\n        articleAuthor;\n\n      // get description\n      metadata.excerpt =\n        jsonld.excerpt ||\n        values['dc:description'] ||\n        values['dcterm:description'] ||\n        values['og:description'] ||\n        values['weibo:article:description'] ||\n        values['weibo:webpage:description'] ||\n        values.description ||\n        values['twitter:description'];\n\n      // get site name\n      metadata.siteName = jsonld.siteName || values['og:site_name'];\n\n      // get article published time\n      metadata.publishedTime =\n        jsonld.datePublished ||\n        values['article:published_time'] ||\n        values['parsely-pub-date'] ||\n        null;\n\n      // in many sites the meta value is escaped with HTML entities,\n      // so here we need to unescape it\n      metadata.title = this._unescapeHtmlEntities(metadata.title);\n      metadata.byline = this._unescapeHtmlEntities(metadata.byline);\n      metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt);\n      metadata.siteName = this._unescapeHtmlEntities(metadata.siteName);\n      metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime);\n\n      return metadata;\n    },\n\n    /**\n     * Check if node is image, or if node contains exactly only one image\n     * whether as a direct child or as its descendants.\n     *\n     * @param Element\n     **/\n    _isSingleImage(node) {\n      while (node) {\n        if (node.tagName === 'IMG') {\n          return true;\n        }\n        if (node.children.length !== 1 || node.textContent.trim() !== '') {\n          return false;\n        }\n        node = node.children[0];\n      }\n      return false;\n    },\n\n    /**\n     * Find all <noscript> that are located after <img> nodes, and which contain only one\n     * <img> element. Replace the first image with the image from inside the <noscript> tag,\n     * and remove the <noscript> tag. This improves the quality of the images we use on\n     * some sites (e.g. Medium).\n     *\n     * @param Element\n     **/\n    _unwrapNoscriptImages(doc) {\n      // Find img without source or attributes that might contains image, and remove it.\n      // This is done to prevent a placeholder img is replaced by img from noscript in next step.\n      var imgs = Array.from(doc.getElementsByTagName('img'));\n      this._forEachNode(imgs, function (img) {\n        for (var i = 0; i < img.attributes.length; i++) {\n          var attr = img.attributes[i];\n          switch (attr.name) {\n            case 'src':\n            case 'srcset':\n            case 'data-src':\n            case 'data-srcset':\n              return;\n          }\n\n          if (/\\.(jpg|jpeg|png|webp)/i.test(attr.value)) {\n            return;\n          }\n        }\n\n        img.remove();\n      });\n\n      // Next find noscript and try to extract its image\n      var noscripts = Array.from(doc.getElementsByTagName('noscript'));\n      this._forEachNode(noscripts, function (noscript) {\n        // Parse content of noscript and make sure it only contains image\n        if (!this._isSingleImage(noscript)) {\n          return;\n        }\n        var tmp = doc.createElement('div');\n        // We're running in the document context, and using unmodified\n        // document contents, so doing this should be safe.\n        // (Also we heavily discourage people from allowing script to\n        // run at all in this document...)\n        // eslint-disable-next-line no-unsanitized/property\n        tmp.innerHTML = noscript.innerHTML;\n\n        // If noscript has previous sibling and it only contains image,\n        // replace it with noscript content. However we also keep old\n        // attributes that might contains image.\n        var prevElement = noscript.previousElementSibling;\n        if (prevElement && this._isSingleImage(prevElement)) {\n          var prevImg = prevElement;\n          if (prevImg.tagName !== 'IMG') {\n            prevImg = prevElement.getElementsByTagName('img')[0];\n          }\n\n          var newImg = tmp.getElementsByTagName('img')[0];\n          for (var i = 0; i < prevImg.attributes.length; i++) {\n            var attr = prevImg.attributes[i];\n            if (attr.value === '') {\n              continue;\n            }\n\n            if (\n              attr.name === 'src' ||\n              attr.name === 'srcset' ||\n              /\\.(jpg|jpeg|png|webp)/i.test(attr.value)\n            ) {\n              if (newImg.getAttribute(attr.name) === attr.value) {\n                continue;\n              }\n\n              var attrName = attr.name;\n              if (newImg.hasAttribute(attrName)) {\n                attrName = 'data-old-' + attrName;\n              }\n\n              newImg.setAttribute(attrName, attr.value);\n            }\n          }\n\n          noscript.parentNode.replaceChild(tmp.firstElementChild, prevElement);\n        }\n      });\n    },\n\n    /**\n     * Removes script tags from the document.\n     *\n     * @param Element\n     **/\n    _removeScripts(doc) {\n      this._removeNodes(this._getAllNodesWithTag(doc, ['script', 'noscript']));\n    },\n\n    /**\n     * Check if this node has only whitespace and a single element with given tag\n     * Returns false if the DIV node contains non-empty text nodes\n     * or if it contains no element with given tag or more than 1 element.\n     *\n     * @param Element\n     * @param string tag of child element\n     **/\n    _hasSingleTagInsideElement(element, tag) {\n      // There should be exactly 1 element child with given tag\n      if (element.children.length != 1 || element.children[0].tagName !== tag) {\n        return false;\n      }\n\n      // And there should be no text nodes with real content\n      return !this._someNode(element.childNodes, function (node) {\n        return node.nodeType === this.TEXT_NODE && this.REGEXPS.hasContent.test(node.textContent);\n      });\n    },\n\n    _isElementWithoutContent(node) {\n      return (\n        node.nodeType === this.ELEMENT_NODE &&\n        !node.textContent.trim().length &&\n        (!node.children.length ||\n          node.children.length ==\n            node.getElementsByTagName('br').length + node.getElementsByTagName('hr').length)\n      );\n    },\n\n    /**\n     * Determine whether element has any children block level elements.\n     *\n     * @param Element\n     */\n    _hasChildBlockElement(element) {\n      return this._someNode(element.childNodes, function (node) {\n        return this.DIV_TO_P_ELEMS.has(node.tagName) || this._hasChildBlockElement(node);\n      });\n    },\n\n    /***\n     * Determine if a node qualifies as phrasing content.\n     * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content\n     **/\n    _isPhrasingContent(node) {\n      return (\n        node.nodeType === this.TEXT_NODE ||\n        this.PHRASING_ELEMS.includes(node.tagName) ||\n        ((node.tagName === 'A' || node.tagName === 'DEL' || node.tagName === 'INS') &&\n          this._everyNode(node.childNodes, this._isPhrasingContent))\n      );\n    },\n\n    _isWhitespace(node) {\n      return (\n        (node.nodeType === this.TEXT_NODE && node.textContent.trim().length === 0) ||\n        (node.nodeType === this.ELEMENT_NODE && node.tagName === 'BR')\n      );\n    },\n\n    /**\n     * Get the inner text of a node - cross browser compatibly.\n     * This also strips out any excess whitespace to be found.\n     *\n     * @param Element\n     * @param Boolean normalizeSpaces (default: true)\n     * @return string\n     **/\n    _getInnerText(e, normalizeSpaces) {\n      normalizeSpaces = typeof normalizeSpaces === 'undefined' ? true : normalizeSpaces;\n      var textContent = e.textContent.trim();\n\n      if (normalizeSpaces) {\n        return textContent.replace(this.REGEXPS.normalize, ' ');\n      }\n      return textContent;\n    },\n\n    /**\n     * Get the number of times a string s appears in the node e.\n     *\n     * @param Element\n     * @param string - what to split on. Default is \",\"\n     * @return number (integer)\n     **/\n    _getCharCount(e, s) {\n      s = s || ',';\n      return this._getInnerText(e).split(s).length - 1;\n    },\n\n    /**\n     * Remove the style attribute on every e and under.\n     * TODO: Test if getElementsByTagName(*) is faster.\n     *\n     * @param Element\n     * @return void\n     **/\n    _cleanStyles(e) {\n      if (!e || e.tagName.toLowerCase() === 'svg') {\n        return;\n      }\n\n      // Remove `style` and deprecated presentational attributes\n      for (var i = 0; i < this.PRESENTATIONAL_ATTRIBUTES.length; i++) {\n        e.removeAttribute(this.PRESENTATIONAL_ATTRIBUTES[i]);\n      }\n\n      if (this.DEPRECATED_SIZE_ATTRIBUTE_ELEMS.includes(e.tagName)) {\n        e.removeAttribute('width');\n        e.removeAttribute('height');\n      }\n\n      var cur = e.firstElementChild;\n      while (cur !== null) {\n        this._cleanStyles(cur);\n        cur = cur.nextElementSibling;\n      }\n    },\n\n    /**\n     * Get the density of links as a percentage of the content\n     * This is the amount of text that is inside a link divided by the total text in the node.\n     *\n     * @param Element\n     * @return number (float)\n     **/\n    _getLinkDensity(element) {\n      var textLength = this._getInnerText(element).length;\n      if (textLength === 0) {\n        return 0;\n      }\n\n      var linkLength = 0;\n\n      // XXX implement _reduceNodeList?\n      this._forEachNode(element.getElementsByTagName('a'), function (linkNode) {\n        var href = linkNode.getAttribute('href');\n        var coefficient = href && this.REGEXPS.hashUrl.test(href) ? 0.3 : 1;\n        linkLength += this._getInnerText(linkNode).length * coefficient;\n      });\n\n      return linkLength / textLength;\n    },\n\n    /**\n     * Get an elements class/id weight. Uses regular expressions to tell if this\n     * element looks good or bad.\n     *\n     * @param Element\n     * @return number (Integer)\n     **/\n    _getClassWeight(e) {\n      if (!this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) {\n        return 0;\n      }\n\n      var weight = 0;\n\n      // Look for a special classname\n      if (typeof e.className === 'string' && e.className !== '') {\n        if (this.REGEXPS.negative.test(e.className)) {\n          weight -= 25;\n        }\n\n        if (this.REGEXPS.positive.test(e.className)) {\n          weight += 25;\n        }\n      }\n\n      // Look for a special ID\n      if (typeof e.id === 'string' && e.id !== '') {\n        if (this.REGEXPS.negative.test(e.id)) {\n          weight -= 25;\n        }\n\n        if (this.REGEXPS.positive.test(e.id)) {\n          weight += 25;\n        }\n      }\n\n      return weight;\n    },\n\n    /**\n     * Clean a node of all elements of type \"tag\".\n     * (Unless it's a youtube/vimeo video. People love movies.)\n     *\n     * @param Element\n     * @param string tag to clean\n     * @return void\n     **/\n    _clean(e, tag) {\n      var isEmbed = ['object', 'embed', 'iframe'].includes(tag);\n\n      this._removeNodes(this._getAllNodesWithTag(e, [tag]), function (element) {\n        // Allow youtube and vimeo videos through as people usually want to see those.\n        if (isEmbed) {\n          // First, check the elements attributes to see if any of them contain youtube or vimeo\n          for (var i = 0; i < element.attributes.length; i++) {\n            if (this._allowedVideoRegex.test(element.attributes[i].value)) {\n              return false;\n            }\n          }\n\n          // For embed with <object> tag, check inner HTML as well.\n          if (element.tagName === 'object' && this._allowedVideoRegex.test(element.innerHTML)) {\n            return false;\n          }\n        }\n\n        return true;\n      });\n    },\n\n    /**\n     * Check if a given node has one of its ancestor tag name matching the\n     * provided one.\n     * @param  HTMLElement node\n     * @param  String      tagName\n     * @param  Number      maxDepth\n     * @param  Function    filterFn a filter to invoke to determine whether this node 'counts'\n     * @return Boolean\n     */\n    _hasAncestorTag(node, tagName, maxDepth, filterFn) {\n      maxDepth = maxDepth || 3;\n      tagName = tagName.toUpperCase();\n      var depth = 0;\n      while (node.parentNode) {\n        if (maxDepth > 0 && depth > maxDepth) {\n          return false;\n        }\n        if (node.parentNode.tagName === tagName && (!filterFn || filterFn(node.parentNode))) {\n          return true;\n        }\n        node = node.parentNode;\n        depth++;\n      }\n      return false;\n    },\n\n    /**\n     * Return an object indicating how many rows and columns this table has.\n     */\n    _getRowAndColumnCount(table) {\n      var rows = 0;\n      var columns = 0;\n      var trs = table.getElementsByTagName('tr');\n      for (var i = 0; i < trs.length; i++) {\n        var rowspan = trs[i].getAttribute('rowspan') || 0;\n        if (rowspan) {\n          rowspan = parseInt(rowspan, 10);\n        }\n        rows += rowspan || 1;\n\n        // Now look for column-related info\n        var columnsInThisRow = 0;\n        var cells = trs[i].getElementsByTagName('td');\n        for (var j = 0; j < cells.length; j++) {\n          var colspan = cells[j].getAttribute('colspan') || 0;\n          if (colspan) {\n            colspan = parseInt(colspan, 10);\n          }\n          columnsInThisRow += colspan || 1;\n        }\n        columns = Math.max(columns, columnsInThisRow);\n      }\n      return { rows, columns };\n    },\n\n    /**\n     * Look for 'data' (as opposed to 'layout') tables, for which we use\n     * similar checks as\n     * https://searchfox.org/mozilla-central/rev/f82d5c549f046cb64ce5602bfd894b7ae807c8f8/accessible/generic/TableAccessible.cpp#19\n     */\n    _markDataTables(root) {\n      var tables = root.getElementsByTagName('table');\n      for (var i = 0; i < tables.length; i++) {\n        var table = tables[i];\n        var role = table.getAttribute('role');\n        if (role == 'presentation') {\n          table._readabilityDataTable = false;\n          continue;\n        }\n        var datatable = table.getAttribute('datatable');\n        if (datatable == '0') {\n          table._readabilityDataTable = false;\n          continue;\n        }\n        var summary = table.getAttribute('summary');\n        if (summary) {\n          table._readabilityDataTable = true;\n          continue;\n        }\n\n        var caption = table.getElementsByTagName('caption')[0];\n        if (caption && caption.childNodes.length) {\n          table._readabilityDataTable = true;\n          continue;\n        }\n\n        // If the table has a descendant with any of these tags, consider a data table:\n        var dataTableDescendants = ['col', 'colgroup', 'tfoot', 'thead', 'th'];\n        var descendantExists = function (tag) {\n          return !!table.getElementsByTagName(tag)[0];\n        };\n        if (dataTableDescendants.some(descendantExists)) {\n          this.log('Data table because found data-y descendant');\n          table._readabilityDataTable = true;\n          continue;\n        }\n\n        // Nested tables indicate a layout table:\n        if (table.getElementsByTagName('table')[0]) {\n          table._readabilityDataTable = false;\n          continue;\n        }\n\n        var sizeInfo = this._getRowAndColumnCount(table);\n\n        if (sizeInfo.columns == 1 || sizeInfo.rows == 1) {\n          // single colum/row tables are commonly used for page layout purposes.\n          table._readabilityDataTable = false;\n          continue;\n        }\n\n        if (sizeInfo.rows >= 10 || sizeInfo.columns > 4) {\n          table._readabilityDataTable = true;\n          continue;\n        }\n        // Now just go by size entirely:\n        table._readabilityDataTable = sizeInfo.rows * sizeInfo.columns > 10;\n      }\n    },\n\n    /* convert images and figures that have properties like data-src into images that can be loaded without JS */\n    _fixLazyImages(root) {\n      this._forEachNode(\n        this._getAllNodesWithTag(root, ['img', 'picture', 'figure']),\n        function (elem) {\n          // In some sites (e.g. Kotaku), they put 1px square image as base64 data uri in the src attribute.\n          // So, here we check if the data uri is too short, just might as well remove it.\n          if (elem.src && this.REGEXPS.b64DataUrl.test(elem.src)) {\n            // Make sure it's not SVG, because SVG can have a meaningful image in under 133 bytes.\n            var parts = this.REGEXPS.b64DataUrl.exec(elem.src);\n            if (parts[1] === 'image/svg+xml') {\n              return;\n            }\n\n            // Make sure this element has other attributes which contains image.\n            // If it doesn't, then this src is important and shouldn't be removed.\n            var srcCouldBeRemoved = false;\n            for (var i = 0; i < elem.attributes.length; i++) {\n              var attr = elem.attributes[i];\n              if (attr.name === 'src') {\n                continue;\n              }\n\n              if (/\\.(jpg|jpeg|png|webp)/i.test(attr.value)) {\n                srcCouldBeRemoved = true;\n                break;\n              }\n            }\n\n            // Here we assume if image is less than 100 bytes (or 133 after encoded to base64)\n            // it will be too small, therefore it might be placeholder image.\n            if (srcCouldBeRemoved) {\n              var b64starts = parts[0].length;\n              var b64length = elem.src.length - b64starts;\n              if (b64length < 133) {\n                elem.removeAttribute('src');\n              }\n            }\n          }\n\n          // also check for \"null\" to work around https://github.com/jsdom/jsdom/issues/2580\n          if (\n            (elem.src || (elem.srcset && elem.srcset != 'null')) &&\n            !elem.className.toLowerCase().includes('lazy')\n          ) {\n            return;\n          }\n\n          for (var j = 0; j < elem.attributes.length; j++) {\n            attr = elem.attributes[j];\n            if (attr.name === 'src' || attr.name === 'srcset' || attr.name === 'alt') {\n              continue;\n            }\n            var copyTo = null;\n            if (/\\.(jpg|jpeg|png|webp)\\s+\\d/.test(attr.value)) {\n              copyTo = 'srcset';\n            } else if (/^\\s*\\S+\\.(jpg|jpeg|png|webp)\\S*\\s*$/.test(attr.value)) {\n              copyTo = 'src';\n            }\n            if (copyTo) {\n              //if this is an img or picture, set the attribute directly\n              if (elem.tagName === 'IMG' || elem.tagName === 'PICTURE') {\n                elem.setAttribute(copyTo, attr.value);\n              } else if (\n                elem.tagName === 'FIGURE' &&\n                !this._getAllNodesWithTag(elem, ['img', 'picture']).length\n              ) {\n                //if the item is a <figure> that does not contain an image or picture, create one and place it inside the figure\n                //see the nytimes-3 testcase for an example\n                var img = this._doc.createElement('img');\n                img.setAttribute(copyTo, attr.value);\n                elem.appendChild(img);\n              }\n            }\n          }\n        },\n      );\n    },\n\n    _getTextDensity(e, tags) {\n      var textLength = this._getInnerText(e, true).length;\n      if (textLength === 0) {\n        return 0;\n      }\n      var childrenLength = 0;\n      var children = this._getAllNodesWithTag(e, tags);\n      this._forEachNode(\n        children,\n        (child) => (childrenLength += this._getInnerText(child, true).length),\n      );\n      return childrenLength / textLength;\n    },\n\n    /**\n     * Clean an element of all tags of type \"tag\" if they look fishy.\n     * \"Fishy\" is an algorithm based on content length, classnames, link density, number of images & embeds, etc.\n     *\n     * @return void\n     **/\n    _cleanConditionally(e, tag) {\n      if (!this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) {\n        return;\n      }\n\n      // Gather counts for other typical elements embedded within.\n      // Traverse backwards so we can remove nodes at the same time\n      // without effecting the traversal.\n      //\n      // TODO: Consider taking into account original contentScore here.\n      this._removeNodes(this._getAllNodesWithTag(e, [tag]), function (node) {\n        // First check if this node IS data table, in which case don't remove it.\n        var isDataTable = function (t) {\n          return t._readabilityDataTable;\n        };\n\n        var isList = tag === 'ul' || tag === 'ol';\n        if (!isList) {\n          var listLength = 0;\n          var listNodes = this._getAllNodesWithTag(node, ['ul', 'ol']);\n          this._forEachNode(listNodes, (list) => (listLength += this._getInnerText(list).length));\n          isList = listLength / this._getInnerText(node).length > 0.9;\n        }\n\n        if (tag === 'table' && isDataTable(node)) {\n          return false;\n        }\n\n        // Next check if we're inside a data table, in which case don't remove it as well.\n        if (this._hasAncestorTag(node, 'table', -1, isDataTable)) {\n          return false;\n        }\n\n        if (this._hasAncestorTag(node, 'code')) {\n          return false;\n        }\n\n        // keep element if it has a data tables\n        if ([...node.getElementsByTagName('table')].some((tbl) => tbl._readabilityDataTable)) {\n          return false;\n        }\n\n        var weight = this._getClassWeight(node);\n\n        this.log('Cleaning Conditionally', node);\n\n        var contentScore = 0;\n\n        if (weight + contentScore < 0) {\n          return true;\n        }\n\n        if (this._getCharCount(node, ',') < 10) {\n          // If there are not very many commas, and the number of\n          // non-paragraph elements is more than paragraphs or other\n          // ominous signs, remove the element.\n          var p = node.getElementsByTagName('p').length;\n          var img = node.getElementsByTagName('img').length;\n          var li = node.getElementsByTagName('li').length - 100;\n          var input = node.getElementsByTagName('input').length;\n          var headingDensity = this._getTextDensity(node, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);\n\n          var embedCount = 0;\n          var embeds = this._getAllNodesWithTag(node, ['object', 'embed', 'iframe']);\n\n          for (var i = 0; i < embeds.length; i++) {\n            // If this embed has attribute that matches video regex, don't delete it.\n            for (var j = 0; j < embeds[i].attributes.length; j++) {\n              if (this._allowedVideoRegex.test(embeds[i].attributes[j].value)) {\n                return false;\n              }\n            }\n\n            // For embed with <object> tag, check inner HTML as well.\n            if (\n              embeds[i].tagName === 'object' &&\n              this._allowedVideoRegex.test(embeds[i].innerHTML)\n            ) {\n              return false;\n            }\n\n            embedCount++;\n          }\n\n          var innerText = this._getInnerText(node);\n\n          // toss any node whose inner text contains nothing but suspicious words\n          if (this.REGEXPS.adWords.test(innerText) || this.REGEXPS.loadingWords.test(innerText)) {\n            return true;\n          }\n\n          var contentLength = innerText.length;\n          var linkDensity = this._getLinkDensity(node);\n          var textishTags = ['SPAN', 'LI', 'TD'].concat(Array.from(this.DIV_TO_P_ELEMS));\n          var textDensity = this._getTextDensity(node, textishTags);\n          var isFigureChild = this._hasAncestorTag(node, 'figure');\n\n          // apply shadiness checks, then check for exceptions\n          const shouldRemoveNode = () => {\n            const errs = [];\n            if (!isFigureChild && img > 1 && p / img < 0.5) {\n              errs.push(`Bad p to img ratio (img=${img}, p=${p})`);\n            }\n            if (!isList && li > p) {\n              errs.push(`Too many li's outside of a list. (li=${li} > p=${p})`);\n            }\n            if (input > Math.floor(p / 3)) {\n              errs.push(`Too many inputs per p. (input=${input}, p=${p})`);\n            }\n            if (\n              !isList &&\n              !isFigureChild &&\n              headingDensity < 0.9 &&\n              contentLength < 25 &&\n              (img === 0 || img > 2) &&\n              linkDensity > 0\n            ) {\n              errs.push(\n                `Suspiciously short. (headingDensity=${headingDensity}, img=${img}, linkDensity=${linkDensity})`,\n              );\n            }\n            if (!isList && weight < 25 && linkDensity > 0.2 + this._linkDensityModifier) {\n              errs.push(`Low weight and a little linky. (linkDensity=${linkDensity})`);\n            }\n            if (weight >= 25 && linkDensity > 0.5 + this._linkDensityModifier) {\n              errs.push(`High weight and mostly links. (linkDensity=${linkDensity})`);\n            }\n            if ((embedCount === 1 && contentLength < 75) || embedCount > 1) {\n              errs.push(\n                `Suspicious embed. (embedCount=${embedCount}, contentLength=${contentLength})`,\n              );\n            }\n            if (img === 0 && textDensity === 0) {\n              errs.push(`No useful content. (img=${img}, textDensity=${textDensity})`);\n            }\n\n            if (errs.length) {\n              this.log('Checks failed', errs);\n              return true;\n            }\n\n            return false;\n          };\n\n          var haveToRemove = shouldRemoveNode();\n\n          // Allow simple lists of images to remain in pages\n          if (isList && haveToRemove) {\n            for (var x = 0; x < node.children.length; x++) {\n              let child = node.children[x];\n              // Don't filter in lists with li's that contain more than one child\n              if (child.children.length > 1) {\n                return haveToRemove;\n              }\n            }\n            let li_count = node.getElementsByTagName('li').length;\n            // Only allow the list to remain if every li contains an image\n            if (img == li_count) {\n              return false;\n            }\n          }\n          return haveToRemove;\n        }\n        return false;\n      });\n    },\n\n    /**\n     * Clean out elements that match the specified conditions\n     *\n     * @param Element\n     * @param Function determines whether a node should be removed\n     * @return void\n     **/\n    _cleanMatchedNodes(e, filter) {\n      var endOfSearchMarkerNode = this._getNextNode(e, true);\n      var next = this._getNextNode(e);\n      while (next && next != endOfSearchMarkerNode) {\n        if (filter.call(this, next, next.className + ' ' + next.id)) {\n          next = this._removeAndGetNext(next);\n        } else {\n          next = this._getNextNode(next);\n        }\n      }\n    },\n\n    /**\n     * Clean out spurious headers from an Element.\n     *\n     * @param Element\n     * @return void\n     **/\n    _cleanHeaders(e) {\n      let headingNodes = this._getAllNodesWithTag(e, ['h1', 'h2']);\n      this._removeNodes(headingNodes, function (node) {\n        let shouldRemove = this._getClassWeight(node) < 0;\n        if (shouldRemove) {\n          this.log('Removing header with low class weight:', node);\n        }\n        return shouldRemove;\n      });\n    },\n\n    /**\n     * Check if this node is an H1 or H2 element whose content is mostly\n     * the same as the article title.\n     *\n     * @param Element  the node to check.\n     * @return boolean indicating whether this is a title-like header.\n     */\n    _headerDuplicatesTitle(node) {\n      if (node.tagName != 'H1' && node.tagName != 'H2') {\n        return false;\n      }\n      var heading = this._getInnerText(node, false);\n      this.log('Evaluating similarity of header:', heading, this._articleTitle);\n      return this._textSimilarity(this._articleTitle, heading) > 0.75;\n    },\n\n    _flagIsActive(flag) {\n      return (this._flags & flag) > 0;\n    },\n\n    _removeFlag(flag) {\n      this._flags = this._flags & ~flag;\n    },\n\n    _isProbablyVisible(node) {\n      // Have to null-check node.style and node.className.includes to deal with SVG and MathML nodes.\n      return (\n        (!node.style || node.style.display != 'none') &&\n        (!node.style || node.style.visibility != 'hidden') &&\n        !node.hasAttribute('hidden') &&\n        //check for \"fallback-image\" so that wikimedia math images are displayed\n        (!node.hasAttribute('aria-hidden') ||\n          node.getAttribute('aria-hidden') != 'true' ||\n          (node.className && node.className.includes && node.className.includes('fallback-image')))\n      );\n    },\n\n    /**\n     * Runs readability.\n     *\n     * Workflow:\n     *  1. Prep the document by removing script tags, css, etc.\n     *  2. Build readability's DOM tree.\n     *  3. Grab the article content from the current dom tree.\n     *  4. Replace the current DOM tree with the new one.\n     *  5. Read peacefully.\n     *\n     * @return void\n     **/\n    parse() {\n      // Avoid parsing too large documents, as per configuration option\n      if (this._maxElemsToParse > 0) {\n        var numTags = this._doc.getElementsByTagName('*').length;\n        if (numTags > this._maxElemsToParse) {\n          throw new Error('Aborting parsing document; ' + numTags + ' elements found');\n        }\n      }\n\n      // Unwrap image from noscript\n      this._unwrapNoscriptImages(this._doc);\n\n      // Extract JSON-LD metadata before removing scripts\n      var jsonLd = this._disableJSONLD ? {} : this._getJSONLD(this._doc);\n\n      // Remove script tags from the document.\n      this._removeScripts(this._doc);\n\n      this._prepDocument();\n\n      var metadata = this._getArticleMetadata(jsonLd);\n      this._metadata = metadata;\n      this._articleTitle = metadata.title;\n\n      var articleContent = this._grabArticle();\n      if (!articleContent) {\n        return null;\n      }\n\n      this.log('Grabbed: ' + articleContent.innerHTML);\n\n      this._postProcessContent(articleContent);\n\n      // If we haven't found an excerpt in the article's metadata, use the article's\n      // first paragraph as the excerpt. This is used for displaying a preview of\n      // the article's content.\n      if (!metadata.excerpt) {\n        var paragraphs = articleContent.getElementsByTagName('p');\n        if (paragraphs.length) {\n          metadata.excerpt = paragraphs[0].textContent.trim();\n        }\n      }\n\n      var textContent = articleContent.textContent;\n      return {\n        title: this._articleTitle,\n        byline: metadata.byline || this._articleByline,\n        dir: this._articleDir,\n        lang: this._articleLang,\n        content: this._serializer(articleContent),\n        textContent,\n        length: textContent.length,\n        excerpt: metadata.excerpt,\n        siteName: metadata.siteName || this._articleSiteName,\n        publishedTime: metadata.publishedTime,\n      };\n    },\n  };\n\n  if (typeof module === 'object') {\n    /* global module */\n    module.exports = Readability;\n  }\n\n  /**\n   * Web Fetcher Helper Content Script\n   * Handles fetching HTML content, text content, and interactive elements from the current page\n   * Supports Readability for better content extraction\n   */\n\n  // Configuration\n  const config = {\n    // Elements that should be ignored when extracting content (used for iframe content and fallback extraction)\n    ignoreElements: [\n      'nav',\n      'header:not(article header)',\n      'footer:not(article footer)',\n      'aside',\n      'script',\n      'style',\n      'noscript',\n      'iframe[src*=\"ads\"]',\n      '.cookie-notice',\n      '.ad',\n      '.ads',\n      '.advertisement',\n      '.banner',\n      '.popup',\n      '.modal',\n      '.overlay',\n      '.social-share',\n      '.social-links',\n      '.related-articles',\n      '.comments',\n    ],\n    minTextLength: 20,\n    maxTotalLength: 100000,\n    minParagraphLength: 2,\n  };\n\n  // Listen for messages from the extension\n  chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {\n    const pingActions = ['search_tabs_content_ping', 'chrome_web_fetcher_ping'];\n    // Respond to ping message\n    if (pingActions.includes(request.action)) {\n      sendResponse({ status: 'pong' });\n      return false; // Synchronous response\n    }\n\n    // Get HTML content\n    else if (request.action === 'getHtmlContent') {\n      try {\n        let rawHtml;\n\n        // If selector is specified, only get content from the matching element\n        if (request.selector) {\n          const element = document.querySelector(request.selector);\n          if (element) {\n            rawHtml = element.outerHTML;\n          } else {\n            throw new Error(`No element found matching selector: ${request.selector}`);\n          }\n        } else {\n          // Otherwise get the entire page content\n          rawHtml = document.documentElement.outerHTML;\n        }\n\n        const cleanedHtml = cleanHtmlContent(rawHtml);\n\n        sendResponse({\n          success: true,\n          htmlContent: cleanedHtml,\n          selector: request.selector,\n        });\n      } catch (error) {\n        sendResponse({\n          success: false,\n          error: `Failed to get HTML content: ${error.message}`,\n        });\n      }\n    }\n\n    // Get text content\n    else if (request.action === 'getTextContent') {\n      try {\n        // If selector is specified, only get content from the matching element\n        if (request.selector) {\n          const element = document.querySelector(request.selector);\n          if (element) {\n            // Directly get the text content of the element\n            const textContent = element.innerText;\n\n            sendResponse({\n              success: true,\n              textContent: textContent,\n              selector: request.selector,\n            });\n          } else {\n            throw new Error(`No element found matching selector: ${request.selector}`);\n          }\n        } else {\n          // Otherwise use Readability to extract the main content\n          const documentClone = document.cloneNode(true);\n\n          const reader = new Readability(documentClone);\n          const article = reader.parse();\n\n          if (article && article.textContent) {\n            // Get metadata\n            const metadata = extractPageMetadata();\n\n            // Get iframe content if available\n            const iframeContent = extractIframeContent();\n\n            // Combine content\n            let fullContent = article.textContent;\n            if (iframeContent && iframeContent.trim().length > config.minTextLength) {\n              fullContent += '\\n\\n--- Embedded Content ---\\n\\n' + iframeContent;\n            }\n\n            // Clean content\n            fullContent = cleanContent(fullContent);\n\n            sendResponse({\n              success: true,\n              textContent: fullContent,\n              article: {\n                title: article.title,\n                byline: article.byline,\n                siteName: article.siteName,\n                excerpt: article.excerpt,\n                lang: article.lang,\n                content: article.content, // HTML content\n              },\n              metadata: metadata,\n            });\n          } else {\n            // Fallback to basic extraction\n            const textContent = document.body.innerText;\n            sendResponse({\n              success: true,\n              textContent: textContent,\n              fallback: true,\n            });\n          }\n        }\n      } catch (error) {\n        console.error('Error extracting text content:', error);\n        sendResponse({\n          success: false,\n          error: `Failed to extract text content: ${error.message}`,\n        });\n      }\n\n      return true; // Async response\n    }\n\n    // Interactive elements feature has been removed\n\n    return true; // Async response\n  });\n\n  /**\n   * Extract metadata from the page\n   * @returns {Object} - Page metadata\n   */\n  function extractPageMetadata() {\n    const metadata = {\n      title: document.title,\n      description: '',\n      author: '',\n      keywords: '',\n      published: '',\n      siteName: '',\n    };\n\n    // Extract description\n    const descriptionElement = document.querySelector(\n      'meta[name=\"description\"], meta[property=\"og:description\"]',\n    );\n    if (descriptionElement) {\n      metadata.description = descriptionElement.getAttribute('content') || '';\n    }\n\n    // Extract author\n    const authorElement = document.querySelector(\n      'meta[name=\"author\"], meta[property=\"article:author\"]',\n    );\n    if (authorElement) {\n      metadata.author = authorElement.getAttribute('content') || '';\n    }\n\n    // Extract keywords\n    const keywordsElement = document.querySelector('meta[name=\"keywords\"]');\n    if (keywordsElement) {\n      metadata.keywords = keywordsElement.getAttribute('content') || '';\n    }\n\n    // Extract published date\n    const publishedElement = document.querySelector(\n      'meta[property=\"article:published_time\"], time[datetime]',\n    );\n    if (publishedElement) {\n      metadata.published =\n        publishedElement.getAttribute('content') || publishedElement.getAttribute('datetime') || '';\n    }\n\n    // Extract site name\n    const siteNameElement = document.querySelector('meta[property=\"og:site_name\"]');\n    if (siteNameElement) {\n      metadata.siteName = siteNameElement.getAttribute('content') || '';\n    }\n\n    return metadata;\n  }\n\n  /**\n   * Extract content from iframes\n   * @returns {string} - Combined iframe content\n   */\n  function extractIframeContent() {\n    let allIframeText = '';\n    const iframes = document.querySelectorAll('iframe');\n\n    for (const iframe of iframes) {\n      try {\n        if (isSameOrigin(iframe) && isElementVisible(iframe)) {\n          const doc = iframe.contentDocument || iframe.contentWindow?.document;\n          if (doc) {\n            const iframeText = doc.body.innerText;\n            if (iframeText && iframeText.trim().length >= config.minTextLength) {\n              allIframeText += iframeText.trim() + '\\n\\n';\n            }\n          }\n        }\n      } catch (error) {\n        console.warn(\n          `Cannot access iframe content (possible cross-origin restriction): ${error.message}`,\n        );\n      }\n    }\n\n    return allIframeText.trim();\n  }\n\n  /**\n   * Check if an element is visible in the DOM.\n   * This mirrors the visibility check used by other helpers.\n   * @param {Element} el\n   * @returns {boolean}\n   */\n  function isElementVisible(el) {\n    if (!el || !el.isConnected) return false;\n    try {\n      const style = window.getComputedStyle(el);\n      if (\n        style.display === 'none' ||\n        style.visibility === 'hidden' ||\n        parseFloat(style.opacity) === 0\n      ) {\n        return false;\n      }\n    } catch (_) {\n      // If getComputedStyle fails (e.g., detached node), treat as not visible\n      return false;\n    }\n\n    const rect = el.getBoundingClientRect();\n    return rect.width > 0 || rect.height > 0 || el.tagName === 'A';\n  }\n\n  /**\n   * Check if iframe is same origin\n   * @param {HTMLIFrameElement} iframe - The iframe to check\n   * @returns {boolean} - Whether the iframe is same origin\n   */\n  function isSameOrigin(iframe) {\n    try {\n      return Boolean(iframe.contentDocument || iframe.contentWindow?.document);\n    } catch (e) {\n      return false;\n    }\n  }\n\n  /**\n   * Clean content text\n   * @param {string} text - The text to clean\n   * @returns {string} - Cleaned text\n   */\n  function cleanContent(text) {\n    return text\n      .replace(/\\s+/g, ' ')\n      .replace(/\\n\\s*\\n/g, '\\n\\n')\n      .trim()\n      .substring(0, config.maxTotalLength);\n  }\n\n  /**\n   * Clean HTML content by removing style tags and their content\n   * @param {string} html - The HTML content to clean\n   * @returns {string} - Cleaned HTML content\n   */\n  function cleanHtmlContent(html) {\n    // Create a new document parser\n    const parser = new DOMParser();\n    const doc = parser.parseFromString(html, 'text/html');\n\n    // Remove all style tags\n    const styleElements = doc.querySelectorAll('style');\n    styleElements.forEach((element) => {\n      if (element.parentNode) {\n        element.parentNode.removeChild(element);\n      }\n    });\n\n    // Remove all inline style attributes\n    const allElementsWithStyle = doc.querySelectorAll('*');\n    allElementsWithStyle.forEach((element) => {\n      element.removeAttribute('style');\n    });\n\n    // Remove all link tags\n    const linkElements = doc.querySelectorAll('link');\n    linkElements.forEach((element) => {\n      if (element.parentNode) {\n        element.parentNode.removeChild(element);\n      }\n    });\n\n    // Remove all script tags\n    const scriptElements = doc.querySelectorAll('script');\n    scriptElements.forEach((element) => {\n      if (element.parentNode) {\n        element.parentNode.removeChild(element);\n      }\n    });\n\n    // Replace all SVG elements with placeholders\n    const svgElements = doc.querySelectorAll('svg');\n    svgElements.forEach((element) => {\n      if (element.parentNode) {\n        // Create a placeholder element\n        const placeholder = doc.createElement('span');\n        placeholder.textContent = '[SVG Icon]';\n        placeholder.setAttribute('data-placeholder', 'svg-icon');\n\n        // Replace SVG element\n        element.parentNode.replaceChild(placeholder, element);\n      }\n    });\n\n    // Replace all SVG images and objects\n    const svgImages = doc.querySelectorAll(\n      'img[src$=\".svg\"], object[data$=\".svg\"], embed[src$=\".svg\"]',\n    );\n    svgImages.forEach((element) => {\n      if (element.parentNode) {\n        // Create a placeholder element\n        const placeholder = doc.createElement('span');\n        placeholder.textContent = '[SVG Image]';\n        placeholder.setAttribute('data-placeholder', 'svg-image');\n        if (element.alt) {\n          placeholder.textContent = `[SVG Image: ${element.alt}]`;\n        }\n\n        // Replace SVG image element\n        element.parentNode.replaceChild(placeholder, element);\n      }\n    });\n\n    // Remove elements with only data-* attributes, no children, and no class or style\n    const allElements = Array.from(doc.querySelectorAll('*'));\n    allElements.forEach((element) => {\n      // Check if element has only data-* attributes\n      let hasOnlyDataAttributes = true;\n      let hasDataAttribute = false;\n\n      // Check all attributes\n      for (let i = 0; i < element.attributes.length; i++) {\n        const attr = element.attributes[i];\n        if (attr.name.startsWith('data-')) {\n          hasDataAttribute = true;\n        } else if (attr.name !== 'id') {\n          // Allow id attribute\n          hasOnlyDataAttributes = false;\n          break;\n        }\n      }\n\n      // If element has only data-* attributes, no children, and no text content\n      if (\n        hasOnlyDataAttributes &&\n        hasDataAttribute &&\n        element.children.length === 0 &&\n        element.textContent.trim() === ''\n      ) {\n        // Remove the element\n        if (element.parentNode) {\n          element.parentNode.removeChild(element);\n        }\n      }\n    });\n\n    // Remove all HTML comments\n    const removeComments = (node) => {\n      const childNodes = node.childNodes;\n      for (let i = childNodes.length - 1; i >= 0; i--) {\n        const child = childNodes[i];\n        if (child.nodeType === 8) {\n          // Comment node\n          node.removeChild(child);\n        } else if (child.nodeType === 1) {\n          // Element node\n          removeComments(child);\n        }\n      }\n    };\n    removeComments(doc);\n\n    // Return cleaned HTML\n    return new XMLSerializer().serializeToString(doc);\n  }\n\n  // Interactive elements feature has been removed\n\n  // Selector generation feature has been removed\n}\n"
  },
  {
    "path": "app/chrome-extension/package.json",
    "content": "{\n  \"name\": \"chrome-mcp-server\",\n  \"description\": \"a chrome extension to use your own chrome as a mcp server\",\n  \"author\": \"hangye\",\n  \"private\": true,\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"wxt\",\n    \"dev:firefox\": \"wxt -b firefox\",\n    \"build\": \"wxt build\",\n    \"build:firefox\": \"wxt build -b firefox\",\n    \"zip\": \"wxt zip\",\n    \"zip:firefox\": \"wxt zip -b firefox\",\n    \"compile\": \"vue-tsc --noEmit\",\n    \"postinstall\": \"wxt prepare\",\n    \"lint\": \"npx eslint .\",\n    \"lint:fix\": \"npx eslint . --fix\",\n    \"format\": \"npx prettier --write .\",\n    \"format:check\": \"npx prettier --check .\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.11.0\",\n    \"@vue-flow/background\": \"^1.3.2\",\n    \"@vue-flow/controls\": \"^1.1.3\",\n    \"@vue-flow/core\": \"^1.47.0\",\n    \"@vue-flow/minimap\": \"^1.5.4\",\n    \"@xenova/transformers\": \"^2.17.2\",\n    \"chrome-mcp-shared\": \"workspace:*\",\n    \"date-fns\": \"^4.1.0\",\n    \"elkjs\": \"^0.11.0\",\n    \"gifenc\": \"^1.0.3\",\n    \"hnswlib-wasm-static\": \"0.8.5\",\n    \"markstream-vue\": \"0.0.3-beta.5\",\n    \"vue\": \"^3.5.13\",\n    \"zod\": \"^3.24.4\"\n  },\n  \"devDependencies\": {\n    \"@iconify-json/lucide\": \"^1.1.0\",\n    \"@tailwindcss/vite\": \"^4.0.0\",\n    \"@types/chrome\": \"^0.0.318\",\n    \"@wxt-dev/module-vue\": \"^1.0.2\",\n    \"dotenv\": \"^16.5.0\",\n    \"fake-indexeddb\": \"^6.2.5\",\n    \"jsdom\": \"^26.0.0\",\n    \"tailwindcss\": \"^4.0.0\",\n    \"unplugin-icons\": \"^0.19.0\",\n    \"unplugin-vue-components\": \"^0.27.5\",\n    \"vite-plugin-static-copy\": \"^3.0.0\",\n    \"vitest\": \"^2.1.8\",\n    \"vue-tsc\": \"^2.2.8\",\n    \"wxt\": \"^0.20.0\"\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/element-picker/controller.ts",
    "content": "/**\n * Element Picker Controller\n *\n * Creates and manages the Element Picker Panel UI, which displays:\n * - List of element requests from the AI\n * - Current selection status for each request\n * - Countdown timer\n * - Cancel/Confirm actions\n */\n\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport {\n  mountQuickPanelShadowHost,\n  type QuickPanelShadowHostElements,\n  type QuickPanelShadowHostManager,\n} from '@/shared/quick-panel/ui';\nimport type { PickedElement } from 'chrome-mcp-shared';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface ElementPickerControllerOptions {\n  /** Custom host element ID */\n  hostId?: string;\n  /** Custom z-index */\n  zIndex?: number;\n  /** Called when user clicks Cancel */\n  onCancel?: () => void;\n  /** Called when user clicks Confirm */\n  onConfirm?: () => void;\n  /** Called when user switches to a different request */\n  onSetActiveRequest?: (requestId: string) => void;\n  /** Called when user clears a selection */\n  onClearSelection?: (requestId: string) => void;\n}\n\nexport interface ElementPickerController {\n  /** Show the panel with initial state */\n  show: (state: ElementPickerUiState) => void;\n  /** Update the panel state */\n  update: (patch: ElementPickerUiPatch) => void;\n  /** Hide and clean up the panel */\n  hide: () => void;\n  /** Check if the panel is currently visible */\n  isVisible: () => boolean;\n  /** Dispose and clean up all resources */\n  dispose: () => void;\n}\n\nexport interface ElementPickerUiRequest {\n  id: string;\n  name: string;\n  description?: string;\n}\n\nexport interface ElementPickerUiState {\n  sessionId: string;\n  requests: ElementPickerUiRequest[];\n  activeRequestId: string | null;\n  selections: Record<string, PickedElement | null>;\n  deadlineTs: number;\n  errorMessage: string | null;\n}\n\nexport type ElementPickerUiPatch = Partial<Omit<ElementPickerUiState, 'sessionId'>> & {\n  sessionId: string;\n};\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_HOST_ID = '__mcp_element_picker_host__';\nconst DEFAULT_Z_INDEX = 2147483647;\n\n// ============================================================\n// Styles (Quick Panel compatible)\n// ============================================================\n\nconst ELEMENT_PICKER_STYLES = /* css */ `\n  /* Overlay positioning - bottom-right corner */\n  .ep-overlay {\n    position: fixed;\n    inset: 0;\n    display: flex;\n    align-items: flex-end;\n    justify-content: flex-end;\n    padding: 16px;\n    pointer-events: none;\n  }\n\n  /* Panel sizing */\n  .ep-panel {\n    width: min(480px, calc(100vw - 32px));\n    max-height: min(600px, calc(100vh - 32px));\n    pointer-events: auto;\n  }\n\n  /* Countdown badge */\n  .ep-countdown {\n    font-family: var(--ac-font-code);\n    font-size: 12px;\n    color: var(--ac-text-muted);\n    padding: 4px 10px;\n    border-radius: 999px;\n    border: 1px solid var(--qp-glass-divider);\n    background: color-mix(in srgb, var(--qp-glass-input-bg) 80%, transparent);\n    user-select: none;\n    white-space: nowrap;\n  }\n\n  .ep-countdown--warning {\n    color: var(--ac-warning);\n    border-color: color-mix(in srgb, var(--ac-warning) 40%, var(--qp-glass-divider));\n  }\n\n  .ep-countdown--danger {\n    color: var(--ac-danger);\n    border-color: color-mix(in srgb, var(--ac-danger) 40%, var(--qp-glass-divider));\n    animation: ep-pulse 1s ease-in-out infinite;\n  }\n\n  @keyframes ep-pulse {\n    0%, 100% { opacity: 1; }\n    50% { opacity: 0.6; }\n  }\n\n  /* Hint text */\n  .ep-hint {\n    margin: 0 0 10px 0;\n    font-size: 12px;\n    color: var(--ac-text-muted);\n  }\n\n  /* Error banner */\n  .ep-error {\n    margin: 0 0 10px 0;\n    padding: 8px 10px;\n    border-radius: var(--ac-radius-card);\n    border: 1px solid color-mix(in srgb, var(--ac-danger) 55%, var(--ac-border));\n    background: color-mix(in srgb, var(--ac-danger) 10%, transparent);\n    color: color-mix(in srgb, var(--ac-danger) 85%, var(--ac-text));\n    font-size: 12px;\n  }\n\n  /* Request list */\n  .ep-list {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  /* Request item card */\n  .ep-item {\n    border-radius: var(--ac-radius-card);\n    border: var(--ac-border-width) solid var(--ac-border);\n    box-shadow: var(--ac-shadow-card);\n    background: var(--ac-surface);\n    padding: 10px 12px;\n    transition: border-color var(--ac-motion-fast), box-shadow var(--ac-motion-fast);\n  }\n\n  .ep-item--active {\n    border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--ac-border));\n    box-shadow:\n      0 0 0 2px color-mix(in srgb, var(--ac-accent-subtle) 65%, transparent),\n      var(--ac-shadow-card);\n  }\n\n  /* Item header */\n  .ep-item-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 10px;\n  }\n\n  .ep-item-title {\n    min-width: 0;\n    font-weight: 600;\n    font-size: 13px;\n    color: var(--ac-text);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  /* Status badge */\n  .ep-badge {\n    flex: none;\n    font-size: 11px;\n    padding: 2px 8px;\n    border-radius: 999px;\n    border: 1px solid var(--qp-glass-divider);\n    color: var(--ac-text-muted);\n    background: color-mix(in srgb, var(--ac-surface-muted) 65%, transparent);\n    user-select: none;\n  }\n\n  .ep-badge--selected {\n    border-color: color-mix(in srgb, var(--ac-success) 55%, var(--qp-glass-divider));\n    color: color-mix(in srgb, var(--ac-success) 85%, var(--ac-text));\n    background: color-mix(in srgb, var(--ac-success) 10%, transparent);\n  }\n\n  .ep-badge--picking {\n    border-color: color-mix(in srgb, var(--ac-accent) 55%, var(--qp-glass-divider));\n    color: var(--ac-accent);\n    background: color-mix(in srgb, var(--ac-accent) 10%, transparent);\n    animation: ep-pulse 1.5s ease-in-out infinite;\n  }\n\n  /* Description text */\n  .ep-desc {\n    margin-top: 6px;\n    font-size: 12px;\n    color: var(--ac-text-muted);\n    white-space: pre-wrap;\n  }\n\n  /* Picked element info */\n  .ep-picked {\n    margin-top: 8px;\n    font-size: 12px;\n    color: var(--ac-text);\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n    padding: 8px;\n    border-radius: var(--ac-radius-inner);\n    background: var(--ac-surface-muted);\n  }\n\n  .ep-picked-text {\n    font-weight: 500;\n    word-break: break-word;\n  }\n\n  .ep-picked-meta {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px;\n    font-size: 11px;\n  }\n\n  .ep-picked code {\n    font-family: var(--ac-font-code);\n    font-size: 10px;\n    color: var(--ac-text-muted);\n    padding: 2px 4px;\n    border-radius: 4px;\n    background: rgba(0, 0, 0, 0.05);\n    word-break: break-all;\n  }\n\n  /* Action buttons row */\n  .ep-actions {\n    margin-top: 8px;\n    display: flex;\n    gap: 8px;\n  }\n\n  /* Footer */\n  .ep-footer {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 10px;\n  }\n\n  .ep-footer-left {\n    font-size: 11px;\n    color: var(--ac-text-muted);\n  }\n\n  .ep-footer-right {\n    display: flex;\n    gap: 8px;\n  }\n`;\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\nfunction formatCountdown(deadlineTs: number): {\n  text: string;\n  level: 'normal' | 'warning' | 'danger';\n} {\n  const remainingMs = Math.max(0, deadlineTs - Date.now());\n  const totalSeconds = Math.floor(remainingMs / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n  const text = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;\n\n  // Warning at 1 minute, danger at 30 seconds\n  let level: 'normal' | 'warning' | 'danger' = 'normal';\n  if (totalSeconds <= 30) {\n    level = 'danger';\n  } else if (totalSeconds <= 60) {\n    level = 'warning';\n  }\n\n  return { text, level };\n}\n\nfunction truncate(text: string, max = 80): string {\n  const t = String(text || '')\n    .trim()\n    .replace(/\\s+/g, ' ');\n  if (t.length <= max) return t;\n  return `${t.slice(0, Math.max(0, max - 1))}...`;\n}\n\n// ============================================================\n// Controller Factory\n// ============================================================\n\nexport function createElementPickerController(\n  options: ElementPickerControllerOptions = {},\n): ElementPickerController {\n  let disposed = false;\n\n  let shadowHost: QuickPanelShadowHostManager | null = null;\n  let elements: QuickPanelShadowHostElements | null = null;\n  let disposer: Disposer | null = null;\n  let state: ElementPickerUiState | null = null;\n\n  // DOM refs\n  let overlayEl: HTMLDivElement | null = null;\n  let panelEl: HTMLDivElement | null = null;\n  let countdownEl: HTMLSpanElement | null = null;\n  let errorEl: HTMLDivElement | null = null;\n  let listEl: HTMLDivElement | null = null;\n  let confirmBtn: HTMLButtonElement | null = null;\n  let cancelBtn: HTMLButtonElement | null = null;\n  let progressEl: HTMLSpanElement | null = null;\n  let timerId: ReturnType<typeof setInterval> | null = null;\n\n  // Cached item elements for incremental updates\n  interface ItemElements {\n    container: HTMLDivElement;\n    badge: HTMLDivElement;\n    pickedContainer: HTMLDivElement | null;\n    pickBtn: HTMLButtonElement;\n    clearBtn: HTMLButtonElement;\n  }\n  const itemElementsMap = new Map<string, ItemElements>();\n\n  const hostId = options.hostId ?? DEFAULT_HOST_ID;\n  const zIndex = options.zIndex ?? DEFAULT_Z_INDEX;\n\n  function ensureMounted(): void {\n    if (shadowHost && elements) return;\n\n    shadowHost = mountQuickPanelShadowHost({ hostId, zIndex });\n    elements = shadowHost.getElements();\n    if (!elements) throw new Error('Failed to mount Element Picker shadow host');\n\n    const localDisposer = new Disposer();\n    disposer = localDisposer;\n\n    // Inject local styles\n    const styleEl = document.createElement('style');\n    styleEl.textContent = ELEMENT_PICKER_STYLES;\n    elements.shadowRoot.append(styleEl);\n    localDisposer.add(() => styleEl.remove());\n\n    // Build UI structure\n    overlayEl = document.createElement('div');\n    overlayEl.className = 'ep-overlay';\n\n    panelEl = document.createElement('div');\n    panelEl.className = 'qp-panel qp-liquid-shimmer ep-panel';\n    panelEl.setAttribute('role', 'dialog');\n    panelEl.setAttribute('aria-modal', 'false');\n    panelEl.setAttribute('aria-label', 'Element Picker');\n\n    // Header\n    const headerEl = document.createElement('div');\n    headerEl.className = 'qp-header';\n\n    const headerLeft = document.createElement('div');\n    headerLeft.className = 'qp-header-left';\n\n    const brand = document.createElement('div');\n    brand.className = 'qp-brand';\n    brand.textContent = '\\u{1F446}'; // Pointing up emoji\n\n    const title = document.createElement('div');\n    title.className = 'qp-title';\n\n    const titleName = document.createElement('div');\n    titleName.className = 'qp-title-name';\n    titleName.textContent = 'Element Picker';\n\n    const titleSub = document.createElement('div');\n    titleSub.className = 'qp-title-sub';\n    titleSub.textContent = 'Click on the requested elements';\n\n    title.append(titleName, titleSub);\n    headerLeft.append(brand, title);\n\n    const headerRight = document.createElement('div');\n    headerRight.className = 'qp-header-right';\n\n    countdownEl = document.createElement('span');\n    countdownEl.className = 'ep-countdown';\n    countdownEl.textContent = '03:00';\n\n    headerRight.append(countdownEl);\n    headerEl.append(headerLeft, headerRight);\n\n    // Content\n    const contentEl = document.createElement('div');\n    contentEl.className = 'qp-content ac-scroll';\n\n    const hintEl = document.createElement('div');\n    hintEl.className = 'ep-hint';\n    hintEl.textContent = 'Click on each element the AI needs. Press Esc to cancel.';\n\n    errorEl = document.createElement('div');\n    errorEl.className = 'ep-error';\n    errorEl.hidden = true;\n\n    listEl = document.createElement('div');\n    listEl.className = 'ep-list';\n\n    contentEl.append(hintEl, errorEl, listEl);\n\n    // Footer\n    const footerEl = document.createElement('div');\n    footerEl.className = 'qp-composer';\n\n    const footerInner = document.createElement('div');\n    footerInner.className = 'ep-footer';\n\n    const footerLeft = document.createElement('div');\n    footerLeft.className = 'ep-footer-left';\n\n    progressEl = document.createElement('span');\n    progressEl.textContent = '0/0 selected';\n    footerLeft.append(progressEl);\n\n    const footerRight = document.createElement('div');\n    footerRight.className = 'ep-footer-right';\n\n    cancelBtn = document.createElement('button');\n    cancelBtn.type = 'button';\n    cancelBtn.className = 'qp-btn ac-btn ac-focus-ring';\n    cancelBtn.textContent = 'Cancel';\n\n    confirmBtn = document.createElement('button');\n    confirmBtn.type = 'button';\n    confirmBtn.className = 'qp-btn ac-btn ac-focus-ring qp-btn--primary';\n    confirmBtn.textContent = 'Confirm';\n\n    footerRight.append(cancelBtn, confirmBtn);\n    footerInner.append(footerLeft, footerRight);\n    footerEl.append(footerInner);\n\n    panelEl.append(headerEl, contentEl, footerEl);\n    overlayEl.append(panelEl);\n    elements.root.append(overlayEl);\n    localDisposer.add(() => overlayEl?.remove());\n\n    // Event listeners\n    localDisposer.listen(cancelBtn, 'click', () => options.onCancel?.());\n    localDisposer.listen(confirmBtn, 'click', () => options.onConfirm?.());\n\n    // Esc key to cancel - use capture phase on shadowRoot to intercept before Quick Panel stops propagation\n    const handleEscKey = (e: Event) => {\n      if (e instanceof KeyboardEvent && e.key === 'Escape') {\n        e.preventDefault();\n        e.stopPropagation();\n        options.onCancel?.();\n      }\n    };\n    elements.shadowRoot.addEventListener('keydown', handleEscKey, { capture: true });\n    localDisposer.add(() =>\n      elements?.shadowRoot.removeEventListener('keydown', handleEscKey, { capture: true }),\n    );\n  }\n\n  function clearTimer(): void {\n    if (timerId !== null) {\n      clearInterval(timerId);\n      timerId = null;\n    }\n  }\n\n  /**\n   * Render only the countdown timer (called frequently by interval).\n   */\n  function renderCountdown(): void {\n    if (!state || !countdownEl) return;\n    const countdown = formatCountdown(state.deadlineTs);\n    countdownEl.textContent = countdown.text;\n    countdownEl.className = `ep-countdown${countdown.level !== 'normal' ? ` ep-countdown--${countdown.level}` : ''}`;\n  }\n\n  /**\n   * Create a picked element info container.\n   */\n  function createPickedInfoEl(picked: PickedElement): HTMLDivElement {\n    const pickedEl = document.createElement('div');\n    pickedEl.className = 'ep-picked';\n\n    if (picked.text) {\n      const textEl = document.createElement('div');\n      textEl.className = 'ep-picked-text';\n      textEl.textContent = `\"${truncate(picked.text, 80)}\"`;\n      pickedEl.append(textEl);\n    }\n\n    const metaEl = document.createElement('div');\n    metaEl.className = 'ep-picked-meta';\n\n    const tagCode = document.createElement('code');\n    tagCode.textContent = picked.tagName || 'element';\n    metaEl.append(tagCode);\n\n    const refCode = document.createElement('code');\n    refCode.textContent = `ref=${picked.ref}`;\n    metaEl.append(refCode);\n\n    if (picked.frameId > 0) {\n      const frameCode = document.createElement('code');\n      frameCode.textContent = `frame=${picked.frameId}`;\n      metaEl.append(frameCode);\n    }\n\n    pickedEl.append(metaEl);\n\n    const selectorEl = document.createElement('div');\n    const selectorCode = document.createElement('code');\n    selectorCode.textContent = truncate(picked.selector || '', 100);\n    selectorEl.append(selectorCode);\n    pickedEl.append(selectorEl);\n\n    return pickedEl;\n  }\n\n  /**\n   * Create a single request item element.\n   */\n  function createItemEl(req: ElementPickerUiRequest): ItemElements {\n    const item = document.createElement('div');\n    item.className = 'ep-item';\n    item.dataset.requestId = req.id;\n\n    // Header row\n    const header = document.createElement('div');\n    header.className = 'ep-item-header';\n\n    const titleEl = document.createElement('div');\n    titleEl.className = 'ep-item-title';\n    titleEl.textContent = req.name;\n\n    const badge = document.createElement('div');\n    badge.className = 'ep-badge';\n    badge.textContent = 'Pending';\n\n    header.append(titleEl, badge);\n    item.append(header);\n\n    // Description (static, only added once)\n    if (req.description) {\n      const desc = document.createElement('div');\n      desc.className = 'ep-desc';\n      desc.textContent = req.description;\n      item.append(desc);\n    }\n\n    // Action buttons\n    const actions = document.createElement('div');\n    actions.className = 'ep-actions';\n\n    const pickBtn = document.createElement('button');\n    pickBtn.type = 'button';\n    pickBtn.className = 'qp-btn ac-btn ac-focus-ring';\n    pickBtn.textContent = 'Pick';\n    pickBtn.addEventListener('click', () => options.onSetActiveRequest?.(req.id));\n\n    const clearBtn = document.createElement('button');\n    clearBtn.type = 'button';\n    clearBtn.className = 'qp-btn ac-btn ac-focus-ring';\n    clearBtn.textContent = 'Clear';\n    clearBtn.disabled = true;\n    clearBtn.addEventListener('click', () => options.onClearSelection?.(req.id));\n\n    actions.append(pickBtn, clearBtn);\n    item.append(actions);\n\n    return { container: item, badge, pickedContainer: null, pickBtn, clearBtn };\n  }\n\n  /**\n   * Update a single item's display state.\n   */\n  function updateItemEl(\n    itemEls: ItemElements,\n    req: ElementPickerUiRequest,\n    picked: PickedElement | null,\n    isActive: boolean,\n  ): void {\n    const { container, badge, pickBtn, clearBtn } = itemEls;\n\n    // Update active state\n    container.classList.toggle('ep-item--active', isActive);\n\n    // Update badge\n    if (picked) {\n      badge.className = 'ep-badge ep-badge--selected';\n      badge.textContent = 'Selected';\n    } else if (isActive) {\n      badge.className = 'ep-badge ep-badge--picking';\n      badge.textContent = 'Picking...';\n    } else {\n      badge.className = 'ep-badge';\n      badge.textContent = 'Pending';\n    }\n\n    // Update pick button\n    pickBtn.textContent = isActive ? 'Picking...' : 'Pick';\n    pickBtn.disabled = isActive;\n\n    // Update clear button\n    clearBtn.disabled = !picked;\n\n    // Handle picked info container\n    const actionsEl = container.querySelector('.ep-actions');\n    if (picked) {\n      if (!itemEls.pickedContainer) {\n        // Create and insert picked info before actions\n        const pickedEl = createPickedInfoEl(picked);\n        actionsEl?.parentNode?.insertBefore(pickedEl, actionsEl);\n        itemEls.pickedContainer = pickedEl;\n      } else {\n        // Update existing picked info\n        const newPickedEl = createPickedInfoEl(picked);\n        itemEls.pickedContainer.replaceWith(newPickedEl);\n        itemEls.pickedContainer = newPickedEl;\n      }\n    } else if (itemEls.pickedContainer) {\n      // Remove picked info\n      itemEls.pickedContainer.remove();\n      itemEls.pickedContainer = null;\n    }\n  }\n\n  /**\n   * Build the list initially or rebuild if requests changed.\n   */\n  function buildList(): void {\n    if (!state || !listEl) return;\n\n    // Clear existing items and cache\n    listEl.innerHTML = '';\n    itemElementsMap.clear();\n\n    for (const req of state.requests) {\n      const itemEls = createItemEl(req);\n      itemElementsMap.set(req.id, itemEls);\n      listEl.append(itemEls.container);\n    }\n  }\n\n  /**\n   * Full render - updates all dynamic parts.\n   */\n  function render(): void {\n    if (!state || !listEl || !countdownEl || !confirmBtn || !errorEl || !progressEl) return;\n\n    // Countdown (always update)\n    renderCountdown();\n\n    // Error banner\n    const err = state.errorMessage ? state.errorMessage.trim() : '';\n    if (err) {\n      errorEl.hidden = false;\n      errorEl.textContent = err;\n    } else {\n      errorEl.hidden = true;\n      errorEl.textContent = '';\n    }\n\n    // Rebuild list if requests changed (rare case)\n    const needsRebuild =\n      itemElementsMap.size !== state.requests.length ||\n      state.requests.some((r) => !itemElementsMap.has(r.id));\n    if (needsRebuild) {\n      buildList();\n    }\n\n    // Count selected and update items\n    let selectedCount = 0;\n    for (const req of state.requests) {\n      const picked = state.selections[req.id] || null;\n      const isActive = state.activeRequestId === req.id;\n      if (picked) selectedCount++;\n\n      const itemEls = itemElementsMap.get(req.id);\n      if (itemEls) {\n        updateItemEl(itemEls, req, picked, isActive);\n      }\n    }\n\n    // Progress text\n    progressEl.textContent = `${selectedCount}/${state.requests.length} selected`;\n\n    // Confirm button state\n    const allSelected = selectedCount === state.requests.length;\n    confirmBtn.disabled = !allSelected;\n    confirmBtn.textContent = allSelected\n      ? 'Confirm'\n      : `Confirm (${selectedCount}/${state.requests.length})`;\n  }\n\n  function show(next: ElementPickerUiState): void {\n    if (disposed) return;\n    ensureMounted();\n\n    state = next;\n    render();\n\n    clearTimer();\n    // Timer only updates countdown, not the full list\n    timerId = setInterval(() => {\n      if (disposed || !state) return;\n      renderCountdown();\n    }, 250);\n  }\n\n  function update(patch: ElementPickerUiPatch): void {\n    if (disposed) return;\n    if (!state || state.sessionId !== patch.sessionId) {\n      // If we don't have matching state yet, ignore update\n      return;\n    }\n\n    state = {\n      ...state,\n      ...patch,\n      sessionId: state.sessionId, // Keep stable\n      requests: patch.requests ?? state.requests,\n      activeRequestId: patch.activeRequestId ?? state.activeRequestId,\n      selections: patch.selections ?? state.selections,\n      deadlineTs: patch.deadlineTs ?? state.deadlineTs,\n      errorMessage: patch.errorMessage ?? state.errorMessage,\n    };\n    render();\n  }\n\n  function hide(): void {\n    clearTimer();\n    state = null;\n    itemElementsMap.clear();\n\n    try {\n      disposer?.dispose();\n    } finally {\n      disposer = null;\n    }\n\n    overlayEl = null;\n    panelEl = null;\n    countdownEl = null;\n    errorEl = null;\n    listEl = null;\n    confirmBtn = null;\n    cancelBtn = null;\n    progressEl = null;\n\n    try {\n      shadowHost?.dispose();\n    } finally {\n      shadowHost = null;\n      elements = null;\n    }\n  }\n\n  function dispose(): void {\n    if (disposed) return;\n    disposed = true;\n    hide();\n  }\n\n  return {\n    show,\n    update,\n    hide,\n    isVisible: () => !!shadowHost && !!elements,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/element-picker/index.ts",
    "content": "/**\n * Element Picker (UI)\n *\n * A Quick Panel-styled floating panel used by chrome_request_element_selection.\n */\n\nexport { createElementPickerController } from './controller';\nexport type {\n  ElementPickerController,\n  ElementPickerControllerOptions,\n  ElementPickerUiState,\n  ElementPickerUiRequest,\n  ElementPickerUiPatch,\n} from './controller';\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/core/agent-bridge.ts",
    "content": "/**\n * Quick Panel Agent Bridge\n *\n * Client-side bridge for Quick Panel (content script) to communicate with\n * the background agent handler. Provides a clean API for sending messages\n * to AI and receiving streaming responses.\n *\n * Features:\n * - Event buffering for handling race conditions\n * - Request lifecycle management\n * - Memory-bounded event storage\n * - Automatic cleanup on terminal events\n *\n * @example\n * ```typescript\n * const bridge = new QuickPanelAgentBridge();\n *\n * // Send a message and subscribe to events\n * const result = await bridge.sendToAI({ instruction: 'Hello' });\n * if (result.success) {\n *   const unsubscribe = bridge.onRequestEvent(result.requestId, (event) => {\n *     console.log('Received event:', event);\n *   });\n * }\n *\n * // Cleanup when done\n * bridge.dispose();\n * ```\n */\n\nimport type { RealtimeEvent } from 'chrome-mcp-shared';\n\nimport {\n  BACKGROUND_MESSAGE_TYPES,\n  TOOL_MESSAGE_TYPES,\n  type QuickPanelAIEventMessage,\n  type QuickPanelCancelAIResponse,\n  type QuickPanelSendToAIPayload,\n  type QuickPanelSendToAIResponse,\n} from '@/common/message-types';\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Callback function for receiving RealtimeEvents.\n */\nexport type RequestEventListener = (event: RealtimeEvent) => void;\n\n/**\n * Configuration options for the agent bridge.\n */\nexport interface AgentBridgeOptions {\n  /** Maximum number of events to buffer per request (default: 200) */\n  maxBufferedEvents?: number;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst LOG_PREFIX = '[QuickPanelAgentBridge]';\nconst DEFAULT_MAX_BUFFERED_EVENTS = 200;\n\n/** Delay before cleaning up request state after terminal event (allows late subscribers) */\nconst TERMINAL_CLEANUP_DELAY_MS = 30000;\n\n// ============================================================\n// Implementation\n// ============================================================\n\n/**\n * Bridge for Quick Panel to communicate with the background agent handler.\n *\n * Responsibilities:\n * 1. Send instructions to AI via background\n * 2. Receive and dispatch streaming events\n * 3. Buffer events for late-subscribing listeners\n * 4. Manage request lifecycle and cleanup\n */\nexport class QuickPanelAgentBridge {\n  /** Listeners organized by requestId */\n  private readonly listenersByRequestId = new Map<string, Set<RequestEventListener>>();\n\n  /** Event buffer for handling race conditions where events arrive before listeners */\n  private readonly bufferByRequestId = new Map<string, RealtimeEvent[]>();\n\n  /** Pending cleanup timers for delayed terminal cleanup */\n  private readonly cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();\n\n  /** Maximum events to buffer per request */\n  private readonly maxBufferedEvents: number;\n\n  /** Message handler bound to this instance */\n  private readonly boundMessageHandler: (message: unknown) => void;\n\n  /** Disposed state flag */\n  private disposed = false;\n\n  constructor(options?: AgentBridgeOptions) {\n    this.maxBufferedEvents = options?.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS;\n    this.boundMessageHandler = this.handleMessage.bind(this);\n\n    // Register message listener\n    chrome.runtime.onMessage.addListener(this.boundMessageHandler);\n  }\n\n  /**\n   * Clean up all resources and unregister listeners.\n   * Should be called when Quick Panel is closing.\n   */\n  dispose(): void {\n    if (this.disposed) return;\n    this.disposed = true;\n\n    chrome.runtime.onMessage.removeListener(this.boundMessageHandler);\n    this.listenersByRequestId.clear();\n    this.bufferByRequestId.clear();\n\n    // Clear all pending cleanup timers\n    for (const timer of this.cleanupTimers.values()) {\n      clearTimeout(timer);\n    }\n    this.cleanupTimers.clear();\n  }\n\n  /**\n   * Check if the bridge has been disposed.\n   */\n  isDisposed(): boolean {\n    return this.disposed;\n  }\n\n  /**\n   * Subscribe to RealtimeEvents for a specific requestId.\n   *\n   * @param requestId - The request ID to subscribe to\n   * @param listener - Callback function for events\n   * @returns Unsubscribe function\n   *\n   * @remarks\n   * Events that arrived before subscription are flushed immediately.\n   * This handles the race condition where background sends events\n   * before the UI has finished setting up listeners.\n   */\n  onRequestEvent(requestId: string, listener: RequestEventListener): () => void {\n    if (this.disposed) {\n      console.warn(`${LOG_PREFIX} Cannot subscribe - bridge is disposed`);\n      return () => {};\n    }\n\n    const id = requestId.trim();\n    if (!id) {\n      console.warn(`${LOG_PREFIX} Invalid requestId`);\n      return () => {};\n    }\n\n    // Add listener to set\n    let listeners = this.listenersByRequestId.get(id);\n    if (!listeners) {\n      listeners = new Set<RequestEventListener>();\n      this.listenersByRequestId.set(id, listeners);\n    }\n    listeners.add(listener);\n\n    // Flush any buffered events to this listener\n    const buffer = this.bufferByRequestId.get(id);\n    if (buffer && buffer.length > 0) {\n      for (const event of buffer) {\n        this.safeInvokeListener(listener, event);\n      }\n      // Clear buffer after flushing\n      this.bufferByRequestId.delete(id);\n    }\n\n    // Return unsubscribe function\n    return () => {\n      const set = this.listenersByRequestId.get(id);\n      if (!set) return;\n\n      set.delete(listener);\n      if (set.size === 0) {\n        this.listenersByRequestId.delete(id);\n      }\n    };\n  }\n\n  /**\n   * Send a new instruction to the selected AgentChat session.\n   *\n   * The background layer will:\n   * 1. Read the selected session ID\n   * 2. Open SSE subscription\n   * 3. POST /act to start the request\n   * 4. Stream events back via QUICK_PANEL_AI_EVENT\n   *\n   * @param payload - The instruction and optional context\n   * @returns Promise resolving to success with requestId/sessionId, or failure with error\n   */\n  async sendToAI(payload: QuickPanelSendToAIPayload): Promise<QuickPanelSendToAIResponse> {\n    if (this.disposed) {\n      return { success: false, error: 'Bridge is disposed' };\n    }\n\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI,\n        payload,\n      });\n\n      return response as QuickPanelSendToAIResponse;\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err);\n      return { success: false, error: msg || 'Failed to send message' };\n    }\n  }\n\n  /**\n   * Cancel an active AI request.\n   *\n   * @param requestId - The request ID to cancel\n   * @param sessionId - Optional session ID for fallback (useful if background state was lost)\n   * @returns Promise resolving to success or failure\n   *\n   * @remarks\n   * Prefer passing sessionId when available for resilience against\n   * MV3 Service Worker restarts that may clear background state.\n   */\n  async cancelRequest(requestId: string, sessionId?: string): Promise<QuickPanelCancelAIResponse> {\n    if (this.disposed) {\n      return { success: false, error: 'Bridge is disposed' };\n    }\n\n    try {\n      const response = await chrome.runtime.sendMessage({\n        type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI,\n        payload: { requestId, sessionId },\n      });\n\n      return response as QuickPanelCancelAIResponse;\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err);\n      return { success: false, error: msg || 'Failed to cancel request' };\n    }\n  }\n\n  /**\n   * Check if there are active listeners for a request.\n   * Useful for determining if UI is still interested in events.\n   */\n  hasListeners(requestId: string): boolean {\n    const listeners = this.listenersByRequestId.get(requestId);\n    return listeners !== undefined && listeners.size > 0;\n  }\n\n  /**\n   * Get the number of active requests being tracked.\n   * Useful for debugging and monitoring.\n   */\n  getActiveRequestCount(): number {\n    return this.listenersByRequestId.size + this.bufferByRequestId.size;\n  }\n\n  // ============================================================\n  // Private Methods\n  // ============================================================\n\n  /**\n   * Handle incoming messages from background.\n   */\n  private handleMessage(message: unknown): void {\n    if (this.disposed) return;\n\n    const msg = message as Partial<QuickPanelAIEventMessage> | undefined;\n    if (!msg || msg.action !== TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT) {\n      return;\n    }\n\n    const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';\n    const event = msg.event as RealtimeEvent | undefined;\n\n    if (!requestId || !event) return;\n\n    // Dispatch to listeners or buffer\n    const listeners = this.listenersByRequestId.get(requestId);\n    if (listeners && listeners.size > 0) {\n      for (const listener of listeners) {\n        this.safeInvokeListener(listener, event);\n      }\n    } else {\n      // No listeners yet - buffer the event\n      this.bufferEvent(requestId, event);\n    }\n\n    // Schedule delayed cleanup on terminal status\n    // This allows late subscribers to still receive the final state\n    if (this.isTerminalEvent(event, requestId)) {\n      this.scheduleDelayedCleanup(requestId);\n    }\n  }\n\n  /**\n   * Safely invoke a listener, catching and logging any errors.\n   */\n  private safeInvokeListener(listener: RequestEventListener, event: RealtimeEvent): void {\n    try {\n      listener(event);\n    } catch (err) {\n      console.warn(`${LOG_PREFIX} Listener error:`, err);\n    }\n  }\n\n  /**\n   * Buffer an event for a request that doesn't have listeners yet.\n   */\n  private bufferEvent(requestId: string, event: RealtimeEvent): void {\n    let buffer = this.bufferByRequestId.get(requestId);\n    if (!buffer) {\n      buffer = [];\n      this.bufferByRequestId.set(requestId, buffer);\n    }\n\n    buffer.push(event);\n\n    // Bound memory by removing oldest events\n    if (buffer.length > this.maxBufferedEvents) {\n      buffer.splice(0, buffer.length - this.maxBufferedEvents);\n    }\n  }\n\n  /**\n   * Check if an event represents a terminal state for the request.\n   *\n   * Terminal events include:\n   * - status events with terminal status (completed, error, cancelled)\n   * - error events (type: 'error')\n   */\n  private isTerminalEvent(event: RealtimeEvent, requestId: string): boolean {\n    // Error events are always terminal\n    if (event.type === 'error') {\n      return true;\n    }\n\n    // Status events with terminal status\n    if (event.type === 'status') {\n      const data = event.data;\n      if (data?.requestId !== requestId) return false;\n\n      const status = data.status;\n      return status === 'completed' || status === 'error' || status === 'cancelled';\n    }\n\n    return false;\n  }\n\n  /**\n   * Clean up all state associated with a request.\n   * Called after delay to allow late subscribers to receive terminal events.\n   */\n  private cleanupRequest(requestId: string): void {\n    // Clear any pending timer first\n    const existingTimer = this.cleanupTimers.get(requestId);\n    if (existingTimer) {\n      clearTimeout(existingTimer);\n      this.cleanupTimers.delete(requestId);\n    }\n\n    this.bufferByRequestId.delete(requestId);\n    this.listenersByRequestId.delete(requestId);\n  }\n\n  /**\n   * Schedule delayed cleanup for a request after terminal event.\n   * This allows late subscribers to still receive the terminal event.\n   */\n  private scheduleDelayedCleanup(requestId: string): void {\n    // Don't schedule if already scheduled\n    if (this.cleanupTimers.has(requestId)) return;\n\n    const timer = setTimeout(() => {\n      this.cleanupTimers.delete(requestId);\n      this.cleanupRequest(requestId);\n    }, TERMINAL_CLEANUP_DELAY_MS);\n\n    this.cleanupTimers.set(requestId, timer);\n  }\n}\n\n// ============================================================\n// Singleton Export (Optional)\n// ============================================================\n\n/**\n * Create a new agent bridge instance.\n * Prefer creating a single instance per Quick Panel lifecycle.\n */\nexport function createAgentBridge(options?: AgentBridgeOptions): QuickPanelAgentBridge {\n  return new QuickPanelAgentBridge(options);\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/core/search-engine.ts",
    "content": "/**\n * Quick Panel Search Engine\n *\n * Aggregates results from multiple SearchProviders.\n *\n * Responsibilities:\n * - Provider registry (add/remove/list)\n * - Scope-based provider selection (including \"all\")\n * - Result aggregation + sorting + caps\n * - Debounced scheduling with cancellation\n * - Short-lived LRU caching to avoid repeat work\n */\n\nimport LRUCache from '@/utils/lru-cache';\nimport {\n  normalizeQuickPanelScope,\n  normalizeSearchQuery,\n  type QuickPanelScope,\n  type SearchProvider,\n  type SearchProviderContext,\n  type SearchQuery,\n  type SearchResult,\n} from './types';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface SearchEngineOptions {\n  /** Initial providers to register */\n  providers?: readonly SearchProvider[];\n  /** Debounce delay in ms. Default: 120 */\n  debounceMs?: number;\n  /** Cache size. Default: 200 */\n  cacheSize?: number;\n  /** Cache TTL in ms. Default: 2000 */\n  cacheTtlMs?: number;\n  /** Per-provider result limit. Default: 8 */\n  perProviderLimit?: number;\n  /** Total result limit. Default: 20 */\n  totalLimit?: number;\n}\n\nexport interface SearchEngineRequest {\n  scope: QuickPanelScope;\n  query: string;\n  limit?: number;\n}\n\nexport interface SearchProviderError {\n  providerId: string;\n  error: string;\n}\n\nexport interface SearchEngineResponse {\n  /** Unique request identifier */\n  requestId: number;\n  /** Original request parameters */\n  request: {\n    scope: QuickPanelScope;\n    query: SearchQuery;\n    limit: number;\n  };\n  /** Aggregated and sorted results */\n  results: SearchResult[];\n  /** Errors from individual providers */\n  providerErrors: SearchProviderError[];\n  /** Whether the request was cancelled */\n  cancelled: boolean;\n  /** Whether results came from cache */\n  fromCache: boolean;\n  /** Time elapsed in ms */\n  elapsedMs: number;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_DEBOUNCE_MS = 120;\nconst DEFAULT_CACHE_SIZE = 200;\nconst DEFAULT_CACHE_TTL_MS = 2000;\nconst DEFAULT_PER_PROVIDER_LIMIT = 8;\nconst DEFAULT_TOTAL_LIMIT = 20;\n\n// ============================================================\n// Helpers\n// ============================================================\n\ninterface CacheEntry {\n  createdAt: number;\n  response: SearchEngineResponse;\n}\n\nfunction normalizeInt(value: unknown, fallback: number): number {\n  const num = typeof value === 'number' ? value : Number.NaN;\n  if (!Number.isFinite(num)) return fallback;\n  return Math.max(0, Math.floor(num));\n}\n\nfunction safeErrorMessage(err: unknown): string {\n  if (err instanceof Error) return err.message || String(err);\n  return String(err);\n}\n\nfunction coerceScore(value: unknown): number {\n  const num = typeof value === 'number' ? value : Number(value);\n  return Number.isFinite(num) ? num : 0;\n}\n\n// ============================================================\n// SearchEngine Class\n// ============================================================\n\nexport class SearchEngine {\n  private readonly providersById = new Map<string, SearchProvider>();\n  private readonly cache: LRUCache<string, CacheEntry>;\n\n  private readonly debounceMs: number;\n  private readonly cacheTtlMs: number;\n  private readonly perProviderLimit: number;\n  private readonly totalLimit: number;\n\n  private disposed = false;\n  private seq = 0;\n  private latestRequestId = 0;\n  private activeAbort: AbortController | null = null;\n\n  private scheduled: {\n    requestId: number;\n    request: SearchEngineRequest;\n    abort: AbortController;\n    timer: ReturnType<typeof setTimeout>;\n    resolve: (value: SearchEngineResponse) => void;\n  } | null = null;\n\n  constructor(options: SearchEngineOptions = {}) {\n    this.debounceMs = normalizeInt(options.debounceMs, DEFAULT_DEBOUNCE_MS);\n    this.cacheTtlMs = normalizeInt(options.cacheTtlMs, DEFAULT_CACHE_TTL_MS);\n    this.perProviderLimit = normalizeInt(options.perProviderLimit, DEFAULT_PER_PROVIDER_LIMIT);\n    this.totalLimit = normalizeInt(options.totalLimit, DEFAULT_TOTAL_LIMIT);\n    this.cache = new LRUCache<string, CacheEntry>(\n      normalizeInt(options.cacheSize, DEFAULT_CACHE_SIZE),\n    );\n\n    // Register initial providers\n    for (const provider of options.providers ?? []) {\n      this.registerProvider(provider);\n    }\n  }\n\n  // --------------------------------------------------------\n  // Provider Management\n  // --------------------------------------------------------\n\n  /**\n   * Register a search provider.\n   * If a provider with the same ID exists, it will be replaced.\n   */\n  registerProvider(provider: SearchProvider): void {\n    if (this.disposed) return;\n\n    const id = String(provider?.id ?? '').trim();\n    if (!id) return;\n\n    // Dispose existing provider with same ID\n    const existing = this.providersById.get(id);\n    if (existing && existing !== provider) {\n      try {\n        existing.dispose?.();\n      } catch {\n        // Best-effort\n      }\n    }\n\n    this.providersById.set(id, provider);\n    // Clear cache when providers change\n    this.cache.clear();\n  }\n\n  /**\n   * Unregister a provider by ID.\n   */\n  unregisterProvider(providerId: string): void {\n    if (this.disposed) return;\n\n    const id = String(providerId ?? '').trim();\n    if (!id) return;\n\n    const existing = this.providersById.get(id);\n    if (!existing) return;\n\n    this.providersById.delete(id);\n    this.cache.clear();\n\n    try {\n      existing.dispose?.();\n    } catch {\n      // Best-effort\n    }\n  }\n\n  /**\n   * List all registered providers.\n   */\n  listProviders(): SearchProvider[] {\n    return [...this.providersById.values()];\n  }\n\n  // --------------------------------------------------------\n  // Lifecycle\n  // --------------------------------------------------------\n\n  /**\n   * Dispose the engine and all providers.\n   */\n  dispose(): void {\n    if (this.disposed) return;\n    this.disposed = true;\n\n    // Cancel scheduled search\n    if (this.scheduled) {\n      clearTimeout(this.scheduled.timer);\n      this.scheduled.abort.abort();\n      this.scheduled.resolve(\n        this.createCancelledResponse(this.scheduled.requestId, this.scheduled.request),\n      );\n      this.scheduled = null;\n    }\n\n    // Cancel active search\n    if (this.activeAbort) {\n      this.activeAbort.abort();\n      this.activeAbort = null;\n    }\n\n    // Dispose all providers\n    for (const provider of this.providersById.values()) {\n      try {\n        provider.dispose?.();\n      } catch {\n        // Best-effort\n      }\n    }\n\n    this.providersById.clear();\n    this.cache.clear();\n  }\n\n  /**\n   * Cancel any active or scheduled search.\n   */\n  cancelActive(): void {\n    if (this.scheduled) {\n      clearTimeout(this.scheduled.timer);\n      this.scheduled.abort.abort();\n      this.scheduled.resolve(\n        this.createCancelledResponse(this.scheduled.requestId, this.scheduled.request),\n      );\n      this.scheduled = null;\n    }\n    this.activeAbort?.abort();\n  }\n\n  // --------------------------------------------------------\n  // Search Methods\n  // --------------------------------------------------------\n\n  /**\n   * Schedule a search with debouncing.\n   * Cancels any pending search and returns the result after the debounce delay.\n   */\n  schedule(request: SearchEngineRequest): Promise<SearchEngineResponse> {\n    if (this.disposed) {\n      return Promise.resolve(this.createCancelledResponse(0, request));\n    }\n\n    // Cancel any pending scheduled search\n    if (this.scheduled) {\n      clearTimeout(this.scheduled.timer);\n      this.scheduled.abort.abort();\n      this.scheduled.resolve(\n        this.createCancelledResponse(this.scheduled.requestId, this.scheduled.request),\n      );\n      this.scheduled = null;\n    }\n\n    // Cancel any active search\n    if (this.activeAbort) {\n      this.activeAbort.abort();\n      this.activeAbort = null;\n    }\n\n    const requestId = ++this.seq;\n    this.latestRequestId = requestId;\n    const abort = new AbortController();\n\n    return new Promise<SearchEngineResponse>((resolve) => {\n      const timer = setTimeout(() => {\n        this.scheduled = null;\n        this.activeAbort = abort;\n\n        void this.execute(requestId, request, abort.signal)\n          .then(resolve)\n          .catch((err) => {\n            resolve(this.createEngineErrorResponse(requestId, request, abort.signal, err));\n          });\n      }, this.debounceMs);\n\n      this.scheduled = { requestId, request, abort, timer, resolve };\n    });\n  }\n\n  /**\n   * Execute a search immediately without debouncing.\n   * Cancels any pending or active search.\n   */\n  async search(request: SearchEngineRequest): Promise<SearchEngineResponse> {\n    if (this.disposed) {\n      return this.createCancelledResponse(0, request);\n    }\n\n    // Cancel scheduled search\n    if (this.scheduled) {\n      clearTimeout(this.scheduled.timer);\n      this.scheduled.abort.abort();\n      this.scheduled.resolve(\n        this.createCancelledResponse(this.scheduled.requestId, this.scheduled.request),\n      );\n      this.scheduled = null;\n    }\n\n    // Cancel active search\n    if (this.activeAbort) {\n      this.activeAbort.abort();\n      this.activeAbort = null;\n    }\n\n    const requestId = ++this.seq;\n    this.latestRequestId = requestId;\n    const abort = new AbortController();\n    this.activeAbort = abort;\n\n    try {\n      return await this.execute(requestId, request, abort.signal);\n    } catch (err) {\n      return this.createEngineErrorResponse(requestId, request, abort.signal, err);\n    }\n  }\n\n  // --------------------------------------------------------\n  // Internal Methods\n  // --------------------------------------------------------\n\n  private createCancelledResponse(\n    requestId: number,\n    request: SearchEngineRequest,\n  ): SearchEngineResponse {\n    const scope = normalizeQuickPanelScope(request?.scope);\n    const query = normalizeSearchQuery(request?.query ?? '');\n    const limit = normalizeInt(request?.limit, this.totalLimit);\n\n    return {\n      requestId,\n      request: { scope, query, limit },\n      results: [],\n      providerErrors: [],\n      cancelled: true,\n      fromCache: false,\n      elapsedMs: 0,\n    };\n  }\n\n  private createEngineErrorResponse(\n    requestId: number,\n    request: SearchEngineRequest,\n    signal: AbortSignal,\n    err: unknown,\n  ): SearchEngineResponse {\n    const scope = normalizeQuickPanelScope(request?.scope);\n    const query = normalizeSearchQuery(request?.query ?? '');\n    const limit = normalizeInt(request?.limit, this.totalLimit);\n    const cancelled = signal.aborted || requestId !== this.latestRequestId;\n\n    return {\n      requestId,\n      request: { scope, query, limit },\n      results: [],\n      providerErrors: [{ providerId: 'engine', error: safeErrorMessage(err) }],\n      cancelled,\n      fromCache: false,\n      elapsedMs: 0,\n    };\n  }\n\n  /**\n   * Get providers that should handle the given scope.\n   */\n  private getProvidersForScope(scope: QuickPanelScope): SearchProvider[] {\n    const providers = [...this.providersById.values()];\n\n    if (scope === 'all') {\n      // Include providers that opt into 'all' meta-scope\n      return providers.filter((p) => (p.includeInAll ?? true) === true);\n    }\n\n    // Match providers that explicitly list this scope\n    return providers.filter((p) => Array.isArray(p.scopes) && p.scopes.includes(scope));\n  }\n\n  /**\n   * Build cache key from request parameters.\n   */\n  private buildCacheKey(\n    scope: QuickPanelScope,\n    query: SearchQuery,\n    limit: number,\n    providers: SearchProvider[],\n  ): string {\n    const providerSig = providers\n      .map((p) => p.id)\n      .filter(Boolean)\n      .sort()\n      .join(',');\n    return `${scope}::${query.text}::${limit}::${providerSig}`;\n  }\n\n  /**\n   * Try to get a valid cached response.\n   */\n  private tryGetCached(key: string, now: number): SearchEngineResponse | null {\n    if (this.cacheTtlMs <= 0) return null;\n\n    const entry = this.cache.get(key);\n    if (!entry) return null;\n    if (now - entry.createdAt > this.cacheTtlMs) return null;\n\n    return entry.response;\n  }\n\n  /**\n   * Store a response in the cache.\n   */\n  private setCached(key: string, response: SearchEngineResponse): void {\n    if (this.cacheTtlMs <= 0) return;\n    this.cache.set(key, { createdAt: Date.now(), response });\n  }\n\n  /**\n   * Execute the search against matching providers.\n   */\n  private async execute(\n    requestId: number,\n    request: SearchEngineRequest,\n    signal: AbortSignal,\n  ): Promise<SearchEngineResponse> {\n    const startedAt = Date.now();\n\n    const scope = normalizeQuickPanelScope(request?.scope);\n    const query = normalizeSearchQuery(request?.query ?? '');\n    const limit = normalizeInt(request?.limit, this.totalLimit);\n\n    const providers = this.getProvidersForScope(scope);\n    const cacheKey = this.buildCacheKey(scope, query, limit, providers);\n\n    // Try cache first\n    const cached = this.tryGetCached(cacheKey, startedAt);\n    if (cached) {\n      return {\n        ...cached,\n        requestId,\n        request: { scope, query, limit },\n        cancelled: signal.aborted || requestId !== this.latestRequestId,\n        fromCache: true,\n        elapsedMs: Date.now() - startedAt,\n      };\n    }\n\n    // Filter providers based on empty query support\n    const eligibleProviders =\n      query.text.length === 0 ? providers.filter((p) => p.supportsEmptyQuery === true) : providers;\n\n    // Build priority map for tie-breaking\n    const priorityById = new Map<string, number>();\n    for (const p of eligibleProviders) {\n      priorityById.set(p.id, typeof p.priority === 'number' ? p.priority : 0);\n    }\n\n    const providerErrors: SearchProviderError[] = [];\n    const results: SearchResult[] = [];\n\n    const perProviderCap = Math.min(limit, this.perProviderLimit);\n    const now = startedAt;\n\n    // Execute all providers in parallel\n    const outcomes = await Promise.all(\n      eligibleProviders.map(async (provider) => {\n        if (signal.aborted) {\n          return {\n            provider,\n            results: [] as SearchResult[],\n            error: undefined as string | undefined,\n          };\n        }\n\n        // Calculate provider-specific limit\n        const providerMax =\n          typeof provider.maxResults === 'number' && Number.isFinite(provider.maxResults)\n            ? Math.max(0, Math.floor(provider.maxResults))\n            : perProviderCap;\n        const providerLimit = Math.min(perProviderCap, providerMax);\n\n        const ctx: SearchProviderContext = {\n          requestedScope: scope,\n          query,\n          limit: providerLimit,\n          signal,\n          now,\n        };\n\n        try {\n          const providerResults = await provider.search(ctx);\n          const safeList = Array.isArray(providerResults) ? providerResults : [];\n\n          // Normalize results\n          const normalized = safeList.slice(0, providerLimit).map((item, index) => {\n            const id =\n              typeof item?.id === 'string' && item.id ? item.id : `${provider.id}_${index}`;\n            return {\n              ...(item as SearchResult),\n              id,\n              provider: provider.id,\n              score: coerceScore((item as SearchResult).score),\n            };\n          });\n\n          return { provider, results: normalized, error: undefined as string | undefined };\n        } catch (err) {\n          return { provider, results: [] as SearchResult[], error: safeErrorMessage(err) };\n        }\n      }),\n    );\n\n    // Collect results and errors\n    for (const out of outcomes) {\n      results.push(...out.results);\n      if (out.error) {\n        providerErrors.push({ providerId: out.provider.id, error: out.error });\n      }\n    }\n\n    // Sort by score (desc), then by priority (desc), then by title (asc)\n    results.sort((a, b) => {\n      const scoreDelta = coerceScore(b.score) - coerceScore(a.score);\n      if (scoreDelta !== 0) return scoreDelta;\n\n      const priA = priorityById.get(a.provider) ?? 0;\n      const priB = priorityById.get(b.provider) ?? 0;\n      if (priA !== priB) return priB - priA;\n\n      return String(a.title ?? '').localeCompare(String(b.title ?? ''));\n    });\n\n    // Apply total limit\n    const sliced = results.slice(0, limit);\n    const cancelled = signal.aborted || requestId !== this.latestRequestId;\n\n    // Filter out abort-related errors when cancelled to avoid UI noise\n    const filteredErrors = cancelled\n      ? providerErrors.filter(\n          (e) =>\n            !e.error.toLowerCase().includes('abort') && !e.error.toLowerCase().includes('cancel'),\n        )\n      : providerErrors;\n\n    const response: SearchEngineResponse = {\n      requestId,\n      request: { scope, query, limit },\n      results: sliced,\n      providerErrors: filteredErrors,\n      cancelled,\n      fromCache: false,\n      elapsedMs: Date.now() - startedAt,\n    };\n\n    // Cache successful non-cancelled response\n    if (!cancelled) {\n      this.setCached(cacheKey, response);\n    }\n\n    // Clean up active abort reference\n    if (this.activeAbort?.signal === signal) {\n      this.activeAbort = null;\n    }\n\n    return response;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/core/types.ts",
    "content": "/**\n * Quick Panel Core Types\n *\n * Shared contracts for the Quick Panel search and UI layers.\n * Framework-agnostic and safe to import from both UI and core modules.\n */\n\n// ============================================================\n// Scope Types\n// ============================================================\n\n/**\n * Available search scopes in Quick Panel\n */\nexport type QuickPanelScope = 'all' | 'tabs' | 'bookmarks' | 'history' | 'content' | 'commands';\n\n/**\n * Scope definition with display properties\n */\nexport interface QuickPanelScopeDefinition {\n  id: QuickPanelScope;\n  label: string;\n  icon: string;\n  /**\n   * Scope prefix for search input recognition.\n   * - Space-terminated prefixes: \"t \", \"b \", \"h \", \"c \"\n   * - Command mode prefix: \">\"\n   * - null for 'all' scope (no prefix)\n   */\n  prefix: string | null;\n}\n\n/** Default scope when no prefix is detected */\nexport const DEFAULT_SCOPE: QuickPanelScope = 'all';\n\n/** Scope definitions following PRD spec */\nexport const QUICK_PANEL_SCOPES: Readonly<Record<QuickPanelScope, QuickPanelScopeDefinition>> = {\n  all: { id: 'all', label: 'All', icon: '\\u2318', prefix: null },\n  tabs: { id: 'tabs', label: 'Tabs', icon: '\\uD83D\\uDDC2\\uFE0F', prefix: 't ' },\n  bookmarks: { id: 'bookmarks', label: 'Bookmarks', icon: '\\u2B50', prefix: 'b ' },\n  history: { id: 'history', label: 'History', icon: '\\uD83D\\uDD50', prefix: 'h ' },\n  content: { id: 'content', label: 'Content', icon: '\\uD83D\\uDCC4', prefix: 'c ' },\n  commands: { id: 'commands', label: 'Commands', icon: '>', prefix: '>' },\n} as const;\n\n/**\n * Type guard for QuickPanelScope\n */\nexport function isQuickPanelScope(value: unknown): value is QuickPanelScope {\n  return typeof value === 'string' && value in QUICK_PANEL_SCOPES;\n}\n\n/**\n * Normalize a value to a valid QuickPanelScope\n */\nexport function normalizeQuickPanelScope(\n  value: unknown,\n  fallback: QuickPanelScope = DEFAULT_SCOPE,\n): QuickPanelScope {\n  return isQuickPanelScope(value) ? value : fallback;\n}\n\n// ============================================================\n// Scope Prefix Parsing\n// ============================================================\n\n/**\n * Result of parsing a scope-prefixed query string\n */\nexport interface ParsedScopeQuery {\n  /** Original input string */\n  raw: string;\n  /** Detected or default scope */\n  scope: QuickPanelScope;\n  /** Query string with prefix removed */\n  query: string;\n  /** Whether a scope prefix was recognized and consumed */\n  consumedPrefix: boolean;\n}\n\n/**\n * Parse a scope-prefixed input string following PRD conventions:\n * - `>foo` -> scope=commands, query=\"foo\"\n * - `t foo` -> scope=tabs, query=\"foo\"\n * - `b foo` -> scope=bookmarks, query=\"foo\"\n * - `h foo` -> scope=history, query=\"foo\"\n * - `c foo` -> scope=content, query=\"foo\"\n *\n * Only checks the beginning of the string (after trimming leading whitespace).\n */\nexport function parseScopePrefixedQuery(\n  rawInput: string,\n  defaultScope: QuickPanelScope = DEFAULT_SCOPE,\n): ParsedScopeQuery {\n  const raw = typeof rawInput === 'string' ? rawInput : '';\n  const leadingTrimmed = raw.replace(/^\\s+/, '');\n\n  // Check for command mode prefix (>)\n  if (leadingTrimmed.startsWith('>')) {\n    return {\n      raw,\n      scope: 'commands',\n      query: leadingTrimmed.slice(1).trimStart(),\n      consumedPrefix: true,\n    };\n  }\n\n  // Check for space-terminated scope prefixes (t, b, h, c)\n  const match = leadingTrimmed.match(/^([tbhc])\\s+(.*)$/s);\n  if (match) {\n    const prefix = match[1];\n    const rest = (match[2] ?? '').trimStart();\n\n    const scopeMap: Record<string, QuickPanelScope> = {\n      t: 'tabs',\n      b: 'bookmarks',\n      h: 'history',\n      c: 'content',\n    };\n\n    const scope = scopeMap[prefix] ?? defaultScope;\n\n    return { raw, scope, query: rest, consumedPrefix: true };\n  }\n\n  // No prefix detected\n  return { raw, scope: defaultScope, query: raw.trim(), consumedPrefix: false };\n}\n\n// ============================================================\n// Search Results\n// ============================================================\n\n/**\n * Icon type - can be an emoji string or a DOM node\n */\nexport type QuickPanelIcon = string | Node;\n\n/**\n * Search result from a provider\n */\nexport interface SearchResult<TData = unknown> {\n  /** Unique identifier within the provider */\n  id: string;\n  /** Provider that generated this result */\n  provider: string;\n  /** Display title */\n  title: string;\n  /** Optional subtitle/description */\n  subtitle?: string;\n  /** Icon (emoji or DOM node) */\n  icon?: QuickPanelIcon;\n  /** Provider-specific data */\n  data: TData;\n  /** Relevance score (higher is better) */\n  score: number;\n}\n\n// ============================================================\n// Actions\n// ============================================================\n\n/**\n * Visual tone for actions\n */\nexport type ActionTone = 'default' | 'danger';\n\n/**\n * Context passed to action execution\n */\nexport interface ActionContext<TData = unknown> {\n  /** The search result being acted upon */\n  result: SearchResult<TData>;\n  /**\n   * Optional open mode hint for navigation actions.\n   * Providers can ignore if not applicable.\n   */\n  openMode?: 'current_tab' | 'new_tab' | 'background_tab';\n}\n\n/**\n * An action that can be performed on a search result\n */\nexport interface Action<TData = unknown> {\n  /** Unique identifier */\n  id: string;\n  /** Display title */\n  title: string;\n  /** Optional subtitle */\n  subtitle?: string;\n  /** Icon */\n  icon?: QuickPanelIcon;\n  /** Visual tone */\n  tone?: ActionTone;\n  /**\n   * Hotkey hint for UI display (e.g., \"Enter\", \"Cmd+Enter\").\n   * Controller remains source of truth for actual keybindings.\n   */\n  hotkeyHint?: string;\n  /** Check if action is available for given context */\n  isAvailable?: (ctx: ActionContext<TData>) => boolean;\n  /** Execute the action */\n  execute: (ctx: ActionContext<TData>) => void | Promise<void>;\n}\n\n// ============================================================\n// Search Query\n// ============================================================\n\n/**\n * Normalized search query passed to providers.\n */\nexport interface SearchQuery {\n  /** Original, unmodified query string */\n  raw: string;\n  /**\n   * Normalized query used for matching/caching:\n   * - trimmed\n   * - collapsed whitespace\n   * - lowercased\n   */\n  text: string;\n  /** Tokenized representation of `text` */\n  tokens: string[];\n}\n\n/**\n * Normalize a raw query string to SearchQuery format.\n */\nexport function normalizeSearchQuery(raw: string): SearchQuery {\n  const input = typeof raw === 'string' ? raw : '';\n  const trimmed = input.trim();\n  const text = trimmed.replace(/\\s+/g, ' ').toLowerCase();\n  const tokens = text ? text.split(' ').filter(Boolean) : [];\n  return { raw: input, text, tokens };\n}\n\n// ============================================================\n// Providers\n// ============================================================\n\n/**\n * Context passed to provider.search method.\n */\nexport interface SearchProviderContext {\n  /** The scope selected by the user (may be 'all'). */\n  requestedScope: QuickPanelScope;\n  /** Normalized query info */\n  query: SearchQuery;\n  /** Max results requested for this provider */\n  limit: number;\n  /** Abort signal for cancellation */\n  signal: AbortSignal;\n  /** Timestamp (ms) for consistent scoring */\n  now: number;\n}\n\n/**\n * Search provider interface.\n *\n * Providers are responsible for:\n * - Searching a specific data source\n * - Ranking results with a score\n * - Providing actions for results\n */\nexport interface SearchProvider<TData = unknown> {\n  /** Unique provider identifier */\n  id: string;\n  /** Display name */\n  name: string;\n  /** Provider icon */\n  icon: string;\n\n  /**\n   * Scopes this provider can handle.\n   *\n   * Note: 'all' is a meta-scope and should not usually be listed here.\n   * The SearchEngine will include providers in 'all' based on includeInAll.\n   */\n  scopes: readonly QuickPanelScope[];\n\n  /**\n   * Whether this provider participates in the 'all' meta-scope.\n   * Default: true\n   */\n  includeInAll?: boolean;\n\n  /**\n   * Provider priority used as a tie-breaker when scores are equal (higher wins).\n   * Default: 0\n   */\n  priority?: number;\n\n  /**\n   * Provider-level hard cap for returned items (optional).\n   * The SearchEngine may apply additional caps.\n   */\n  maxResults?: number;\n\n  /**\n   * Whether the provider wants to run for empty queries.\n   * Default: false\n   */\n  supportsEmptyQuery?: boolean;\n\n  /**\n   * Search for results matching the query.\n   *\n   * @param ctx - Search context with query, limit, signal, etc.\n   * @returns Promise of search results\n   */\n  search: (ctx: SearchProviderContext) => Promise<SearchResult<TData>[]>;\n\n  /**\n   * Get available actions for a result.\n   *\n   * @param item - The search result to get actions for\n   * @returns Array of available actions\n   */\n  getActions: (item: SearchResult<TData>) => Action<TData>[];\n\n  /**\n   * Optional cleanup hook for releasing resources.\n   * Called when provider is unregistered or engine is disposed.\n   */\n  dispose?: () => void;\n}\n\n// ============================================================\n// Panel View Types\n// ============================================================\n\n/**\n * Available views in Quick Panel\n */\nexport type QuickPanelView = 'search' | 'chat';\n\n/**\n * Panel state\n */\nexport interface QuickPanelState {\n  view: QuickPanelView;\n  scope: QuickPanelScope;\n  query: string;\n  results: SearchResult[];\n  selectedIndex: number;\n  isLoading: boolean;\n  errorMessage: string | null;\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/index.ts",
    "content": "/**\n * Quick Panel Entry Point\n *\n * This module provides the main controller for Quick Panel functionality.\n * It orchestrates:\n * - Shadow DOM host management\n * - AI Chat panel lifecycle\n * - Agent bridge communication\n * - Keyboard shortcut handling (external)\n *\n * Usage in content script:\n * ```typescript\n * import { createQuickPanelController } from './quick-panel';\n *\n * const controller = createQuickPanelController();\n *\n * // Show panel (e.g., on keyboard shortcut)\n * controller.show();\n *\n * // Hide panel\n * controller.hide();\n *\n * // Toggle visibility\n * controller.toggle();\n *\n * // Cleanup on unload\n * controller.dispose();\n * ```\n */\n\nimport { createAgentBridge, type QuickPanelAgentBridge } from './core/agent-bridge';\nimport {\n  mountQuickPanelShadowHost,\n  mountQuickPanelAiChatPanel,\n  type QuickPanelShadowHostManager,\n  type QuickPanelAiChatPanelManager,\n} from './ui';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface QuickPanelControllerOptions {\n  /** Custom host element ID for Shadow DOM. Default: '__mcp_quick_panel_host__' */\n  hostId?: string;\n  /** Custom z-index for overlay. Default: 2147483647 (highest possible) */\n  zIndex?: number;\n  /** Panel title. Default: 'Agent' */\n  title?: string;\n  /** Panel subtitle. Default: 'Quick Panel' */\n  subtitle?: string;\n  /** Input placeholder. Default: 'Ask the agent...' */\n  placeholder?: string;\n}\n\nexport interface QuickPanelController {\n  /** Show the Quick Panel (creates if not exists) */\n  show: () => void;\n  /** Hide the Quick Panel (disposes UI but keeps bridge alive) */\n  hide: () => void;\n  /** Toggle Quick Panel visibility */\n  toggle: () => void;\n  /** Check if panel is currently visible */\n  isVisible: () => boolean;\n  /** Fully dispose all resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst LOG_PREFIX = '[QuickPanelController]';\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Create a Quick Panel controller instance.\n *\n * The controller manages the full lifecycle of the Quick Panel UI,\n * including Shadow DOM isolation, AI chat interface, and background\n * communication.\n *\n * @example\n * ```typescript\n * // In content script\n * const quickPanel = createQuickPanelController();\n *\n * // Listen for keyboard shortcut (e.g., Cmd+Shift+K)\n * document.addEventListener('keydown', (e) => {\n *   if (e.metaKey && e.shiftKey && e.key === 'k') {\n *     e.preventDefault();\n *     quickPanel.toggle();\n *   }\n * });\n *\n * // Cleanup on extension unload\n * window.addEventListener('unload', () => {\n *   quickPanel.dispose();\n * });\n * ```\n */\nexport function createQuickPanelController(\n  options: QuickPanelControllerOptions = {},\n): QuickPanelController {\n  let disposed = false;\n\n  // Shared agent bridge (persists across show/hide cycles)\n  let agentBridge: QuickPanelAgentBridge | null = null;\n\n  // UI components (created on show, disposed on hide)\n  let shadowHost: QuickPanelShadowHostManager | null = null;\n  let chatPanel: QuickPanelAiChatPanelManager | null = null;\n\n  /**\n   * Ensure agent bridge is initialized\n   */\n  function ensureBridge(): QuickPanelAgentBridge {\n    if (!agentBridge || agentBridge.isDisposed()) {\n      agentBridge = createAgentBridge();\n    }\n    return agentBridge;\n  }\n\n  /**\n   * Dispose current UI (keeps bridge alive for potential reuse)\n   */\n  function disposeUI(): void {\n    if (chatPanel) {\n      try {\n        chatPanel.dispose();\n      } catch (err) {\n        console.warn(`${LOG_PREFIX} Error disposing chat panel:`, err);\n      }\n      chatPanel = null;\n    }\n\n    if (shadowHost) {\n      try {\n        shadowHost.dispose();\n      } catch (err) {\n        console.warn(`${LOG_PREFIX} Error disposing shadow host:`, err);\n      }\n      shadowHost = null;\n    }\n  }\n\n  /**\n   * Show the Quick Panel\n   */\n  function show(): void {\n    if (disposed) {\n      console.warn(`${LOG_PREFIX} Cannot show - controller is disposed`);\n      return;\n    }\n\n    // Already visible\n    if (chatPanel && shadowHost?.getElements()) {\n      chatPanel.focusInput();\n      return;\n    }\n\n    // Clean up any stale UI\n    disposeUI();\n\n    // Create shadow host\n    shadowHost = mountQuickPanelShadowHost({\n      hostId: options.hostId,\n      zIndex: options.zIndex,\n    });\n\n    const elements = shadowHost.getElements();\n    if (!elements) {\n      console.error(`${LOG_PREFIX} Failed to create shadow host elements`);\n      disposeUI();\n      return;\n    }\n\n    // Ensure bridge is ready\n    const bridge = ensureBridge();\n\n    // Create chat panel\n    chatPanel = mountQuickPanelAiChatPanel({\n      mount: elements.root,\n      agentBridge: bridge,\n      title: options.title,\n      subtitle: options.subtitle,\n      placeholder: options.placeholder,\n      autoFocus: true,\n      onRequestClose: () => hide(),\n    });\n  }\n\n  /**\n   * Hide the Quick Panel\n   */\n  function hide(): void {\n    if (disposed) return;\n    disposeUI();\n  }\n\n  /**\n   * Toggle Quick Panel visibility\n   */\n  function toggle(): void {\n    if (disposed) return;\n\n    if (isVisible()) {\n      hide();\n    } else {\n      show();\n    }\n  }\n\n  /**\n   * Check if panel is currently visible\n   */\n  function isVisible(): boolean {\n    return chatPanel !== null && shadowHost?.getElements() !== null;\n  }\n\n  /**\n   * Fully dispose all resources\n   */\n  function dispose(): void {\n    if (disposed) return;\n    disposed = true;\n\n    disposeUI();\n\n    if (agentBridge) {\n      try {\n        agentBridge.dispose();\n      } catch (err) {\n        console.warn(`${LOG_PREFIX} Error disposing agent bridge:`, err);\n      }\n      agentBridge = null;\n    }\n  }\n\n  return {\n    show,\n    hide,\n    toggle,\n    isVisible,\n    dispose,\n  };\n}\n\n// ============================================================\n// Re-exports for convenience\n// ============================================================\n\n// Core types\nexport {\n  DEFAULT_SCOPE,\n  QUICK_PANEL_SCOPES,\n  normalizeQuickPanelScope,\n  parseScopePrefixedQuery,\n  normalizeSearchQuery,\n} from './core/types';\n\nexport type {\n  QuickPanelScope,\n  QuickPanelScopeDefinition,\n  QuickPanelView,\n  ParsedScopeQuery,\n  QuickPanelIcon,\n  SearchResult,\n  ActionTone,\n  ActionContext,\n  Action,\n  SearchQuery,\n  SearchProviderContext,\n  SearchProvider,\n  QuickPanelState,\n} from './core/types';\n\n// Agent bridge\nexport { createAgentBridge } from './core/agent-bridge';\nexport type {\n  QuickPanelAgentBridge,\n  RequestEventListener,\n  AgentBridgeOptions,\n} from './core/agent-bridge';\n\n// UI Components\nexport {\n  // Shadow host\n  mountQuickPanelShadowHost,\n  // Panel shell (unified container)\n  mountQuickPanelShell,\n  // AI Chat\n  mountQuickPanelAiChatPanel,\n  createQuickPanelMessageRenderer,\n  // Search UI\n  createSearchInput,\n  createQuickEntries,\n  // Styles\n  QUICK_PANEL_STYLES,\n} from './ui';\n\nexport type {\n  // Shadow host\n  QuickPanelShadowHostElements,\n  QuickPanelShadowHostManager,\n  QuickPanelShadowHostOptions,\n  // Panel shell\n  QuickPanelShellElements,\n  QuickPanelShellManager,\n  QuickPanelShellOptions,\n  // AI Chat\n  QuickPanelAiChatPanelManager,\n  QuickPanelAiChatPanelOptions,\n  QuickPanelAiChatPanelState,\n  QuickPanelMessageRenderer,\n  QuickPanelMessageRendererOptions,\n  // Search input\n  SearchInputManager,\n  SearchInputOptions,\n  SearchInputState,\n  // Quick entries\n  QuickEntriesManager,\n  QuickEntriesOptions,\n} from './ui';\n\n// Search Engine\nexport { SearchEngine } from './core/search-engine';\nexport type {\n  SearchEngineOptions,\n  SearchEngineRequest,\n  SearchEngineResponse,\n  SearchProviderError,\n} from './core/search-engine';\n\n// Search Providers\nexport { createTabsProvider } from './providers';\nexport type { TabsProviderOptions, TabsSearchResultData } from './providers';\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/providers/index.ts",
    "content": "/**\n * Quick Panel Search Providers\n *\n * Exports all search providers for Quick Panel.\n */\n\nexport {\n  createTabsProvider,\n  type TabsProviderOptions,\n  type TabsSearchResultData,\n} from './tabs-provider';\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/providers/tabs-provider.ts",
    "content": "/**\n * Tabs Search Provider (Quick Panel)\n *\n * Searches open browser tabs via background service worker bridge.\n * Runs in content script Quick Panel UI - delegates tab operations\n * to the background service worker via chrome.runtime messaging.\n */\n\nimport {\n  BACKGROUND_MESSAGE_TYPES,\n  type QuickPanelActivateTabResponse,\n  type QuickPanelCloseTabResponse,\n  type QuickPanelTabSummary,\n  type QuickPanelTabsQueryResponse,\n} from '@/common/message-types';\nimport type { Action, SearchProvider, SearchProviderContext, SearchResult } from '../core/types';\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Data associated with a tab search result.\n */\nexport interface TabsSearchResultData {\n  tabId: number;\n  windowId: number;\n  url: string;\n  title: string;\n  favIconUrl?: string;\n  pinned: boolean;\n  active: boolean;\n}\n\nexport interface TabsProviderOptions {\n  /** Provider ID. Default: 'tabs' */\n  id?: string;\n  /** Display name. Default: 'Tabs' */\n  name?: string;\n  /** Icon. Default: '🗂️' */\n  icon?: string;\n  /** Include tabs from all windows. Default: true */\n  includeAllWindows?: boolean;\n}\n\n// ============================================================\n// Tabs Client (Background Bridge)\n// ============================================================\n\ninterface TabsSnapshot {\n  tabs: QuickPanelTabSummary[];\n  currentTabId: number | null;\n  currentWindowId: number | null;\n}\n\ninterface TabsClient {\n  listTabs: (options: { includeAllWindows: boolean; signal: AbortSignal }) => Promise<TabsSnapshot>;\n  activateTab: (tabId: number, windowId?: number) => Promise<void>;\n  closeTab: (tabId: number) => Promise<void>;\n}\n\nfunction createRuntimeTabsClient(): TabsClient {\n  async function listTabs(options: {\n    includeAllWindows: boolean;\n    signal: AbortSignal;\n  }): Promise<TabsSnapshot> {\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      throw new Error('chrome.runtime.sendMessage is not available');\n    }\n    if (options.signal.aborted) {\n      throw new Error('aborted');\n    }\n\n    const resp = (await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY,\n      payload: { includeAllWindows: options.includeAllWindows },\n    })) as QuickPanelTabsQueryResponse;\n\n    if (!resp || resp.success !== true) {\n      const err = (resp as { error?: unknown })?.error;\n      throw new Error(typeof err === 'string' ? err : 'Failed to query tabs');\n    }\n\n    return {\n      tabs: resp.tabs,\n      currentTabId: resp.currentTabId,\n      currentWindowId: resp.currentWindowId,\n    };\n  }\n\n  async function activateTab(tabId: number, windowId?: number): Promise<void> {\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      throw new Error('chrome.runtime.sendMessage is not available');\n    }\n\n    const resp = (await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE,\n      payload: { tabId, windowId },\n    })) as QuickPanelActivateTabResponse;\n\n    if (!resp || resp.success !== true) {\n      const err = (resp as { error?: unknown })?.error;\n      throw new Error(typeof err === 'string' ? err : 'Failed to activate tab');\n    }\n  }\n\n  async function closeTab(tabId: number): Promise<void> {\n    if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {\n      throw new Error('chrome.runtime.sendMessage is not available');\n    }\n\n    const resp = (await chrome.runtime.sendMessage({\n      type: BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE,\n      payload: { tabId },\n    })) as QuickPanelCloseTabResponse;\n\n    if (!resp || resp.success !== true) {\n      const err = (resp as { error?: unknown })?.error;\n      throw new Error(typeof err === 'string' ? err : 'Failed to close tab');\n    }\n  }\n\n  return { listTabs, activateTab, closeTab };\n}\n\n// ============================================================\n// Scoring Helpers\n// ============================================================\n\nfunction normalizeText(value: unknown): string {\n  return String(value ?? '')\n    .trim()\n    .toLowerCase()\n    .replace(/\\s+/g, ' ');\n}\n\nfunction normalizeUrl(value: unknown): string {\n  const raw = String(value ?? '').trim();\n  if (!raw) return '';\n\n  // Remove protocol and www prefix for cleaner matching\n  let text = raw.replace(/^https?:\\/\\//i, '').replace(/^www\\./i, '');\n\n  // Attempt URL decode\n  try {\n    text = decodeURIComponent(text);\n  } catch {\n    // Best-effort\n  }\n\n  return normalizeText(text);\n}\n\n/**\n * Check if needle is a subsequence of haystack.\n */\nfunction isSubsequence(needle: string, haystack: string): boolean {\n  if (!needle) return true;\n  let i = 0;\n  for (const ch of haystack) {\n    if (ch === needle[i]) i++;\n    if (i >= needle.length) return true;\n  }\n  return false;\n}\n\n/** Minimum token length for subsequence matching (to avoid over-matching) */\nconst MIN_SUBSEQUENCE_TOKEN_LENGTH = 3;\n\n/**\n * Check if character is a word boundary.\n */\nfunction isBoundaryChar(ch: string): boolean {\n  return (\n    ch === '' ||\n    ch === ' ' ||\n    ch === '/' ||\n    ch === '-' ||\n    ch === '_' ||\n    ch === '.' ||\n    ch === ':' ||\n    ch === '#' ||\n    ch === '?' ||\n    ch === '&'\n  );\n}\n\n/**\n * Score a single token against a haystack string.\n * Returns 0 if no match, higher values for better matches.\n */\nfunction scoreToken(haystack: string, token: string): number {\n  if (!haystack || !token) return 0;\n\n  // Exact match\n  if (haystack === token) return 1;\n\n  // Prefix match\n  if (haystack.startsWith(token)) return 0.95;\n\n  // Substring match\n  const idx = haystack.indexOf(token);\n  if (idx >= 0) {\n    const prev = idx > 0 ? haystack[idx - 1] : '';\n    const boundaryBoost = isBoundaryChar(prev) ? 0.15 : 0;\n    const positionPenalty = idx / Math.max(1, haystack.length);\n    return Math.max(0.55, 0.8 + boundaryBoost - positionPenalty * 0.2);\n  }\n\n  // Subsequence match (fuzzy) - only for tokens >= MIN_SUBSEQUENCE_TOKEN_LENGTH\n  if (token.length >= MIN_SUBSEQUENCE_TOKEN_LENGTH && isSubsequence(token, haystack)) {\n    return 0.4;\n  }\n\n  return 0;\n}\n\n/**\n * Compute overall score for a tab based on query tokens.\n * Each token can match in EITHER title OR url (cross-field matching).\n */\nfunction computeTabScore(\n  tab: QuickPanelTabSummary,\n  queryTokens: readonly string[],\n  currentWindowId: number | null,\n  currentTabId: number | null,\n): number {\n  if (queryTokens.length === 0) return 0;\n\n  const normalizedTitle = normalizeText(tab.title);\n  const normalizedUrl = normalizeUrl(tab.url);\n\n  // For each token, take the best score from title or url\n  let totalScore = 0;\n  for (const token of queryTokens) {\n    const titleTokenScore = scoreToken(normalizedTitle, token);\n    const urlTokenScore = scoreToken(normalizedUrl, token);\n    const bestScore = Math.max(titleTokenScore, urlTokenScore);\n\n    // If token doesn't match either field, reject this tab\n    if (bestScore <= 0) return 0;\n\n    // Weight title matches higher than url matches (title: 0.75, url: 0.25)\n    const weightedScore = titleTokenScore * 0.75 + urlTokenScore * 0.25;\n    totalScore += weightedScore;\n  }\n\n  const base = (totalScore / queryTokens.length) * 100;\n  if (base <= 0) return 0;\n\n  // Boost for context relevance\n  let boost = 0;\n  if (typeof currentWindowId === 'number' && tab.windowId === currentWindowId) {\n    boost += 10;\n  }\n  if (typeof currentTabId === 'number' && tab.tabId === currentTabId) {\n    boost += 15;\n  } else if (tab.active) {\n    boost += 6;\n  }\n  if (tab.pinned) boost += 4;\n  if (tab.audible) boost += 2;\n\n  return base + boost;\n}\n\n/**\n * Sort tabs by score with tie-breaking rules.\n */\nfunction sortTabs(\n  a: { tab: QuickPanelTabSummary; score: number },\n  b: { tab: QuickPanelTabSummary; score: number },\n  currentWindowId: number | null,\n  currentTabId: number | null,\n): number {\n  // Primary: score descending\n  if (b.score !== a.score) return b.score - a.score;\n\n  // Tie-breaker 1: current tab first\n  const aIsCurrentTab = typeof currentTabId === 'number' && a.tab.tabId === currentTabId;\n  const bIsCurrentTab = typeof currentTabId === 'number' && b.tab.tabId === currentTabId;\n  if (aIsCurrentTab !== bIsCurrentTab) return aIsCurrentTab ? -1 : 1;\n\n  // Tie-breaker 2: current window first\n  const aIsCurrentWin = typeof currentWindowId === 'number' && a.tab.windowId === currentWindowId;\n  const bIsCurrentWin = typeof currentWindowId === 'number' && b.tab.windowId === currentWindowId;\n  if (aIsCurrentWin !== bIsCurrentWin) return aIsCurrentWin ? -1 : 1;\n\n  // Tie-breaker 3: pinned first\n  if (a.tab.pinned !== b.tab.pinned) return a.tab.pinned ? -1 : 1;\n\n  // Tie-breaker 4: active first\n  if (a.tab.active !== b.tab.active) return a.tab.active ? -1 : 1;\n\n  // Tie-breaker 5: tab index\n  return a.tab.index - b.tab.index;\n}\n\n// ============================================================\n// Provider Factory\n// ============================================================\n\n/**\n * Create a Tabs search provider for Quick Panel.\n *\n * @example\n * ```typescript\n * const tabsProvider = createTabsProvider();\n * searchEngine.registerProvider(tabsProvider);\n * ```\n */\nexport function createTabsProvider(\n  options: TabsProviderOptions = {},\n): SearchProvider<TabsSearchResultData> {\n  const id = options.id?.trim() || 'tabs';\n  const name = options.name?.trim() || 'Tabs';\n  const icon = options.icon?.trim() || '\\uD83D\\uDDC2\\uFE0F'; // 🗂️\n  const includeAllWindows = options.includeAllWindows ?? true;\n\n  const client: TabsClient = createRuntimeTabsClient();\n\n  /**\n   * Get actions available for a tab result.\n   */\n  function getActions(item: SearchResult<TabsSearchResultData>): Action<TabsSearchResultData>[] {\n    const tabId = item.data.tabId;\n    const windowId = item.data.windowId;\n\n    return [\n      {\n        id: 'tabs.activate',\n        title: 'Switch to tab',\n        hotkeyHint: 'Enter',\n        execute: async () => {\n          await client.activateTab(tabId, windowId);\n        },\n      },\n      {\n        id: 'tabs.close',\n        title: 'Close tab',\n        tone: 'danger',\n        execute: async () => {\n          await client.closeTab(tabId);\n        },\n      },\n    ];\n  }\n\n  /**\n   * Search for tabs matching the query.\n   */\n  async function search(ctx: SearchProviderContext): Promise<SearchResult<TabsSearchResultData>[]> {\n    if (ctx.signal.aborted) return [];\n\n    const snapshot = await client.listTabs({ includeAllWindows, signal: ctx.signal });\n    if (ctx.signal.aborted) return [];\n\n    const tokens = ctx.query.tokens;\n    const limit = ctx.limit;\n\n    const scored: Array<{ tab: QuickPanelTabSummary; score: number }> = [];\n\n    for (const tab of snapshot.tabs) {\n      // Skip invalid tabs\n      if (typeof tab.tabId !== 'number' || tab.tabId <= 0) continue;\n\n      // Empty query: show all tabs with recency-based scoring\n      if (tokens.length === 0) {\n        let score = 0;\n        if (typeof snapshot.currentTabId === 'number' && tab.tabId === snapshot.currentTabId) {\n          score += 100;\n        }\n        if (\n          typeof snapshot.currentWindowId === 'number' &&\n          tab.windowId === snapshot.currentWindowId\n        ) {\n          score += 30;\n        }\n        if (tab.active) score += 20;\n        if (tab.pinned) score += 10;\n\n        // Use lastAccessed for recency (more recent = higher score)\n        const lastAccessed = tab.lastAccessed;\n        if (typeof lastAccessed === 'number' && Number.isFinite(lastAccessed) && lastAccessed > 0) {\n          // Normalize recency: more recent tabs get higher bonus (up to 15 points)\n          // Clamp ageMs to prevent negative values from future timestamps\n          const ageMs = Math.max(0, ctx.now - lastAccessed);\n          // Decay over 1 hour, clamp bonus to [0, 15]\n          const recencyBonus = Math.max(0, Math.min(15, 15 - ageMs / (1000 * 60 * 60)));\n          score += recencyBonus;\n        } else {\n          // Fallback to index if lastAccessed not available\n          score += Math.max(0, 5 - tab.index * 0.05);\n        }\n\n        scored.push({ tab, score });\n        continue;\n      }\n\n      // Query-based scoring\n      const score = computeTabScore(tab, tokens, snapshot.currentWindowId, snapshot.currentTabId);\n      if (score > 0) {\n        scored.push({ tab, score });\n      }\n    }\n\n    // Sort and limit\n    scored.sort((a, b) => sortTabs(a, b, snapshot.currentWindowId, snapshot.currentTabId));\n    const top = scored.slice(0, limit);\n\n    // Convert to SearchResult format\n    return top.map(({ tab, score }) => {\n      const title = tab.title?.trim() || tab.url || 'Untitled';\n      const url = tab.url?.trim() || '';\n\n      const data: TabsSearchResultData = {\n        tabId: tab.tabId,\n        windowId: tab.windowId,\n        title,\n        url,\n        favIconUrl: tab.favIconUrl,\n        pinned: tab.pinned,\n        active: tab.active,\n      };\n\n      return {\n        id: String(tab.tabId),\n        provider: id,\n        title,\n        subtitle: url,\n        icon,\n        data,\n        score,\n      };\n    });\n  }\n\n  return {\n    id,\n    name,\n    icon,\n    scopes: ['tabs'],\n    includeInAll: true,\n    priority: 50, // High priority for tab switching\n    maxResults: 50,\n    supportsEmptyQuery: true,\n    search,\n    getActions,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/ai-chat-panel.ts",
    "content": "/**\n * Quick Panel AI Chat Panel\n *\n * A complete AI chat interface for Quick Panel, featuring:\n * - Streaming message display with real-time updates\n * - Liquid Glass design with AgentChat token compatibility\n * - Full keyboard navigation (Enter to send, Esc to close)\n * - Request lifecycle management (send, cancel, cleanup)\n * - Auto-context collection (page URL, text selection)\n *\n * This component is framework-agnostic and renders directly to Shadow DOM\n * for optimal isolation and performance in content script context.\n */\n\nimport type {\n  AgentMessage,\n  AgentStatusEvent,\n  AgentUsageStats,\n  RealtimeEvent,\n} from 'chrome-mcp-shared';\n\nimport type { QuickPanelAIContext, QuickPanelSendToAIPayload } from '@/common/message-types';\nimport type { QuickPanelAgentBridge } from '../core/agent-bridge';\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport {\n  createQuickPanelMessageRenderer,\n  type QuickPanelMessageRenderer,\n} from './message-renderer';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface QuickPanelAiChatPanelOptions {\n  /** Shadow DOM mount point (typically `elements.root` from shadow-host.ts) */\n  mount: HTMLElement;\n  /** Agent bridge for background communication */\n  agentBridge: QuickPanelAgentBridge;\n\n  /** Header title. Default: \"Agent\" */\n  title?: string;\n  /** Header subtitle. Default: \"Quick Panel\" */\n  subtitle?: string;\n  /** Input placeholder. Default: \"Ask the agent...\" */\n  placeholder?: string;\n  /** Auto-focus textarea on mount. Default: true */\n  autoFocus?: boolean;\n\n  /** Optional context provider for enhanced AI understanding */\n  getContext?: () => QuickPanelAIContext | null | Promise<QuickPanelAIContext | null>;\n\n  /** Called when user requests to close the panel */\n  onRequestClose?: () => void;\n}\n\nexport interface QuickPanelAiChatPanelState {\n  sending: boolean;\n  streaming: boolean;\n  cancelling: boolean;\n  currentRequestId: string | null;\n  sessionId: string | null;\n  lastStatus: AgentStatusEvent['status'] | null;\n  lastUsage: AgentUsageStats | null;\n  errorMessage: string | null;\n}\n\nexport interface QuickPanelAiChatPanelManager {\n  getState: () => QuickPanelAiChatPanelState;\n  focusInput: () => void;\n  clearMessages: () => void;\n  close: () => void;\n  dispose: () => void;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst LOG_PREFIX = '[QuickPanelAiChatPanel]';\n\nconst DEFAULT_TITLE = 'Agent';\nconst DEFAULT_SUBTITLE = 'Quick Panel';\nconst DEFAULT_PLACEHOLDER = 'Ask the agent...';\n\n/** Max chars for selected text context to avoid payload bloat */\nconst MAX_SELECTED_TEXT_CHARS = 3000;\n/** Max chars for error message display */\nconst MAX_ERROR_DISPLAY_CHARS = 600;\n\nconst TEXTAREA_MIN_HEIGHT_PX = 42;\nconst TEXTAREA_MAX_HEIGHT_PX = 160;\n\n/** Auto-hide duration for success/warning banners */\nconst BANNER_AUTO_HIDE_MS = 2400;\n\n// SVG Icons\nconst ICON_CLOSE = `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>`;\nconst ICON_SEND = `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z\"/></svg>`;\nconst ICON_STOP = `<svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" rx=\"1\"/></svg>`;\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction truncateText(text: string, maxChars: number): string {\n  const trimmed = text.trim();\n  if (trimmed.length <= maxChars) return trimmed;\n  return `${trimmed.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\\u2026`;\n}\n\nfunction safeFocus(element: HTMLElement): void {\n  try {\n    element.focus();\n  } catch {\n    // Best-effort focus\n  }\n}\n\nfunction isTerminalStatus(status: AgentStatusEvent['status']): boolean {\n  return status === 'completed' || status === 'error' || status === 'cancelled';\n}\n\n/**\n * Collect default context from the current page\n */\nfunction collectDefaultContext(): QuickPanelAIContext {\n  const context: QuickPanelAIContext = {\n    pageUrl: globalThis.location?.href,\n  };\n\n  try {\n    const selection = globalThis.getSelection?.();\n    const selectedText = selection?.toString()?.trim() ?? '';\n    if (selectedText) {\n      context.selectedText = truncateText(selectedText, MAX_SELECTED_TEXT_CHARS);\n    }\n  } catch {\n    // Ignore selection access errors\n  }\n\n  return context;\n}\n\n/**\n * Build a local user message for optimistic rendering\n */\nfunction buildLocalUserMessage(\n  sessionId: string,\n  requestId: string,\n  instruction: string,\n): AgentMessage {\n  return {\n    id: `local-user:${requestId}`,\n    sessionId,\n    role: 'user',\n    content: instruction,\n    messageType: 'chat',\n    requestId,\n    isStreaming: false,\n    isFinal: true,\n    createdAt: new Date().toISOString(),\n  };\n}\n\n/**\n * Format usage stats for display\n */\nfunction formatUsageStats(usage: AgentUsageStats | null): string | null {\n  if (!usage) return null;\n\n  const parts: string[] = [];\n\n  const inputTokens = Number.isFinite(usage.inputTokens) ? usage.inputTokens : 0;\n  const outputTokens = Number.isFinite(usage.outputTokens) ? usage.outputTokens : 0;\n  parts.push(`in ${inputTokens}`, `out ${outputTokens}`);\n\n  if (Number.isFinite(usage.durationMs) && usage.durationMs > 0) {\n    const seconds = Math.max(1, Math.round(usage.durationMs / 1000));\n    parts.push(`${seconds}s`);\n  }\n\n  if (Number.isFinite(usage.totalCostUsd) && usage.totalCostUsd > 0) {\n    parts.push(`$${usage.totalCostUsd.toFixed(4)}`);\n  }\n\n  return parts.join(' \\u2022 ');\n}\n\n// ============================================================\n// DOM Builder Functions\n// ============================================================\n\ninterface PanelDOMElements {\n  overlay: HTMLDivElement;\n  panel: HTMLDivElement;\n  titleSubEl: HTMLDivElement;\n  streamIndicator: HTMLDivElement;\n  streamText: HTMLSpanElement;\n  closeBtn: HTMLButtonElement;\n  contentEl: HTMLDivElement;\n  emptyEl: HTMLDivElement;\n  messagesEl: HTMLDivElement;\n  banner: HTMLDivElement;\n  textarea: HTMLTextAreaElement;\n  /** Unified action button: send/stop */\n  actionBtn: HTMLButtonElement;\n}\n\nfunction buildPanelDOM(options: QuickPanelAiChatPanelOptions): PanelDOMElements {\n  const title = options.title?.trim() || DEFAULT_TITLE;\n  const subtitle = options.subtitle?.trim() || DEFAULT_SUBTITLE;\n  const placeholder = options.placeholder?.trim() || DEFAULT_PLACEHOLDER;\n\n  // Overlay (click outside to close)\n  const overlay = document.createElement('div');\n  overlay.className = 'qp-overlay';\n  overlay.setAttribute('data-mcp-quick-panel-ai-chat', 'true');\n\n  // Panel container\n  const panel = document.createElement('div');\n  panel.className = 'qp-panel';\n  panel.setAttribute('role', 'dialog');\n  panel.setAttribute('aria-modal', 'true');\n  panel.setAttribute('aria-label', title);\n\n  // ---- Header ----\n  const header = document.createElement('div');\n  header.className = 'qp-header';\n\n  const headerLeft = document.createElement('div');\n  headerLeft.className = 'qp-header-left';\n\n  const brand = document.createElement('div');\n  brand.className = 'qp-brand';\n  brand.textContent = '\\u2726'; // Star symbol\n\n  const titleWrap = document.createElement('div');\n  titleWrap.className = 'qp-title';\n\n  const titleNameEl = document.createElement('div');\n  titleNameEl.className = 'qp-title-name';\n  titleNameEl.textContent = title;\n\n  const titleSubEl = document.createElement('div');\n  titleSubEl.className = 'qp-title-sub';\n  titleSubEl.textContent = subtitle;\n\n  titleWrap.append(titleNameEl, titleSubEl);\n  headerLeft.append(brand, titleWrap);\n\n  const headerRight = document.createElement('div');\n  headerRight.className = 'qp-header-right';\n\n  const streamIndicator = document.createElement('div');\n  streamIndicator.className = 'qp-stream-indicator';\n  streamIndicator.hidden = true;\n\n  const streamDot = document.createElement('span');\n  streamDot.className = 'qp-stream-dot ac-pulse';\n\n  const streamText = document.createElement('span');\n  streamText.textContent = 'Streaming';\n\n  streamIndicator.append(streamDot, streamText);\n\n  const closeBtn = document.createElement('button');\n  closeBtn.type = 'button';\n  closeBtn.className = 'qp-icon-btn ac-focus-ring';\n  closeBtn.innerHTML = ICON_CLOSE;\n  closeBtn.setAttribute('aria-label', 'Close Quick Panel');\n\n  headerRight.append(streamIndicator, closeBtn);\n  header.append(headerLeft, headerRight);\n\n  // ---- Content ----\n  const contentEl = document.createElement('div');\n  contentEl.className = 'qp-content ac-scroll';\n\n  const emptyEl = document.createElement('div');\n  emptyEl.className = 'qp-empty';\n\n  const emptyIcon = document.createElement('div');\n  emptyIcon.className = 'qp-empty-icon';\n  emptyIcon.textContent = '\\u2726';\n\n  const emptyText = document.createElement('div');\n  emptyText.className = 'qp-empty-text';\n  emptyText.textContent = 'Ask about this page. Streaming replies appear here.';\n\n  emptyEl.append(emptyIcon, emptyText);\n\n  const messagesEl = document.createElement('div');\n  messagesEl.className = 'qp-messages';\n\n  contentEl.append(emptyEl, messagesEl);\n\n  // ---- Composer ----\n  const composer = document.createElement('div');\n  composer.className = 'qp-composer';\n\n  const banner = document.createElement('div');\n  banner.className = 'qp-status';\n  banner.hidden = true;\n\n  const textarea = document.createElement('textarea');\n  textarea.className = 'qp-textarea ac-focus-ring';\n  textarea.placeholder = placeholder;\n  textarea.rows = 1;\n\n  const actions = document.createElement('div');\n  actions.className = 'qp-actions';\n\n  const actionsLeft = document.createElement('div');\n  actionsLeft.className = 'qp-actions-left';\n\n  // Keyboard hints\n  const hints = [\n    { key: 'Enter', label: 'Send' },\n    { key: 'Shift+Enter', label: 'New line' },\n    { key: 'Esc', label: 'Close' },\n  ];\n\n  for (const hint of hints) {\n    const keyEl = document.createElement('span');\n    keyEl.className = 'qp-kbd';\n    keyEl.textContent = hint.key;\n\n    const labelEl = document.createElement('span');\n    labelEl.textContent = hint.label;\n\n    actionsLeft.append(keyEl, labelEl);\n  }\n\n  const actionsRight = document.createElement('div');\n  actionsRight.className = 'qp-actions-right';\n\n  // Unified action button: shows send icon normally, stop icon when loading\n  const actionBtn = document.createElement('button');\n  actionBtn.type = 'button';\n  actionBtn.className = 'qp-icon-btn qp-icon-btn--action qp-icon-btn--primary ac-focus-ring';\n  actionBtn.innerHTML = ICON_SEND;\n  actionBtn.setAttribute('aria-label', 'Send message');\n  actionBtn.dataset.action = 'send';\n\n  actionsRight.append(actionBtn);\n  actions.append(actionsLeft, actionsRight);\n  composer.append(banner, textarea, actions);\n\n  // Assemble\n  panel.append(header, contentEl, composer);\n  overlay.append(panel);\n\n  return {\n    overlay,\n    panel,\n    titleSubEl,\n    streamIndicator,\n    streamText,\n    closeBtn,\n    contentEl,\n    emptyEl,\n    messagesEl,\n    banner,\n    textarea,\n    actionBtn,\n  };\n}\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Mount the Quick Panel AI Chat interface.\n *\n * @example\n * ```typescript\n * const chatPanel = mountQuickPanelAiChatPanel({\n *   mount: shadowHostElements.root,\n *   agentBridge,\n *   onRequestClose: () => quickPanel.hide(),\n * });\n *\n * // Later: clean up\n * chatPanel.dispose();\n * ```\n */\nexport function mountQuickPanelAiChatPanel(\n  options: QuickPanelAiChatPanelOptions,\n): QuickPanelAiChatPanelManager {\n  const disposer = new Disposer();\n\n  const mount = options.mount;\n  const agentBridge = options.agentBridge;\n  const defaultSubtitle = options.subtitle?.trim() || DEFAULT_SUBTITLE;\n\n  // Clean up any existing panel in same mount (crash recovery)\n  try {\n    const existing = mount.querySelector?.('[data-mcp-quick-panel-ai-chat=\"true\"]');\n    if (existing instanceof HTMLElement) {\n      existing.remove();\n    }\n  } catch {\n    // Ignore cleanup errors\n  }\n\n  // --------------------------------------------------------\n  // State Management\n  // --------------------------------------------------------\n\n  let disposed = false;\n  let requestUnsubscribe: (() => void) | null = null;\n  let bannerTimer: ReturnType<typeof setTimeout> | null = null;\n\n  let state: QuickPanelAiChatPanelState = {\n    sending: false,\n    streaming: false,\n    cancelling: false,\n    currentRequestId: null,\n    sessionId: null,\n    lastStatus: null,\n    lastUsage: null,\n    errorMessage: null,\n  };\n\n  // --------------------------------------------------------\n  // DOM Setup\n  // --------------------------------------------------------\n\n  const dom = buildPanelDOM(options);\n  mount.append(dom.overlay);\n  disposer.add(() => dom.overlay.remove());\n\n  // Message renderer\n  const renderer: QuickPanelMessageRenderer = createQuickPanelMessageRenderer({\n    container: dom.messagesEl,\n    scrollContainer: dom.contentEl,\n    autoScroll: true,\n    autoScrollThresholdPx: 96,\n  });\n  disposer.add(() => renderer.dispose());\n\n  // --------------------------------------------------------\n  // Banner Management\n  // --------------------------------------------------------\n\n  function clearBannerTimer(): void {\n    if (bannerTimer) {\n      clearTimeout(bannerTimer);\n      bannerTimer = null;\n    }\n  }\n\n  function hideBanner(): void {\n    clearBannerTimer();\n    dom.banner.hidden = true;\n    dom.banner.className = 'qp-status';\n    dom.banner.textContent = '';\n  }\n\n  function showBanner(\n    tone: 'info' | 'success' | 'warning' | 'error',\n    message: string,\n    autoHideMs?: number,\n  ): void {\n    clearBannerTimer();\n    dom.banner.hidden = false;\n    dom.banner.className = 'qp-status';\n\n    if (tone === 'error') dom.banner.classList.add('qp-status--error');\n    if (tone === 'success') dom.banner.classList.add('qp-status--success');\n    if (tone === 'warning') dom.banner.classList.add('qp-status--warning');\n\n    dom.banner.textContent = message;\n\n    if (autoHideMs && autoHideMs > 0) {\n      bannerTimer = setTimeout(hideBanner, autoHideMs);\n    }\n  }\n\n  // --------------------------------------------------------\n  // Textarea Auto-resize\n  // --------------------------------------------------------\n\n  function resizeTextarea(): void {\n    try {\n      dom.textarea.style.height = 'auto';\n      const targetHeight = Math.min(\n        TEXTAREA_MAX_HEIGHT_PX,\n        Math.max(TEXTAREA_MIN_HEIGHT_PX, dom.textarea.scrollHeight),\n      );\n      dom.textarea.style.height = `${targetHeight}px`;\n    } catch {\n      // Ignore resize errors\n    }\n  }\n\n  // --------------------------------------------------------\n  // UI Rendering\n  // --------------------------------------------------------\n\n  function renderEmptyState(): void {\n    const hasMessages = renderer.getMessageCount() > 0;\n    dom.emptyEl.hidden = hasMessages;\n    dom.messagesEl.hidden = !hasMessages;\n  }\n\n  function renderHeaderSubtitle(): void {\n    if (state.errorMessage) {\n      dom.titleSubEl.textContent = 'Error';\n      return;\n    }\n    if (state.streaming) {\n      dom.titleSubEl.textContent = 'Streaming\\u2026';\n      return;\n    }\n    if (state.sending) {\n      dom.titleSubEl.textContent = 'Sending\\u2026';\n      return;\n    }\n\n    const usageText = formatUsageStats(state.lastUsage);\n    dom.titleSubEl.textContent = usageText ? `Last: ${usageText}` : defaultSubtitle;\n  }\n\n  function renderControls(): void {\n    const inputText = dom.textarea.value.trim();\n    const isLoading = state.sending || state.streaming || state.cancelling;\n    const canSend = inputText.length > 0 && !isLoading;\n    const canCancel = state.currentRequestId !== null && !state.cancelling;\n\n    // Update action button state and appearance\n    if (isLoading) {\n      // Show stop icon when loading/streaming\n      dom.actionBtn.innerHTML = ICON_STOP;\n      dom.actionBtn.setAttribute('aria-label', 'Stop request');\n      dom.actionBtn.dataset.action = 'stop';\n      dom.actionBtn.disabled = !canCancel;\n      dom.actionBtn.classList.remove('qp-icon-btn--primary');\n      dom.actionBtn.classList.add('qp-icon-btn--danger');\n    } else {\n      // Show send icon when idle\n      dom.actionBtn.innerHTML = ICON_SEND;\n      dom.actionBtn.setAttribute('aria-label', 'Send message');\n      dom.actionBtn.dataset.action = 'send';\n      dom.actionBtn.disabled = !canSend;\n      dom.actionBtn.classList.remove('qp-icon-btn--danger');\n      dom.actionBtn.classList.add('qp-icon-btn--primary');\n    }\n\n    // Stream indicator\n    dom.streamIndicator.hidden = !isLoading;\n    if (state.cancelling) {\n      dom.streamText.textContent = 'Cancelling';\n    } else if (state.sending) {\n      dom.streamText.textContent = 'Sending';\n    } else {\n      dom.streamText.textContent = 'Streaming';\n    }\n\n    // Allow typing while streaming; disable only during send/cancel\n    dom.textarea.disabled = state.sending || state.cancelling;\n\n    renderHeaderSubtitle();\n    renderEmptyState();\n  }\n\n  function setState(patch: Partial<QuickPanelAiChatPanelState>): void {\n    state = { ...state, ...patch };\n    renderControls();\n  }\n\n  // --------------------------------------------------------\n  // Subscription Management\n  // --------------------------------------------------------\n\n  function cleanupActiveSubscription(): void {\n    if (requestUnsubscribe) {\n      try {\n        requestUnsubscribe();\n      } catch {\n        // Ignore cleanup errors\n      }\n      requestUnsubscribe = null;\n    }\n  }\n\n  // --------------------------------------------------------\n  // Context Resolution\n  // --------------------------------------------------------\n\n  async function resolveContext(): Promise<QuickPanelAIContext | undefined> {\n    // Try custom context provider\n    try {\n      if (options.getContext) {\n        const provided = await options.getContext();\n        if (provided && typeof provided === 'object') {\n          return provided;\n        }\n      }\n    } catch (err) {\n      console.warn(`${LOG_PREFIX} getContext failed:`, err);\n    }\n\n    // Fallback to default context\n    const fallback = collectDefaultContext();\n\n    // Don't send empty context\n    if (!isNonEmptyString(fallback.pageUrl) && !isNonEmptyString(fallback.selectedText)) {\n      return undefined;\n    }\n\n    return fallback;\n  }\n\n  // --------------------------------------------------------\n  // Request Lifecycle\n  // --------------------------------------------------------\n\n  async function sendCurrentInput(): Promise<void> {\n    if (disposed) return;\n    if (state.sending || state.streaming || state.cancelling) return;\n\n    const instruction = dom.textarea.value.trim();\n    if (!instruction) return;\n\n    // Clear previous errors\n    setState({ errorMessage: null, lastUsage: null, lastStatus: null });\n    hideBanner();\n\n    // Save input for restoration on failure\n    const savedInput = dom.textarea.value;\n    dom.textarea.value = '';\n    resizeTextarea();\n\n    setState({ sending: true });\n\n    // Resolve context\n    const context = await resolveContext();\n    if (disposed) return;\n\n    const payload: QuickPanelSendToAIPayload = {\n      instruction,\n      context: context ?? undefined,\n    };\n\n    // Send to agent\n    const result = await agentBridge.sendToAI(payload);\n    if (disposed) return;\n\n    if (!result.success) {\n      // Restore input on failure\n      dom.textarea.value = savedInput;\n      resizeTextarea();\n\n      const errorMsg = truncateText(result.error, MAX_ERROR_DISPLAY_CHARS);\n      setState({ sending: false, errorMessage: errorMsg });\n      showBanner('error', errorMsg);\n      return;\n    }\n\n    // Optimistic user message rendering\n    // Note: Server will also echo user message; we render locally for instant feedback\n    // and skip server-echoed user messages in handleRequestEvent\n    renderer.upsert(buildLocalUserMessage(result.sessionId, result.requestId, instruction));\n    renderer.scrollToBottom();\n\n    setState({\n      sending: false,\n      streaming: true,\n      currentRequestId: result.requestId,\n      sessionId: result.sessionId,\n      lastStatus: 'starting',\n    });\n\n    // Subscribe to events\n    cleanupActiveSubscription();\n    requestUnsubscribe = agentBridge.onRequestEvent(result.requestId, (event) => {\n      if (disposed) return;\n      handleRequestEvent(event);\n    });\n  }\n\n  async function cancelCurrentRequest(): Promise<void> {\n    if (disposed) return;\n    if (!state.currentRequestId) return;\n    if (state.cancelling) return;\n\n    const requestId = state.currentRequestId;\n    const sessionId = state.sessionId || undefined;\n\n    setState({ cancelling: true });\n\n    const result = await agentBridge.cancelRequest(requestId, sessionId);\n    if (disposed) return;\n\n    if (!result.success) {\n      const errorMsg = truncateText(result.error, MAX_ERROR_DISPLAY_CHARS);\n      setState({ cancelling: false, errorMessage: errorMsg });\n      showBanner('error', errorMsg);\n      return;\n    }\n\n    // Cancellation completion will be driven by the 'cancelled' status event\n    setState({ cancelling: false });\n  }\n\n  function handleTerminal(status: AgentStatusEvent['status'], message?: string): void {\n    cleanupActiveSubscription();\n\n    setState({\n      streaming: false,\n      sending: false,\n      cancelling: false,\n      currentRequestId: null,\n      sessionId: null,\n      lastStatus: status,\n    });\n\n    if (status === 'completed') {\n      const usageText = formatUsageStats(state.lastUsage);\n      const bannerMsg = usageText ? `Completed \\u2022 ${usageText}` : 'Completed';\n      showBanner('success', bannerMsg, BANNER_AUTO_HIDE_MS);\n      return;\n    }\n\n    if (status === 'cancelled') {\n      showBanner('warning', 'Cancelled', BANNER_AUTO_HIDE_MS);\n      return;\n    }\n\n    if (status === 'error') {\n      const errorMsg = truncateText(\n        message || state.errorMessage || 'Request failed',\n        MAX_ERROR_DISPLAY_CHARS,\n      );\n      setState({ errorMessage: errorMsg });\n      showBanner('error', errorMsg);\n    }\n  }\n\n  /**\n   * Handle incoming RealtimeEvent with runtime guards for malformed data.\n   */\n  function handleRequestEvent(event: RealtimeEvent): void {\n    if (disposed) return;\n\n    // Runtime guard: validate event structure\n    if (!event || typeof event !== 'object' || !('type' in event)) {\n      console.warn(`${LOG_PREFIX} Invalid event structure:`, event);\n      return;\n    }\n\n    try {\n      switch (event.type) {\n        case 'message': {\n          const msg = event.data;\n\n          // Runtime guard: validate message structure\n          if (!msg || typeof msg !== 'object' || typeof msg.id !== 'string') {\n            console.warn(`${LOG_PREFIX} Invalid message data:`, msg);\n            return;\n          }\n\n          // For user messages from server, replace local optimistic message\n          // This preserves server-side metadata (cliSource, etc.)\n          if (msg.role === 'user') {\n            const localUserId = `local-user:${msg.requestId}`;\n            renderer.remove(localUserId);\n          }\n\n          renderer.upsert(msg);\n\n          if (msg.isStreaming === true && !msg.isFinal) {\n            setState({ streaming: true });\n          }\n          return;\n        }\n\n        case 'status': {\n          const statusData = event.data;\n\n          // Runtime guard: validate status data\n          if (\n            !statusData ||\n            typeof statusData !== 'object' ||\n            typeof statusData.status !== 'string'\n          ) {\n            console.warn(`${LOG_PREFIX} Invalid status data:`, statusData);\n            return;\n          }\n\n          setState({ lastStatus: statusData.status });\n\n          if (\n            statusData.status === 'starting' ||\n            statusData.status === 'ready' ||\n            statusData.status === 'running'\n          ) {\n            setState({ streaming: true });\n            return;\n          }\n\n          if (isTerminalStatus(statusData.status)) {\n            handleTerminal(statusData.status, statusData.message);\n          }\n          return;\n        }\n\n        case 'usage': {\n          setState({ lastUsage: event.data });\n          return;\n        }\n\n        case 'error': {\n          const errorMsg = truncateText(event.error || 'Unknown error', MAX_ERROR_DISPLAY_CHARS);\n          setState({ errorMessage: errorMsg });\n          showBanner('error', errorMsg);\n\n          cleanupActiveSubscription();\n          setState({\n            streaming: false,\n            sending: false,\n            cancelling: false,\n            currentRequestId: null,\n            sessionId: null,\n            lastStatus: 'error',\n          });\n          return;\n        }\n\n        case 'connected':\n        case 'heartbeat': {\n          // These events are typically filtered by background, but handle exhaustively\n          return;\n        }\n      }\n    } catch (err) {\n      // Catch any unexpected errors to prevent UI crash\n      console.warn(`${LOG_PREFIX} Error handling event:`, err, event);\n    }\n  }\n\n  // --------------------------------------------------------\n  // Event Handlers\n  // --------------------------------------------------------\n\n  disposer.listen(dom.overlay, 'click', (ev: MouseEvent) => {\n    if (disposed) return;\n    // Close on backdrop click\n    if (ev.target === dom.overlay) {\n      close();\n    }\n  });\n\n  disposer.listen(dom.closeBtn, 'click', () => close());\n\n  // Unified action button handler: send or stop based on current state\n  disposer.listen(dom.actionBtn, 'click', () => {\n    if (disposed) return;\n    const action = dom.actionBtn.dataset.action;\n    if (action === 'stop') {\n      void cancelCurrentRequest();\n    } else {\n      void sendCurrentInput();\n    }\n  });\n\n  disposer.listen(dom.textarea, 'input', () => {\n    if (disposed) return;\n    resizeTextarea();\n    renderControls();\n  });\n\n  disposer.listen(dom.textarea, 'keydown', (ev: KeyboardEvent) => {\n    if (disposed) return;\n\n    // Esc closes the panel\n    if (ev.key === 'Escape' && !ev.isComposing) {\n      ev.preventDefault();\n      close();\n      return;\n    }\n\n    // Enter sends, Shift+Enter inserts newline\n    if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {\n      ev.preventDefault();\n      void sendCurrentInput();\n    }\n  });\n\n  // --------------------------------------------------------\n  // Public API\n  // --------------------------------------------------------\n\n  function focusInput(): void {\n    if (disposed) return;\n    safeFocus(dom.textarea);\n  }\n\n  function clearMessages(): void {\n    if (disposed) return;\n    renderer.clear();\n    hideBanner();\n    setState({ lastUsage: null, lastStatus: null, errorMessage: null });\n  }\n\n  function close(): void {\n    if (disposed) return;\n\n    // Best-effort cancel on close\n    if (state.currentRequestId) {\n      void cancelCurrentRequest();\n    }\n\n    try {\n      options.onRequestClose?.();\n    } catch (err) {\n      console.warn(`${LOG_PREFIX} onRequestClose failed:`, err);\n    }\n\n    dispose();\n  }\n\n  function dispose(): void {\n    if (disposed) return;\n    disposed = true;\n\n    cleanupActiveSubscription();\n    clearBannerTimer();\n    disposer.dispose();\n  }\n\n  // --------------------------------------------------------\n  // Initialization\n  // --------------------------------------------------------\n\n  resizeTextarea();\n  renderControls();\n\n  if (options.autoFocus !== false) {\n    focusInput();\n  }\n\n  return {\n    getState: () => ({ ...state }),\n    focusInput,\n    clearMessages,\n    close,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/index.ts",
    "content": "/**\n * Quick Panel UI Module Index\n *\n * Exports all UI components for the Quick Panel feature.\n */\n\n// ============================================================\n// Shell (unified container for search + chat views)\n// ============================================================\n\nexport {\n  mountQuickPanelShell,\n  type QuickPanelShellElements,\n  type QuickPanelShellManager,\n  type QuickPanelShellOptions,\n} from './panel-shell';\n\n// ============================================================\n// Shadow DOM host\n// ============================================================\n\nexport {\n  mountQuickPanelShadowHost,\n  type QuickPanelShadowHostElements,\n  type QuickPanelShadowHostManager,\n  type QuickPanelShadowHostOptions,\n} from './shadow-host';\n\n// ============================================================\n// Search UI Components\n// ============================================================\n\nexport {\n  createSearchInput,\n  type SearchInputManager,\n  type SearchInputOptions,\n  type SearchInputState,\n} from './search-input';\n\nexport {\n  createQuickEntries,\n  type QuickEntriesManager,\n  type QuickEntriesOptions,\n} from './quick-entries';\n\n// ============================================================\n// AI Chat Components\n// ============================================================\n\nexport {\n  createQuickPanelMessageRenderer,\n  type QuickPanelMessageRenderer,\n  type QuickPanelMessageRendererOptions,\n} from './message-renderer';\n\nexport { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';\n\nexport {\n  mountQuickPanelAiChatPanel,\n  type QuickPanelAiChatPanelManager,\n  type QuickPanelAiChatPanelOptions,\n  type QuickPanelAiChatPanelState,\n} from './ai-chat-panel';\n\n// ============================================================\n// Styles\n// ============================================================\n\nexport { QUICK_PANEL_STYLES } from './styles';\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/markdown-renderer.ts",
    "content": "/**\n * Quick Panel Markdown Renderer\n *\n * Simple markdown renderer for Quick Panel.\n * Currently uses plain text rendering - markdown support to be added later\n * when proper Vue/content-script integration is resolved.\n */\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface MarkdownRendererInstance {\n  /** Update the markdown content */\n  setContent: (content: string, isStreaming?: boolean) => void;\n  /** Get current content */\n  getContent: () => string;\n  /** Dispose resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Create a markdown renderer instance that mounts to a container element.\n * Currently renders as plain text - markdown support pending.\n *\n * @param container - The DOM element to render content into\n * @returns Markdown renderer instance with setContent and dispose methods\n */\nexport function createMarkdownRenderer(container: HTMLElement): MarkdownRendererInstance {\n  let currentContent = '';\n\n  // Create a wrapper div for content\n  const contentEl = document.createElement('div');\n  contentEl.className = 'qp-markdown-content';\n  container.appendChild(contentEl);\n\n  return {\n    setContent(newContent: string, _streaming = false) {\n      currentContent = newContent;\n      // For now, render as plain text with basic whitespace preservation\n      contentEl.textContent = newContent;\n    },\n\n    getContent() {\n      return currentContent;\n    },\n\n    dispose() {\n      try {\n        contentEl.remove();\n      } catch {\n        // Best-effort cleanup\n      }\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/message-renderer.ts",
    "content": "/**\n * Quick Panel Message Renderer\n *\n * Renders AgentChat-compatible messages for the Quick Panel AI Chat UI.\n * Features:\n * - Markdown rendering for assistant messages via markstream-vue\n * - XSS-safe rendering for user messages (textContent only)\n * - Streaming message support (in-place updates via message id)\n * - Auto-scroll with proximity detection\n * - Memory-efficient DOM recycling\n */\n\nimport type { AgentMessage, AgentRole } from 'chrome-mcp-shared';\nimport { createMarkdownRenderer, type MarkdownRendererInstance } from './markdown-renderer';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface QuickPanelMessageRendererOptions {\n  /** Container element for message nodes (typically `.qp-messages`) */\n  container: HTMLElement;\n  /** Scroll container for auto-scroll heuristics (typically `.qp-content`) */\n  scrollContainer?: HTMLElement | null;\n  /** Auto-scroll on new/updated messages when user is near bottom. Default: true */\n  autoScroll?: boolean;\n  /** Pixel threshold for \"near bottom\" detection. Default: 96 */\n  autoScrollThresholdPx?: number;\n}\n\nexport interface QuickPanelMessageRenderer {\n  /** Insert or update a message by id */\n  upsert: (message: AgentMessage) => void;\n  /** Remove a message by id */\n  remove: (messageId: string) => void;\n  /** Clear all messages */\n  clear: () => void;\n  /** Replace all messages with a new array */\n  setMessages: (messages: AgentMessage[]) => void;\n  /** Get current message count */\n  getMessageCount: () => number;\n  /** Force scroll to bottom */\n  scrollToBottom: () => void;\n  /** Clean up resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Internal Types\n// ============================================================\n\n/** DOM elements for a single message entry */\ninterface MessageEntry {\n  wrapper: HTMLDivElement;\n  bubble: HTMLDivElement;\n  textEl: HTMLDivElement;\n  metaEl: HTMLDivElement;\n  metaLeftEl: HTMLDivElement;\n  streamDotEl: HTMLSpanElement;\n  timeEl: HTMLSpanElement;\n  metaRightEl: HTMLSpanElement;\n  requestIdEl: HTMLElement;\n  /** Markdown renderer for assistant messages */\n  markdownRenderer: MarkdownRendererInstance | null;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_AUTO_SCROLL_THRESHOLD_PX = 96;\n\n/** Maximum length for truncated request ID display */\nconst REQUEST_ID_DISPLAY_LENGTH = 10;\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction joinClasses(...parts: Array<string | false | null | undefined>): string {\n  return parts.filter(Boolean).join(' ');\n}\n\nfunction formatMessageTime(isoString: string): string {\n  const date = new Date(isoString);\n  if (Number.isNaN(date.getTime())) return '';\n\n  try {\n    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n  } catch {\n    return '';\n  }\n}\n\nfunction isStreamingMessage(message: AgentMessage): boolean {\n  return message.isStreaming === true && message.isFinal !== true;\n}\n\nfunction getWrapperClassName(role: AgentRole): string {\n  return role === 'user' ? 'qp-msg qp-msg--user' : 'qp-msg qp-msg--assistant';\n}\n\nfunction getBubbleClassName(role: AgentRole): string {\n  return joinClasses('qp-bubble', role === 'user' && 'qp-bubble--user');\n}\n\nfunction formatRequestIdForDisplay(requestId: string): { short: string; full: string } {\n  const full = requestId.trim();\n  const short =\n    full.length <= REQUEST_ID_DISPLAY_LENGTH ? full : full.slice(0, REQUEST_ID_DISPLAY_LENGTH);\n  return { short, full };\n}\n\n/**\n * Get a label prefix for special message types\n */\nfunction getMessageTypeLabel(message: AgentMessage): string | null {\n  if (message.role === 'tool') return 'Tool';\n  if (message.role === 'system') return 'System';\n  if (message.messageType === 'tool_use') return 'Tool';\n  if (message.messageType === 'tool_result') return 'Result';\n  return null;\n}\n\n// ============================================================\n// DOM Creation Helpers\n// ============================================================\n\nfunction createMetaLeftElement(): {\n  container: HTMLDivElement;\n  streamDot: HTMLSpanElement;\n  time: HTMLSpanElement;\n} {\n  const container = document.createElement('div');\n  Object.assign(container.style, {\n    display: 'inline-flex',\n    alignItems: 'center',\n    gap: '6px',\n    minWidth: '0',\n  });\n\n  const streamDot = document.createElement('span');\n  streamDot.className = 'qp-msg-stream-dot ac-pulse';\n  streamDot.hidden = true;\n\n  const time = document.createElement('span');\n\n  container.append(streamDot, time);\n\n  return { container, streamDot, time };\n}\n\nfunction createMetaRightElement(): { container: HTMLSpanElement; requestId: HTMLElement } {\n  const container = document.createElement('span');\n  container.hidden = true;\n\n  const requestId = document.createElement('code');\n\n  container.append(requestId);\n\n  return { container, requestId };\n}\n\nfunction createMessageEntry(messageId: string, message: AgentMessage): MessageEntry {\n  const wrapper = document.createElement('div');\n  wrapper.className = getWrapperClassName(message.role);\n  wrapper.dataset.messageId = messageId;\n  wrapper.dataset.role = message.role;\n  wrapper.dataset.messageType = message.messageType;\n\n  const bubble = document.createElement('div');\n  bubble.className = getBubbleClassName(message.role);\n\n  const textEl = document.createElement('div');\n  textEl.className = 'qp-msg-text';\n\n  const metaEl = document.createElement('div');\n  metaEl.className = 'qp-msg-meta';\n\n  const metaLeft = createMetaLeftElement();\n  const metaRight = createMetaRightElement();\n\n  metaEl.append(metaLeft.container, metaRight.container);\n  bubble.append(textEl, metaEl);\n  wrapper.append(bubble);\n\n  // Create markdown renderer for assistant messages\n  let markdownRenderer: MarkdownRendererInstance | null = null;\n  if (message.role === 'assistant') {\n    markdownRenderer = createMarkdownRenderer(textEl);\n  }\n\n  return {\n    wrapper,\n    bubble,\n    textEl,\n    metaEl,\n    metaLeftEl: metaLeft.container,\n    streamDotEl: metaLeft.streamDot,\n    timeEl: metaLeft.time,\n    metaRightEl: metaRight.container,\n    requestIdEl: metaRight.requestId,\n    markdownRenderer,\n  };\n}\n\n// ============================================================\n// Entry Update Logic\n// ============================================================\n\nfunction updateMessageEntry(entry: MessageEntry, messageId: string, message: AgentMessage): void {\n  // Update wrapper classes and data attributes\n  const wrapperClass = getWrapperClassName(message.role);\n  if (entry.wrapper.className !== wrapperClass) {\n    entry.wrapper.className = wrapperClass;\n  }\n\n  entry.wrapper.dataset.role = message.role;\n  entry.wrapper.dataset.messageType = message.messageType;\n  entry.wrapper.dataset.messageId = messageId;\n\n  // Update bubble class\n  const bubbleClass = getBubbleClassName(message.role);\n  if (entry.bubble.className !== bubbleClass) {\n    entry.bubble.className = bubbleClass;\n  }\n\n  // Update content based on message role\n  const textContent = message.content ?? '';\n\n  if (message.role === 'assistant' && entry.markdownRenderer) {\n    // Use markdown renderer for assistant messages\n    entry.markdownRenderer.setContent(textContent, isStreamingMessage(message));\n  } else {\n    // Use plain text for user messages (XSS-safe)\n    if (entry.textEl.textContent !== textContent) {\n      entry.textEl.textContent = textContent;\n    }\n  }\n\n  // Update time display\n  const typeLabel = getMessageTypeLabel(message);\n  const timeText = formatMessageTime(message.createdAt) || '\\u2014'; // em dash for empty\n  entry.timeEl.textContent = typeLabel ? `${typeLabel} \\u2022 ${timeText}` : timeText;\n\n  // Update streaming indicator\n  entry.streamDotEl.hidden = !isStreamingMessage(message);\n\n  // Update request ID display\n  const rawRequestId = isNonEmptyString(message.requestId) ? message.requestId.trim() : '';\n  if (rawRequestId) {\n    const formatted = formatRequestIdForDisplay(rawRequestId);\n    entry.requestIdEl.textContent = formatted.short;\n    entry.requestIdEl.title = formatted.full;\n    entry.metaRightEl.hidden = false;\n  } else {\n    entry.requestIdEl.textContent = '';\n    entry.requestIdEl.title = '';\n    entry.metaRightEl.hidden = true;\n  }\n}\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Create a message renderer instance for the Quick Panel AI Chat.\n *\n * @example\n * ```typescript\n * const renderer = createQuickPanelMessageRenderer({\n *   container: messagesEl,\n *   scrollContainer: contentEl,\n * });\n *\n * // Render streaming message\n * renderer.upsert(message);\n *\n * // Clean up\n * renderer.dispose();\n * ```\n */\nexport function createQuickPanelMessageRenderer(\n  options: QuickPanelMessageRendererOptions,\n): QuickPanelMessageRenderer {\n  const container = options.container;\n  const scrollContainer = options.scrollContainer ?? null;\n  const autoScroll = options.autoScroll ?? true;\n  const thresholdPx = options.autoScrollThresholdPx ?? DEFAULT_AUTO_SCROLL_THRESHOLD_PX;\n\n  /** Map of messageId -> DOM entry */\n  const entries = new Map<string, MessageEntry>();\n\n  let disposed = false;\n\n  // --------------------------------------------------------\n  // Scroll Management\n  // --------------------------------------------------------\n\n  function isNearBottom(): boolean {\n    if (!scrollContainer) return true;\n\n    const { scrollHeight, scrollTop, clientHeight } = scrollContainer;\n    const distanceFromBottom = scrollHeight - scrollTop - clientHeight;\n    return distanceFromBottom <= thresholdPx;\n  }\n\n  function scrollToBottom(): void {\n    if (!scrollContainer) return;\n\n    try {\n      scrollContainer.scrollTo({ top: scrollContainer.scrollHeight });\n    } catch {\n      // Fallback for older browsers\n      scrollContainer.scrollTop = scrollContainer.scrollHeight;\n    }\n  }\n\n  // --------------------------------------------------------\n  // Core Operations\n  // --------------------------------------------------------\n\n  function upsert(message: AgentMessage): void {\n    if (disposed) return;\n\n    const messageId = message.id?.trim();\n    if (!messageId) return;\n\n    const shouldAutoScroll = autoScroll && isNearBottom();\n\n    let entry = entries.get(messageId);\n    if (!entry) {\n      entry = createMessageEntry(messageId, message);\n      entries.set(messageId, entry);\n      container.append(entry.wrapper);\n    }\n\n    updateMessageEntry(entry, messageId, message);\n\n    if (shouldAutoScroll) {\n      scrollToBottom();\n    }\n  }\n\n  function remove(messageId: string): void {\n    if (disposed) return;\n\n    const id = messageId?.trim();\n    if (!id) return;\n\n    const entry = entries.get(id);\n    if (!entry) return;\n\n    entries.delete(id);\n\n    // Dispose markdown renderer if exists\n    if (entry.markdownRenderer) {\n      entry.markdownRenderer.dispose();\n    }\n\n    try {\n      entry.wrapper.remove();\n    } catch {\n      // Fallback for edge cases\n      entry.wrapper.parentElement?.removeChild(entry.wrapper);\n    }\n  }\n\n  function clear(): void {\n    if (disposed) return;\n\n    // Dispose all markdown renderers\n    for (const entry of entries.values()) {\n      if (entry.markdownRenderer) {\n        entry.markdownRenderer.dispose();\n      }\n    }\n\n    entries.clear();\n    container.textContent = '';\n  }\n\n  function setMessages(messages: AgentMessage[]): void {\n    if (disposed) return;\n\n    // Dispose all existing markdown renderers\n    for (const entry of entries.values()) {\n      if (entry.markdownRenderer) {\n        entry.markdownRenderer.dispose();\n      }\n    }\n\n    // Clear existing state\n    entries.clear();\n    container.textContent = '';\n\n    // Render all messages\n    for (const msg of messages) {\n      const id = msg.id?.trim();\n      if (!id) continue;\n\n      const entry = createMessageEntry(id, msg);\n      entries.set(id, entry);\n      updateMessageEntry(entry, id, msg);\n      container.append(entry.wrapper);\n    }\n\n    // Scroll to bottom after batch render\n    scrollToBottom();\n  }\n\n  function getMessageCount(): number {\n    return entries.size;\n  }\n\n  function dispose(): void {\n    if (disposed) return;\n    disposed = true;\n\n    // Dispose all markdown renderers\n    for (const entry of entries.values()) {\n      if (entry.markdownRenderer) {\n        entry.markdownRenderer.dispose();\n      }\n    }\n\n    entries.clear();\n    container.textContent = '';\n  }\n\n  // --------------------------------------------------------\n  // Public API\n  // --------------------------------------------------------\n\n  return {\n    upsert,\n    remove,\n    clear,\n    setMessages,\n    getMessageCount,\n    scrollToBottom,\n    dispose,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/panel-shell.ts",
    "content": "/**\n * Quick Panel Shell\n *\n * A unified panel container that hosts multiple views:\n * - `search`: the launcher/search UI (Phase 1+)\n * - `chat`: AI Chat view (existing capability)\n *\n * The shell owns the overlay + glass panel layout and provides isolated\n * mount points per-view for header/content/footer sections.\n */\n\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport type { QuickPanelView } from '../core/types';\n\n// SVG Icons\nconst ICON_CLOSE = `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>`;\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface QuickPanelShellElements {\n  /** Overlay backdrop */\n  overlay: HTMLDivElement;\n  /** Main panel container */\n  panel: HTMLDivElement;\n\n  /** Header section */\n  header: HTMLDivElement;\n  headerLeft: HTMLDivElement;\n  headerRight: HTMLDivElement;\n  closeBtn: HTMLButtonElement;\n\n  /** View-specific header mounts */\n  headerSearchMount: HTMLDivElement;\n  headerChatMount: HTMLDivElement;\n  headerRightSearchMount: HTMLDivElement;\n  headerRightChatMount: HTMLDivElement;\n\n  /** Content section */\n  content: HTMLDivElement;\n  contentSearchMount: HTMLDivElement;\n  contentChatMount: HTMLDivElement;\n\n  /** Footer section */\n  footer: HTMLDivElement;\n  footerSearchMount: HTMLDivElement;\n  footerChatMount: HTMLDivElement;\n}\n\nexport interface QuickPanelShellOptions {\n  /** Shadow DOM mount point (typically `elements.root` from shadow-host.ts) */\n  mount: HTMLElement;\n  /** Default view on mount. Default: `search` */\n  defaultView?: QuickPanelView;\n  /** Accessible label for the dialog. Default: \"Quick Panel\" */\n  ariaLabel?: string;\n  /** Close when clicking the backdrop. Default: true */\n  closeOnBackdropClick?: boolean;\n  /** Called when close is requested (button/backdrop/api) */\n  onRequestClose?: (reason: 'button' | 'backdrop' | 'api') => void;\n  /** Called after view changes */\n  onViewChange?: (view: QuickPanelView) => void;\n}\n\nexport interface QuickPanelShellManager {\n  /** Get shell elements (null if disposed) */\n  getElements: () => QuickPanelShellElements | null;\n  /** Get current view */\n  getView: () => QuickPanelView;\n  /** Switch to a different view */\n  setView: (view: QuickPanelView) => void;\n  /** Request panel close */\n  requestClose: (reason?: 'button' | 'backdrop' | 'api') => void;\n  /** Clean up resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_ARIA_LABEL = 'Quick Panel';\nconst DEFAULT_VIEW: QuickPanelView = 'search';\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Mount the Quick Panel shell.\n *\n * @example\n * ```typescript\n * const shell = mountQuickPanelShell({\n *   mount: shadowHostElements.root,\n *   defaultView: 'search',\n *   onRequestClose: () => quickPanel.hide(),\n * });\n *\n * // Get mount points for search view\n * const elements = shell.getElements();\n * if (elements) {\n *   // Mount search input to elements.headerSearchMount\n *   // Mount results to elements.contentSearchMount\n * }\n *\n * // Switch to chat view\n * shell.setView('chat');\n *\n * // Cleanup\n * shell.dispose();\n * ```\n */\nexport function mountQuickPanelShell(options: QuickPanelShellOptions): QuickPanelShellManager {\n  const disposer = new Disposer();\n  const mount = options.mount;\n  const closeOnBackdropClick = options.closeOnBackdropClick ?? true;\n\n  let disposed = false;\n  let elements: QuickPanelShellElements | null = null;\n  let currentView: QuickPanelView = options.defaultView ?? DEFAULT_VIEW;\n\n  // Best-effort cleanup (crash recovery / duplicate mounts)\n  try {\n    const existing = mount.querySelector?.('[data-mcp-quick-panel-shell=\"true\"]');\n    if (existing instanceof HTMLElement) {\n      existing.remove();\n    }\n  } catch {\n    // Ignore cleanup errors\n  }\n\n  // --------------------------------------------------------\n  // DOM Construction\n  // --------------------------------------------------------\n\n  const overlay = document.createElement('div');\n  overlay.className = 'qp-overlay';\n  overlay.setAttribute('data-mcp-quick-panel-shell', 'true');\n\n  const panel = document.createElement('div');\n  panel.className = 'qp-panel';\n  panel.setAttribute('role', 'dialog');\n  panel.setAttribute('aria-modal', 'true');\n  panel.setAttribute('aria-label', options.ariaLabel?.trim() || DEFAULT_ARIA_LABEL);\n  panel.dataset.qpView = currentView;\n\n  // Header\n  const header = document.createElement('div');\n  header.className = 'qp-header';\n\n  const headerLeft = document.createElement('div');\n  headerLeft.className = 'qp-header-left';\n\n  const headerSearchMount = document.createElement('div');\n  headerSearchMount.className = 'qp-header-mount qp-header-mount--search';\n\n  const headerChatMount = document.createElement('div');\n  headerChatMount.className = 'qp-header-mount qp-header-mount--chat';\n\n  headerLeft.append(headerSearchMount, headerChatMount);\n\n  const headerRight = document.createElement('div');\n  headerRight.className = 'qp-header-right';\n\n  const headerRightSearchMount = document.createElement('div');\n  headerRightSearchMount.className = 'qp-header-right-mount qp-header-right-mount--search';\n\n  const headerRightChatMount = document.createElement('div');\n  headerRightChatMount.className = 'qp-header-right-mount qp-header-right-mount--chat';\n\n  const closeBtn = document.createElement('button');\n  closeBtn.type = 'button';\n  closeBtn.className = 'qp-icon-btn ac-focus-ring';\n  closeBtn.innerHTML = ICON_CLOSE;\n  closeBtn.setAttribute('aria-label', 'Close Quick Panel');\n\n  headerRight.append(headerRightSearchMount, headerRightChatMount, closeBtn);\n  header.append(headerLeft, headerRight);\n\n  // Content\n  const content = document.createElement('div');\n  content.className = 'qp-content ac-scroll';\n\n  const contentSearchMount = document.createElement('div');\n  contentSearchMount.className = 'qp-content-mount qp-content-mount--search';\n\n  const contentChatMount = document.createElement('div');\n  contentChatMount.className = 'qp-content-mount qp-content-mount--chat';\n\n  content.append(contentSearchMount, contentChatMount);\n\n  // Footer (reuse `.qp-composer` for consistent glass divider/padding)\n  const footer = document.createElement('div');\n  footer.className = 'qp-composer';\n\n  const footerSearchMount = document.createElement('div');\n  footerSearchMount.className = 'qp-footer-mount qp-footer-mount--search';\n\n  const footerChatMount = document.createElement('div');\n  footerChatMount.className = 'qp-footer-mount qp-footer-mount--chat';\n\n  footer.append(footerSearchMount, footerChatMount);\n\n  // Assemble\n  panel.append(header, content, footer);\n  overlay.append(panel);\n  mount.append(overlay);\n  disposer.add(() => overlay.remove());\n\n  elements = {\n    overlay,\n    panel,\n    header,\n    headerLeft,\n    headerRight,\n    closeBtn,\n    headerSearchMount,\n    headerChatMount,\n    headerRightSearchMount,\n    headerRightChatMount,\n    content,\n    contentSearchMount,\n    contentChatMount,\n    footer,\n    footerSearchMount,\n    footerChatMount,\n  };\n\n  // --------------------------------------------------------\n  // View Switching\n  // --------------------------------------------------------\n\n  function renderView(view: QuickPanelView): void {\n    if (!elements) return;\n\n    elements.panel.dataset.qpView = view;\n\n    // Search view visibility\n    const isSearch = view === 'search';\n    elements.headerSearchMount.hidden = !isSearch;\n    elements.headerRightSearchMount.hidden = !isSearch;\n    elements.contentSearchMount.hidden = !isSearch;\n    elements.footerSearchMount.hidden = !isSearch;\n\n    // Chat view visibility\n    const isChat = view === 'chat';\n    elements.headerChatMount.hidden = !isChat;\n    elements.headerRightChatMount.hidden = !isChat;\n    elements.contentChatMount.hidden = !isChat;\n    elements.footerChatMount.hidden = !isChat;\n  }\n\n  function setView(view: QuickPanelView): void {\n    if (disposed) return;\n    if (view !== 'search' && view !== 'chat') return;\n    if (view === currentView) return;\n\n    currentView = view;\n    renderView(currentView);\n\n    try {\n      options.onViewChange?.(currentView);\n    } catch {\n      // Best-effort callback\n    }\n  }\n\n  // Apply initial visibility\n  renderView(currentView);\n\n  // --------------------------------------------------------\n  // Close Handling\n  // --------------------------------------------------------\n\n  function requestClose(reason: 'button' | 'backdrop' | 'api' = 'api'): void {\n    if (disposed) return;\n\n    try {\n      options.onRequestClose?.(reason);\n    } catch {\n      // Best-effort: caller owns lifecycle\n    }\n  }\n\n  disposer.listen(closeBtn, 'click', () => requestClose('button'));\n\n  if (closeOnBackdropClick) {\n    disposer.listen(overlay, 'click', (ev: MouseEvent) => {\n      if (disposed) return;\n      if (ev.target === overlay) {\n        requestClose('backdrop');\n      }\n    });\n  }\n\n  // --------------------------------------------------------\n  // Public API\n  // --------------------------------------------------------\n\n  return {\n    getElements: () => elements,\n    getView: () => currentView,\n    setView,\n    requestClose,\n    dispose: () => {\n      if (disposed) return;\n      disposed = true;\n      elements = null;\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/quick-entries.ts",
    "content": "/**\n * Quick Panel Quick Entries\n *\n * Four-grid shortcuts for quickly switching scopes:\n * - Tabs / Bookmarks / History / Commands\n *\n * Following PRD spec for Quick Panel entry UI.\n */\n\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport { QUICK_PANEL_SCOPES, normalizeQuickPanelScope, type QuickPanelScope } from '../core/types';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface QuickEntriesOptions {\n  /** Container to mount quick entries */\n  container: HTMLElement;\n  /**\n   * Scopes to render as quick entries.\n   * Default: tabs/bookmarks/history/commands\n   */\n  scopes?: readonly QuickPanelScope[];\n  /** Called when an entry is selected */\n  onSelect: (scope: QuickPanelScope) => void;\n}\n\nexport interface QuickEntriesManager {\n  /** Root DOM element */\n  root: HTMLDivElement;\n  /** Set the active (highlighted) scope */\n  setActiveScope: (scope: QuickPanelScope | null) => void;\n  /** Enable/disable a specific entry */\n  setDisabled: (scope: QuickPanelScope, disabled: boolean) => void;\n  /** Show/hide the quick entries grid */\n  setVisible: (visible: boolean) => void;\n  /** Clean up resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_SCOPES: QuickPanelScope[] = ['tabs', 'bookmarks', 'history', 'commands'];\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Create Quick Panel quick entries component.\n *\n * @example\n * ```typescript\n * const quickEntries = createQuickEntries({\n *   container: contentSearchMount,\n *   onSelect: (scope) => {\n *     searchInput.setScope(scope);\n *     controller.search(scope, '');\n *   },\n * });\n *\n * // Highlight active scope\n * quickEntries.setActiveScope('tabs');\n *\n * // Cleanup\n * quickEntries.dispose();\n * ```\n */\nexport function createQuickEntries(options: QuickEntriesOptions): QuickEntriesManager {\n  const disposer = new Disposer();\n  let disposed = false;\n\n  const scopes = (options.scopes?.length ? [...options.scopes] : DEFAULT_SCOPES).map((s) =>\n    normalizeQuickPanelScope(s),\n  );\n\n  // --------------------------------------------------------\n  // DOM Construction\n  // --------------------------------------------------------\n\n  const root = document.createElement('div');\n  root.className = 'qp-entries';\n  options.container.append(root);\n  disposer.add(() => root.remove());\n\n  const buttonsByScope = new Map<QuickPanelScope, HTMLButtonElement>();\n\n  function createEntry(scope: QuickPanelScope): HTMLButtonElement {\n    const def = QUICK_PANEL_SCOPES[scope];\n\n    const btn = document.createElement('button');\n    btn.type = 'button';\n    btn.className = 'qp-entry ac-btn ac-focus-ring';\n    btn.dataset.scope = scope;\n    btn.dataset.active = 'false';\n    btn.setAttribute('aria-label', `Switch scope to ${def.label}`);\n\n    const icon = document.createElement('div');\n    icon.className = 'qp-entry__icon';\n    icon.textContent = def.icon;\n\n    const label = document.createElement('div');\n    label.className = 'qp-entry__label';\n    label.textContent = def.label;\n\n    const prefix = document.createElement('div');\n    prefix.className = 'qp-entry__prefix';\n    prefix.textContent = def.prefix ? def.prefix.trim() : '';\n    prefix.hidden = !def.prefix;\n\n    btn.append(icon, label, prefix);\n\n    disposer.listen(btn, 'click', () => {\n      if (disposed) return;\n      options.onSelect(scope);\n    });\n\n    return btn;\n  }\n\n  // Build entries\n  for (const scope of scopes) {\n    // Only render known scopes and avoid 'all' in quick entries\n    if (!(scope in QUICK_PANEL_SCOPES) || scope === 'all') continue;\n\n    const btn = createEntry(scope);\n    buttonsByScope.set(scope, btn);\n    root.append(btn);\n  }\n\n  // --------------------------------------------------------\n  // State Management\n  // --------------------------------------------------------\n\n  function setActiveScope(scope: QuickPanelScope | null): void {\n    if (disposed) return;\n\n    const active = scope ? normalizeQuickPanelScope(scope) : null;\n    for (const [id, btn] of buttonsByScope) {\n      btn.dataset.active = active === id ? 'true' : 'false';\n    }\n  }\n\n  function setDisabled(scope: QuickPanelScope, disabled: boolean): void {\n    if (disposed) return;\n\n    const id = normalizeQuickPanelScope(scope);\n    const btn = buttonsByScope.get(id);\n    if (!btn) return;\n\n    btn.disabled = disabled;\n  }\n\n  function setVisible(visible: boolean): void {\n    if (disposed) return;\n    root.hidden = !visible;\n  }\n\n  // --------------------------------------------------------\n  // Public API\n  // --------------------------------------------------------\n\n  return {\n    root,\n    setActiveScope,\n    setDisabled,\n    setVisible,\n    dispose: () => {\n      if (disposed) return;\n      disposed = true;\n      buttonsByScope.clear();\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/search-input.ts",
    "content": "/**\n * Quick Panel Search Input\n *\n * A scope-aware search input component with:\n * - PRD-defined scope prefixes (t/b/h/c/>)\n * - Scope chip for visual indication and cycling\n * - XSS-safe rendering (textContent/value only)\n * - IME composition handling\n * - Disposer-based cleanup\n */\n\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport {\n  DEFAULT_SCOPE,\n  QUICK_PANEL_SCOPES,\n  normalizeQuickPanelScope,\n  parseScopePrefixedQuery,\n  type QuickPanelScope,\n} from '../core/types';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface SearchInputState {\n  scope: QuickPanelScope;\n  query: string;\n}\n\nexport interface SearchInputOptions {\n  /** Container to mount the search input */\n  container: HTMLElement;\n  /** Initial scope. Default: 'all' */\n  initialScope?: QuickPanelScope;\n  /** Initial query string */\n  initialQuery?: string;\n  /** Input placeholder. Default: 'Search...' */\n  placeholder?: string;\n  /** Auto-focus on mount. Default: true */\n  autoFocus?: boolean;\n  /**\n   * Available scopes for cycling.\n   * Default: all known scopes\n   */\n  availableScopes?: readonly QuickPanelScope[];\n\n  /** Called when state changes (scope or query) */\n  onChange?: (state: SearchInputState) => void;\n  /** Called when scope changes */\n  onScopeChange?: (scope: QuickPanelScope) => void;\n  /** Called when query changes */\n  onQueryChange?: (query: string) => void;\n  /** Called when clear button is clicked */\n  onClear?: () => void;\n}\n\nexport interface SearchInputManager {\n  /** Root DOM element */\n  root: HTMLDivElement;\n  /** Input element */\n  input: HTMLInputElement;\n  /** Get current state */\n  getState: () => SearchInputState;\n  /** Set scope programmatically */\n  setScope: (scope: QuickPanelScope, options?: { emit?: boolean }) => void;\n  /** Set query programmatically */\n  setQuery: (query: string, options?: { emit?: boolean }) => void;\n  /** Clear the input */\n  clear: (options?: { emit?: boolean }) => void;\n  /** Focus the input */\n  focus: () => void;\n  /** Clean up resources */\n  dispose: () => void;\n}\n\n// ============================================================\n// Helpers\n// ============================================================\n\nfunction isNonEmptyString(value: unknown): value is string {\n  return typeof value === 'string' && value.trim().length > 0;\n}\n\nfunction safeFocus(el: HTMLElement): void {\n  try {\n    el.focus();\n  } catch {\n    // Best-effort\n  }\n}\n\nfunction buildScopeCycleList(input: readonly QuickPanelScope[] | undefined): QuickPanelScope[] {\n  const defaultList: QuickPanelScope[] = [\n    'all',\n    'tabs',\n    'bookmarks',\n    'history',\n    'content',\n    'commands',\n  ];\n  const list = (input?.length ? [...input] : defaultList).map((s) => normalizeQuickPanelScope(s));\n\n  // Ensure 'all' exists as a stable fallback\n  if (!list.includes('all')) {\n    list.unshift('all');\n  }\n\n  // De-duplicate while preserving order\n  const seen = new Set<QuickPanelScope>();\n  return list.filter((s) => {\n    if (seen.has(s)) return false;\n    seen.add(s);\n    return true;\n  });\n}\n\n// ============================================================\n// Main Factory\n// ============================================================\n\n/**\n * Create a Quick Panel search input component.\n *\n * @example\n * ```typescript\n * const searchInput = createSearchInput({\n *   container: headerSearchMount,\n *   initialScope: 'all',\n *   onChange: ({ scope, query }) => {\n *     controller.search(scope, query);\n *   },\n * });\n *\n * // Programmatically set scope\n * searchInput.setScope('tabs');\n *\n * // Cleanup\n * searchInput.dispose();\n * ```\n */\nexport function createSearchInput(options: SearchInputOptions): SearchInputManager {\n  const disposer = new Disposer();\n  const scopes = buildScopeCycleList(options.availableScopes);\n\n  let disposed = false;\n  let isComposing = false;\n\n  let state: SearchInputState = {\n    scope: normalizeQuickPanelScope(options.initialScope, DEFAULT_SCOPE),\n    query: (options.initialQuery ?? '').trim(),\n  };\n\n  // --------------------------------------------------------\n  // DOM Construction\n  // --------------------------------------------------------\n\n  const root = document.createElement('div');\n  root.className = 'qp-search';\n\n  const brand = document.createElement('div');\n  brand.className = 'qp-brand';\n  brand.textContent = '\\u2726'; // Star symbol\n\n  const scopeBtn = document.createElement('button');\n  scopeBtn.type = 'button';\n  scopeBtn.className = 'qp-scope-chip ac-btn ac-focus-ring';\n  scopeBtn.setAttribute('aria-label', 'Switch search scope');\n\n  const input = document.createElement('input');\n  input.type = 'text';\n  input.className = 'qp-search-input ac-focus-ring';\n  input.placeholder = options.placeholder?.trim() || 'Search\\u2026';\n  input.setAttribute('autocomplete', 'off');\n  input.setAttribute('spellcheck', 'false');\n  input.setAttribute('aria-label', 'Quick Panel search');\n\n  const clearBtn = document.createElement('button');\n  clearBtn.type = 'button';\n  clearBtn.className = 'qp-icon-btn ac-btn ac-focus-ring';\n  clearBtn.textContent = '\\u00D7'; // ×\n  clearBtn.setAttribute('aria-label', 'Clear search');\n\n  root.append(brand, scopeBtn, input, clearBtn);\n  options.container.append(root);\n  disposer.add(() => root.remove());\n\n  // --------------------------------------------------------\n  // Rendering\n  // --------------------------------------------------------\n\n  function renderScopeChip(): void {\n    const def = QUICK_PANEL_SCOPES[state.scope];\n    const prefixHint = def.prefix ? def.prefix.trim() : '';\n\n    scopeBtn.textContent = '';\n\n    const iconEl = document.createElement('span');\n    iconEl.className = 'qp-scope-chip__icon';\n    iconEl.textContent = def.icon;\n\n    const labelEl = document.createElement('span');\n    labelEl.className = 'qp-scope-chip__label';\n    labelEl.textContent = def.label;\n\n    scopeBtn.append(iconEl, labelEl);\n\n    if (prefixHint) {\n      const prefixEl = document.createElement('span');\n      prefixEl.className = 'qp-scope-chip__prefix';\n      prefixEl.textContent = prefixHint;\n      scopeBtn.append(prefixEl);\n    }\n  }\n\n  function renderClearButton(): void {\n    clearBtn.hidden = !isNonEmptyString(input.value);\n  }\n\n  function render(): void {\n    renderScopeChip();\n    renderClearButton();\n  }\n\n  // --------------------------------------------------------\n  // State Change Emission\n  // --------------------------------------------------------\n\n  function emit(): void {\n    try {\n      options.onChange?.({ ...state });\n    } catch {\n      // Best-effort\n    }\n    try {\n      options.onScopeChange?.(state.scope);\n    } catch {\n      // Best-effort\n    }\n    try {\n      options.onQueryChange?.(state.query);\n    } catch {\n      // Best-effort\n    }\n  }\n\n  // --------------------------------------------------------\n  // State Mutators\n  // --------------------------------------------------------\n\n  function setScope(next: QuickPanelScope, opts: { emit?: boolean } = {}): void {\n    if (disposed) return;\n\n    const normalized = normalizeQuickPanelScope(next, DEFAULT_SCOPE);\n    if (state.scope === normalized) return;\n\n    // Only allow scopes in the cycle list\n    if (!scopes.includes(normalized)) return;\n\n    state = { ...state, scope: normalized };\n    render();\n\n    if (opts.emit !== false) emit();\n  }\n\n  function setQuery(nextQuery: string, opts: { emit?: boolean } = {}): void {\n    if (disposed) return;\n\n    const q = (nextQuery ?? '').trim();\n    if (state.query === q && input.value === q) {\n      render();\n      return;\n    }\n\n    state = { ...state, query: q };\n    input.value = q;\n    render();\n\n    if (opts.emit !== false) emit();\n  }\n\n  function clear(opts: { emit?: boolean } = {}): void {\n    if (disposed) return;\n\n    setQuery('', { emit: false });\n\n    try {\n      options.onClear?.();\n    } catch {\n      // Best-effort\n    }\n\n    if (opts.emit !== false) emit();\n  }\n\n  // --------------------------------------------------------\n  // Prefix Parsing\n  // --------------------------------------------------------\n\n  function applyPrefixParsing(): void {\n    if (disposed) return;\n    if (isComposing) return;\n\n    const parsed = parseScopePrefixedQuery(input.value, state.scope);\n\n    if (parsed.consumedPrefix) {\n      // Apply scope change if available\n      if (scopes.includes(parsed.scope) && parsed.scope !== state.scope) {\n        setScope(parsed.scope, { emit: false });\n      }\n\n      // Consume the prefix from the visible input\n      if (input.value !== parsed.query) {\n        input.value = parsed.query;\n        // Move caret to end after rewrite for predictable UX\n        try {\n          input.setSelectionRange(input.value.length, input.value.length);\n        } catch {\n          // Ignore\n        }\n      }\n    }\n\n    // Always update query state from current input value\n    setQuery(input.value, { emit: false });\n    emit();\n  }\n\n  // --------------------------------------------------------\n  // Event Handlers\n  // --------------------------------------------------------\n\n  disposer.listen(input, 'compositionstart', () => {\n    isComposing = true;\n  });\n\n  disposer.listen(input, 'compositionend', () => {\n    isComposing = false;\n    applyPrefixParsing();\n  });\n\n  disposer.listen(input, 'input', () => {\n    applyPrefixParsing();\n  });\n\n  disposer.listen(clearBtn, 'click', () => {\n    clear();\n    safeFocus(input);\n  });\n\n  disposer.listen(scopeBtn, 'click', () => {\n    const idx = scopes.indexOf(state.scope);\n    const next = scopes[(idx >= 0 ? idx + 1 : 0) % scopes.length] ?? 'all';\n    setScope(next);\n    safeFocus(input);\n  });\n\n  // --------------------------------------------------------\n  // Initialization\n  // --------------------------------------------------------\n\n  input.value = state.query;\n  render();\n\n  if (options.autoFocus !== false) {\n    safeFocus(input);\n  }\n\n  // --------------------------------------------------------\n  // Public API\n  // --------------------------------------------------------\n\n  return {\n    root,\n    input,\n    getState: () => ({ ...state }),\n    setScope,\n    setQuery,\n    clear,\n    focus: () => safeFocus(input),\n    dispose: () => {\n      if (disposed) return;\n      disposed = true;\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/shadow-host.ts",
    "content": "/**\n * Quick Panel Shadow Host\n *\n * Creates an isolated Shadow DOM container for the Quick Panel AI Chat UI.\n * This module runs in a content script context and provides:\n *\n * - Style isolation via Shadow DOM (no CSS bleed in/out)\n * - Event isolation (UI events don't bubble to the host page)\n * - Theme synchronization with AgentChat (via chrome.storage)\n *\n * Architecture:\n * - Host element attached to documentElement with highest z-index\n * - Shadow root contains styles + UI container\n * - Theme is synced from chrome.storage.local['agentTheme']\n */\n\nimport { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';\nimport { QUICK_PANEL_STYLES } from './styles';\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Elements exposed by the shadow host for UI mounting.\n */\nexport interface QuickPanelShadowHostElements {\n  /** The host element attached to the document */\n  host: HTMLElement;\n  /** The shadow root */\n  shadowRoot: ShadowRoot;\n  /** Container for UI elements (pointer-events: none by default) */\n  uiRoot: HTMLElement;\n  /** Theme root element (class=\"agent-theme qp-root\") */\n  root: HTMLElement;\n}\n\n/**\n * Manager interface for the shadow host.\n */\nexport interface QuickPanelShadowHostManager {\n  /** Get the current elements (null if disposed) */\n  getElements: () => QuickPanelShadowHostElements | null;\n  /** Check if a node belongs to this shadow host */\n  isOverlayElement: (node: unknown) => boolean;\n  /** Check if an event originated from within the shadow host */\n  isEventFromUi: (event: Event) => boolean;\n  /** Clean up and remove the shadow host */\n  dispose: () => void;\n}\n\n/**\n * Options for mounting the shadow host.\n */\nexport interface QuickPanelShadowHostOptions {\n  /** Custom host element ID (default: __mcp_quick_panel_host__) */\n  hostId?: string;\n  /** Custom z-index (default: 2147483647 - highest possible) */\n  zIndex?: number;\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst DEFAULT_HOST_ID = '__mcp_quick_panel_host__';\nconst UI_CONTAINER_ID = '__mcp_quick_panel_ui__';\nconst ROOT_ID = '__mcp_quick_panel_root__';\n\n/** Highest possible z-index to ensure Quick Panel is on top */\nconst DEFAULT_Z_INDEX = 2147483647;\n\n/** Storage key for AgentChat theme (owned by sidepanel) */\nconst THEME_STORAGE_KEY = 'agentTheme';\n\n/** Default theme if none is set */\nconst DEFAULT_THEME_ID = 'warm-editorial';\n\n/** Dark theme ID for dark mode */\nconst DARK_THEME_ID = 'dark-console';\n\n/** Valid theme IDs (subset supported by Quick Panel) */\nconst VALID_THEME_IDS = new Set([\n  'warm-editorial',\n  'blueprint-architect',\n  'zen-journal',\n  'neo-pop',\n  'dark-console',\n  'swiss-grid',\n]);\n\n/** Light theme IDs that should switch to dark in dark mode */\nconst LIGHT_THEME_IDS = new Set([\n  'warm-editorial',\n  'blueprint-architect',\n  'zen-journal',\n  'neo-pop',\n  'swiss-grid',\n]);\n\n/** Events to stop from propagating to the host page */\nconst BLOCKED_EVENT_TYPES = [\n  // Pointer events\n  'pointerdown',\n  'pointerup',\n  'pointermove',\n  'pointerenter',\n  'pointerleave',\n  'pointercancel',\n  // Mouse events\n  'mousedown',\n  'mouseup',\n  'mousemove',\n  'mouseenter',\n  'mouseleave',\n  'click',\n  'dblclick',\n  'contextmenu',\n  // Keyboard events\n  'keydown',\n  'keyup',\n  'keypress',\n  // Touch events\n  'touchstart',\n  'touchmove',\n  'touchend',\n  'touchcancel',\n  // Scroll events\n  'wheel',\n  // Form events\n  'focus',\n  'blur',\n  'input',\n  'change',\n] as const;\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\n/**\n * Set a CSS property with !important to override page styles.\n */\nfunction setImportantStyle(element: HTMLElement, property: string, value: string): void {\n  element.style.setProperty(property, value, 'important');\n}\n\n/**\n * Normalize and validate a theme ID.\n */\nfunction normalizeThemeId(value: unknown): string {\n  if (typeof value !== 'string') return DEFAULT_THEME_ID;\n  const trimmed = value.trim();\n  return VALID_THEME_IDS.has(trimmed) ? trimmed : DEFAULT_THEME_ID;\n}\n\n/**\n * Check if system prefers dark mode.\n */\nfunction systemPrefersDark(): boolean {\n  try {\n    return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Get effective theme ID considering system dark mode preference.\n * If system is in dark mode and the theme is a light theme, switch to dark-console.\n */\nfunction getEffectiveThemeId(baseThemeId: string): string {\n  if (systemPrefersDark() && LIGHT_THEME_IDS.has(baseThemeId)) {\n    return DARK_THEME_ID;\n  }\n  return baseThemeId;\n}\n\n/**\n * Read the stored theme ID from chrome.storage.\n */\nasync function readStoredThemeId(): Promise<string> {\n  try {\n    if (!chrome?.storage?.local) return DEFAULT_THEME_ID;\n    const result = await chrome.storage.local.get(THEME_STORAGE_KEY);\n    return normalizeThemeId(result[THEME_STORAGE_KEY]);\n  } catch {\n    return DEFAULT_THEME_ID;\n  }\n}\n\n/**\n * Apply a theme ID to the root element, considering system dark mode preference.\n */\nfunction applyThemeId(root: HTMLElement, themeId: string): void {\n  const normalizedTheme = normalizeThemeId(themeId);\n  const effectiveTheme = getEffectiveThemeId(normalizedTheme);\n  root.dataset.agentTheme = effectiveTheme;\n}\n\n// ============================================================\n// Main Export\n// ============================================================\n\n/**\n * Mount the Quick Panel Shadow DOM host.\n *\n * @param options - Configuration options\n * @returns Manager interface for the shadow host\n *\n * @example\n * ```typescript\n * const shadowHost = mountQuickPanelShadowHost();\n * const elements = shadowHost.getElements();\n *\n * if (elements) {\n *   // Mount UI into elements.root\n *   mountQuickPanelAiChatPanel({\n *     mount: elements.root,\n *     agentBridge,\n *   });\n * }\n *\n * // Cleanup when done\n * shadowHost.dispose();\n * ```\n */\nexport function mountQuickPanelShadowHost(\n  options: QuickPanelShadowHostOptions = {},\n): QuickPanelShadowHostManager {\n  const disposer = new Disposer();\n  let elements: QuickPanelShadowHostElements | null = null;\n\n  const hostId = options.hostId ?? DEFAULT_HOST_ID;\n  const zIndex = options.zIndex ?? DEFAULT_Z_INDEX;\n\n  // Clean up any existing host (from previous instance or crash recovery)\n  const existing = document.getElementById(hostId);\n  if (existing) {\n    try {\n      existing.remove();\n    } catch {\n      // Best-effort cleanup\n    }\n  }\n\n  // Create host element\n  const host = document.createElement('div');\n  host.id = hostId;\n  host.setAttribute('data-mcp-quick-panel', 'true');\n\n  // Apply styles with !important to override page styles\n  setImportantStyle(host, 'position', 'fixed');\n  setImportantStyle(host, 'inset', '0');\n  setImportantStyle(host, 'z-index', String(zIndex));\n  setImportantStyle(host, 'pointer-events', 'none');\n  setImportantStyle(host, 'contain', 'layout style paint');\n  setImportantStyle(host, 'isolation', 'isolate');\n\n  // Create shadow root\n  const shadowRoot = host.attachShadow({ mode: 'open' });\n\n  // Inject styles\n  const styleEl = document.createElement('style');\n  styleEl.textContent = QUICK_PANEL_STYLES;\n  shadowRoot.append(styleEl);\n\n  // Create UI container\n  const uiRoot = document.createElement('div');\n  uiRoot.id = UI_CONTAINER_ID;\n  setImportantStyle(uiRoot, 'position', 'fixed');\n  setImportantStyle(uiRoot, 'inset', '0');\n  setImportantStyle(uiRoot, 'pointer-events', 'none');\n  shadowRoot.append(uiRoot);\n\n  // Create theme root (where UI components mount)\n  const root = document.createElement('div');\n  root.id = ROOT_ID;\n  root.className = 'agent-theme qp-root';\n\n  // Apply theme synchronously BEFORE mounting to avoid flash\n  // Use system dark mode preference as initial hint\n  const initialTheme = getEffectiveThemeId(DEFAULT_THEME_ID);\n  root.dataset.agentTheme = initialTheme;\n\n  uiRoot.append(root);\n\n  // Mount to document\n  const mountPoint = document.documentElement ?? document.body;\n  mountPoint.append(host);\n  disposer.add(() => host.remove());\n\n  elements = { host, shadowRoot, uiRoot, root };\n\n  // Event isolation: stop UI events from bubbling to the page\n  const stopPropagation = (event: Event): void => {\n    event.stopPropagation();\n  };\n\n  for (const eventType of BLOCKED_EVENT_TYPES) {\n    disposer.listen(root, eventType, stopPropagation);\n  }\n\n  // Async update with stored theme (if different from initial)\n  void (async () => {\n    const themeId = await readStoredThemeId();\n    applyThemeId(root, themeId);\n  })();\n\n  // System dark mode change listener\n  // Re-apply theme when system color scheme changes\n  let currentStoredThemeId = DEFAULT_THEME_ID;\n\n  // Track the stored theme ID\n  void (async () => {\n    currentStoredThemeId = await readStoredThemeId();\n  })();\n\n  // Theme change listener\n  const handleStorageChange = (\n    changes: Record<string, chrome.storage.StorageChange>,\n    areaName: string,\n  ): void => {\n    if (areaName !== 'local') return;\n    const change = changes[THEME_STORAGE_KEY];\n    if (!change) return;\n    // Update tracked theme ID and apply\n    currentStoredThemeId = normalizeThemeId(change.newValue);\n    applyThemeId(root, currentStoredThemeId);\n  };\n\n  try {\n    chrome?.storage?.onChanged?.addListener(handleStorageChange);\n    disposer.add(() => chrome?.storage?.onChanged?.removeListener(handleStorageChange));\n  } catch {\n    // Best-effort: theme sync is optional\n  }\n\n  try {\n    const darkModeMediaQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');\n    if (darkModeMediaQuery) {\n      const handleDarkModeChange = (): void => {\n        applyThemeId(root, currentStoredThemeId);\n      };\n\n      // Use addEventListener for modern browsers\n      if (typeof darkModeMediaQuery.addEventListener === 'function') {\n        darkModeMediaQuery.addEventListener('change', handleDarkModeChange);\n        disposer.add(() => darkModeMediaQuery.removeEventListener('change', handleDarkModeChange));\n      }\n    }\n  } catch {\n    // Best-effort: dark mode detection is optional\n  }\n\n  // Helper to check if a node belongs to this shadow host\n  const isOverlayElement = (node: unknown): boolean => {\n    if (!(node instanceof Node)) return false;\n    if (node === host) return true;\n\n    const rootNode = typeof node.getRootNode === 'function' ? node.getRootNode() : null;\n    return rootNode instanceof ShadowRoot && rootNode.host === host;\n  };\n\n  // Helper to check if an event originated from within the shadow host\n  const isEventFromUi = (event: Event): boolean => {\n    try {\n      if (typeof event.composedPath === 'function') {\n        return event.composedPath().some((el) => isOverlayElement(el));\n      }\n    } catch {\n      // Fallback to checking target\n    }\n    return isOverlayElement(event.target);\n  };\n\n  return {\n    getElements: () => elements,\n    isOverlayElement,\n    isEventFromUi,\n    dispose: () => {\n      elements = null;\n      disposer.dispose();\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/quick-panel/ui/styles.ts",
    "content": "/**\n * Quick Panel AI Chat Styles\n *\n * This stylesheet is injected into the Quick Panel's Shadow DOM (content script).\n * It intentionally reuses AgentChat token names (--ac-*) to maintain visual consistency\n * with the sidepanel AgentChat component.\n *\n * Design System:\n * - Source of truth: app/chrome-extension/entrypoints/sidepanel/styles/agent-chat.css\n * - This file extracts a minimal token + utility subset for content script use\n * - Liquid Glass styling follows quick-panel-prd.md V6 spec\n *\n * Note: Content Script Shadow DOM cannot directly import sidepanel CSS (not web_accessible).\n * We maintain a synced subset here to balance visual consistency with bundle size.\n */\n\nexport const QUICK_PANEL_STYLES = /* css */ `\n  /* ============================================================\n   * Reset & Box Sizing\n   * ============================================================ */\n\n  :host {\n    all: initial;\n  }\n\n  *,\n  *::before,\n  *::after {\n    box-sizing: border-box;\n  }\n\n  [hidden] {\n    display: none !important;\n  }\n\n  /* ============================================================\n   * Root Container & Theme Tokens\n   * Subset of AgentChat tokens for Quick Panel use\n   * ============================================================ */\n\n  .qp-root {\n    position: fixed;\n    inset: 0;\n    pointer-events: none;\n    font-family: var(--ac-font-body, ui-sans-serif, system-ui);\n    color: var(--ac-text, #111827);\n    line-height: 1.4;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n\n  .qp-root.agent-theme {\n    /* ===========================================\n     * Font Stacks\n     * =========================================== */\n    --ac-font-sans:\n      'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,\n      'Apple Color Emoji', 'Segoe UI Emoji';\n    --ac-font-serif: 'Newsreader', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;\n    --ac-font-mono:\n      'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',\n      'Courier New', monospace;\n    --ac-font-grotesk:\n      'Space Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial;\n\n    --ac-font-body: var(--ac-font-sans);\n    --ac-font-heading: var(--ac-font-serif);\n    --ac-font-code: var(--ac-font-mono);\n\n    /* ===========================================\n     * Geometry & Spacing\n     * =========================================== */\n    --ac-border-width: 1px;\n    --ac-border-width-strong: 2px;\n    --ac-radius-container: 0px;\n    --ac-radius-card: 12px;\n    --ac-radius-inner: 8px;\n    --ac-radius-button: 8px;\n\n    /* ===========================================\n     * Motion\n     * =========================================== */\n    --ac-motion-fast: 120ms;\n    --ac-motion-normal: 180ms;\n\n    /* ===========================================\n     * Warm Editorial Theme (Default)\n     * =========================================== */\n    --ac-bg: transparent;\n    --ac-bg-pattern: none;\n    --ac-bg-pattern-size: 16px 16px;\n\n    --ac-header-bg: rgba(253, 252, 248, 0.95);\n    --ac-header-border: rgba(245, 245, 244, 0.5);\n\n    --ac-surface: #ffffff;\n    --ac-surface-muted: #f2f0eb;\n    --ac-surface-inset: #f2f0eb;\n\n    --ac-text: #1a1a1a;\n    --ac-text-muted: #6e6e6e;\n    --ac-text-subtle: #a8a29e;\n    --ac-text-inverse: #ffffff;\n    --ac-text-placeholder: #a8a29e;\n\n    --ac-border: #e7e5e4;\n    --ac-border-strong: #d6d3d1;\n\n    --ac-hover-bg: #f5f5f4;\n    --ac-hover-bg-subtle: #fafaf9;\n\n    --ac-accent: #d97757;\n    --ac-accent-hover: #c4664a;\n    --ac-accent-subtle: rgba(217, 119, 87, 0.12);\n    --ac-accent-contrast: #ffffff;\n\n    --ac-link: var(--ac-accent);\n    --ac-link-hover: var(--ac-accent-hover);\n\n    --ac-selection-bg: #ffedd5;\n    --ac-selection-text: #7c2d12;\n\n    --ac-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);\n    --ac-shadow-float: 0 4px 20px -2px rgba(0, 0, 0, 0.05);\n\n    --ac-focus-ring: rgba(214, 211, 209, 0.9);\n\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(217, 119, 87, 0.25), 0 0 12px rgba(217, 119, 87, 0.2);\n\n    /* Status Colors */\n    --ac-success: #22c55e;\n    --ac-warning: #f59e0b;\n    --ac-danger: #ef4444;\n\n    /* Scrollbar */\n    --ac-scrollbar-size: 4px;\n    --ac-scrollbar-thumb: rgba(0, 0, 0, 0.25);\n    --ac-scrollbar-thumb-hover: rgba(0, 0, 0, 0.4);\n\n    /* ===========================================\n     * Quick Panel Solid Tokens (Editorial Style)\n     * No glassmorphism - solid backgrounds for clarity\n     * =========================================== */\n    --qp-panel-bg: var(--ac-surface);\n    --qp-panel-border: var(--ac-border);\n    --qp-panel-shadow: var(--ac-shadow-card), 0 25px 50px -12px rgba(0, 0, 0, 0.15);\n    --qp-divider: var(--ac-border);\n    --qp-input-bg: var(--ac-surface);\n    --qp-input-border: var(--ac-border);\n  }\n\n  /* ===========================================\n   * Dark Console Theme\n   * =========================================== */\n  .qp-root.agent-theme[data-agent-theme='dark-console'] {\n    --ac-font-body: var(--ac-font-mono);\n    --ac-font-heading: var(--ac-font-mono);\n    --ac-font-code: var(--ac-font-mono);\n\n    --ac-surface: #0f1117;\n    --ac-surface-muted: #0a0c10;\n    --ac-surface-inset: #1a1d26;\n\n    --ac-text: #e5e7eb;\n    --ac-text-muted: #9ca3af;\n    --ac-text-subtle: #6b7280;\n    --ac-text-inverse: #0a0c10;\n    --ac-text-placeholder: #4b5563;\n\n    --ac-border: #1f2937;\n    --ac-border-strong: #374151;\n\n    --ac-hover-bg: rgba(255, 255, 255, 0.06);\n    --ac-hover-bg-subtle: rgba(255, 255, 255, 0.04);\n\n    --ac-accent: #d97757;\n    --ac-accent-hover: #e8956f;\n    --ac-accent-subtle: rgba(217, 119, 87, 0.18);\n    --ac-accent-contrast: #ffffff;\n\n    --ac-focus-ring: rgba(217, 119, 87, 0.4);\n    --ac-timeline-node-pulse-shadow:\n      0 0 0 2px rgba(217, 119, 87, 0.35), 0 0 14px rgba(217, 119, 87, 0.25);\n\n    --ac-scrollbar-thumb: rgba(255, 255, 255, 0.12);\n    --ac-scrollbar-thumb-hover: rgba(255, 255, 255, 0.22);\n\n    --qp-panel-bg: var(--ac-surface);\n    --qp-panel-border: var(--ac-border);\n    --qp-panel-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);\n    --qp-divider: var(--ac-border);\n    --qp-input-bg: var(--ac-surface-inset);\n    --qp-input-border: var(--ac-border);\n  }\n\n  .qp-root ::selection {\n    background: var(--ac-selection-bg);\n    color: var(--ac-selection-text);\n  }\n\n  /* ============================================================\n   * Utility Classes (AgentChat Subset)\n   * ============================================================ */\n\n  /* Scrollbar Styling */\n  .qp-root .ac-scroll {\n    scrollbar-width: thin;\n    scrollbar-color: var(--ac-scrollbar-thumb) transparent;\n  }\n\n  .qp-root .ac-scroll::-webkit-scrollbar {\n    width: var(--ac-scrollbar-size);\n    height: var(--ac-scrollbar-size);\n  }\n\n  .qp-root .ac-scroll::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  .qp-root .ac-scroll::-webkit-scrollbar-thumb {\n    background-color: var(--ac-scrollbar-thumb);\n    border-radius: 999px;\n  }\n\n  .qp-root .ac-scroll::-webkit-scrollbar-thumb:hover {\n    background-color: var(--ac-scrollbar-thumb-hover);\n  }\n\n  /* Focus Ring */\n  .qp-root .ac-focus-ring:focus-visible {\n    outline: none;\n    box-shadow: 0 0 0 2px var(--ac-focus-ring);\n  }\n\n  /* Button Base */\n  .qp-root .ac-btn {\n    transition:\n      background-color var(--ac-motion-fast),\n      color var(--ac-motion-fast);\n  }\n\n  .qp-root .ac-btn:hover {\n    background-color: var(--ac-hover-bg);\n  }\n\n  /* Pulse Animation (Streaming Indicator) */\n  @keyframes ac-pulse {\n    0%, 100% {\n      opacity: 1;\n    }\n    50% {\n      opacity: 0.5;\n    }\n  }\n\n  .qp-root .ac-pulse {\n    animation: ac-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .qp-root .ac-pulse {\n      animation: none;\n    }\n  }\n\n  /* Text Shimmer (Streaming Status) */\n  .qp-root .text-shimmer {\n    background: linear-gradient(\n      90deg,\n      var(--ac-accent, #d97757) 0%,\n      var(--ac-accent-hover, #ffcab0) 50%,\n      var(--ac-accent, #d97757) 100%\n    );\n    background-size: 200% auto;\n    color: transparent;\n    -webkit-background-clip: text;\n    background-clip: text;\n    animation: ac-shimmer 3s linear infinite;\n  }\n\n  @keyframes ac-shimmer {\n    to {\n      background-position: 200% center;\n    }\n  }\n\n  /* ============================================================\n   * Liquid Glass Panel (PRD V6)\n   * ============================================================ */\n\n  .qp-overlay {\n    position: fixed;\n    inset: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 24px;\n    pointer-events: auto;\n  }\n\n  .qp-panel {\n    width: min(760px, calc(100vw - 48px));\n    max-height: min(720px, calc(100vh - 48px));\n    display: flex;\n    flex-direction: column;\n    border-radius: 24px;\n    overflow: hidden;\n    pointer-events: auto;\n\n    background: var(--qp-panel-bg);\n    border: var(--ac-border-width) solid var(--qp-panel-border);\n    box-shadow: var(--qp-panel-shadow);\n  }\n\n  /* ============================================================\n   * AI Chat Layout Components\n   * ============================================================ */\n\n  /* Header */\n  .qp-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n    padding: 14px 16px;\n    border-bottom: var(--ac-border-width) solid var(--qp-divider);\n  }\n\n  .qp-header-left {\n    min-width: 0;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n  }\n\n  .qp-brand {\n    width: 34px;\n    height: 34px;\n    border-radius: var(--ac-radius-inner);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: var(--ac-accent-subtle);\n    color: var(--ac-accent);\n    font-size: 24px;\n  }\n\n  .qp-title {\n    display: flex;\n    flex-direction: column;\n    gap: 2px;\n    min-width: 0;\n  }\n\n  .qp-title-name {\n    font-weight: 700;\n    font-size: 13px;\n    letter-spacing: 0.2px;\n    color: var(--ac-text);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .qp-title-sub {\n    font-size: 11px;\n    color: var(--ac-text-muted);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .qp-header-right {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    flex: none;\n  }\n\n  .qp-stream-indicator {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    font-size: 11px;\n    color: var(--ac-text-muted);\n    user-select: none;\n  }\n\n  .qp-stream-dot {\n    width: 8px;\n    height: 8px;\n    border-radius: 999px;\n    background: var(--ac-accent);\n    box-shadow: var(--ac-timeline-node-pulse-shadow);\n  }\n\n  /* Buttons */\n  .qp-btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: 8px;\n    border: var(--ac-border-width) solid var(--qp-divider);\n    background: var(--ac-hover-bg);\n    color: var(--ac-text);\n    border-radius: var(--ac-radius-button);\n    padding: 8px 10px;\n    font-size: 11px;\n    cursor: pointer;\n    user-select: none;\n    font-family: inherit;\n    transition: background-color var(--ac-motion-fast);\n  }\n\n  .qp-btn:hover:not(:disabled) {\n    background: var(--ac-hover-bg-subtle);\n  }\n\n  .qp-btn:disabled {\n    cursor: not-allowed;\n    opacity: 0.6;\n  }\n\n  .qp-btn--primary {\n    background: var(--ac-accent);\n    border-color: var(--ac-accent);\n    color: var(--ac-accent-contrast);\n  }\n\n  .qp-btn--primary:hover:not(:disabled) {\n    background: var(--ac-accent-hover);\n  }\n\n  .qp-btn--danger {\n    background: var(--ac-danger);\n    border-color: var(--ac-danger);\n    color: #ffffff;\n  }\n\n  /* Content Area */\n  .qp-content {\n    flex: 1;\n    overflow: auto;\n    padding: 14px;\n    min-height: 0;\n  }\n\n  .qp-messages {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  /* Message Bubbles */\n  .qp-msg {\n    display: flex;\n    gap: 10px;\n  }\n\n  .qp-msg--user {\n    justify-content: flex-end;\n  }\n\n  .qp-msg--assistant {\n    justify-content: flex-start;\n  }\n\n  .qp-bubble {\n    max-width: 90%;\n    border-radius: var(--ac-radius-card);\n    border: var(--ac-border-width) solid var(--ac-border);\n    box-shadow: var(--ac-shadow-card);\n    padding: 10px 12px;\n    background: var(--ac-surface);\n  }\n\n  .qp-bubble--user {\n    background: color-mix(in srgb, var(--ac-accent-subtle) 80%, transparent);\n    border-color: color-mix(in srgb, var(--ac-border) 70%, transparent);\n  }\n\n  .qp-msg-text {\n    font-size: 13px;\n    white-space: pre-wrap;\n    word-break: break-word;\n    color: var(--ac-text);\n  }\n\n  .qp-msg-meta {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 10px;\n    margin-top: 6px;\n    font-size: 10px;\n    color: var(--ac-text-subtle);\n  }\n\n  .qp-msg-meta code {\n    font-family: var(--ac-font-code);\n    font-size: 10px;\n  }\n\n  .qp-msg-stream-dot {\n    width: 8px;\n    height: 8px;\n    border-radius: 999px;\n    background: var(--ac-accent);\n    box-shadow: var(--ac-timeline-node-pulse-shadow);\n    flex: none;\n  }\n\n  /* Status Indicators */\n  .qp-status {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 8px;\n    padding: 8px 10px;\n    border-radius: 999px;\n    border: var(--ac-border-width) solid var(--ac-border);\n    background: var(--ac-surface-muted);\n    color: var(--ac-text-muted);\n    font-size: 11px;\n    user-select: none;\n    align-self: center;\n  }\n\n  .qp-status--error {\n    border-color: color-mix(in srgb, var(--ac-danger) 55%, var(--ac-border));\n    color: var(--ac-danger);\n    background: color-mix(in srgb, var(--ac-danger) 12%, transparent);\n  }\n\n  .qp-status--success {\n    border-color: color-mix(in srgb, var(--ac-success) 55%, var(--ac-border));\n    color: color-mix(in srgb, var(--ac-success) 85%, var(--ac-text));\n    background: color-mix(in srgb, var(--ac-success) 10%, transparent);\n  }\n\n  .qp-status--warning {\n    border-color: color-mix(in srgb, var(--ac-warning) 55%, var(--ac-border));\n    color: color-mix(in srgb, var(--ac-warning) 85%, var(--ac-text));\n    background: color-mix(in srgb, var(--ac-warning) 10%, transparent);\n  }\n\n  /* Composer */\n  .qp-composer {\n    padding: 12px 14px;\n    border-top: 1px solid var(--qp-divider);\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n  }\n\n  .qp-textarea {\n    width: 100%;\n    min-height: 42px;\n    max-height: 160px;\n    resize: none;\n    padding: 10px 10px;\n    border-radius: var(--ac-radius-card);\n    border: 1px solid var(--qp-input-border);\n    background: var(--qp-input-bg);\n    color: var(--ac-text);\n    font-family: var(--ac-font-body);\n    font-size: 13px;\n    line-height: 1.35;\n    outline: none;\n  }\n\n  .qp-textarea::placeholder {\n    color: var(--ac-text-placeholder);\n  }\n\n  .qp-actions {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 10px;\n  }\n\n  .qp-actions-left {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    font-size: 11px;\n    color: var(--ac-text-subtle);\n    user-select: none;\n  }\n\n  .qp-actions-right {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n  }\n\n  .qp-kbd {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    border: var(--ac-border-width) solid var(--qp-divider);\n    background: var(--ac-surface-muted);\n    padding: 4px 8px;\n    border-radius: 999px;\n    font-family: var(--ac-font-code);\n    font-size: 10px;\n    color: var(--ac-text-muted);\n  }\n\n  /* Empty State */\n  .qp-empty {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 12px;\n    padding: 40px 20px;\n    text-align: center;\n    color: var(--ac-text-muted);\n  }\n\n  .qp-empty-icon {\n    font-size: 32px;\n    opacity: 0.6;\n  }\n\n  .qp-empty-text {\n    font-size: 13px;\n    line-height: 1.5;\n  }\n\n  /* ============================================================\n   * Search UI (Phase 1)\n   * ============================================================ */\n\n  /* Search Input Container */\n  .qp-search {\n    min-width: 0;\n    width: 100%;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n  }\n\n  /* Scope Chip */\n  .qp-scope-chip {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    border: 1px solid var(--qp-divider);\n    background: rgba(255, 255, 255, 0.12);\n    border-radius: 999px;\n    padding: 6px 10px;\n    color: var(--ac-text);\n    font-family: var(--ac-font-body);\n    font-size: 12px;\n    cursor: pointer;\n    user-select: none;\n    flex: none;\n    transition: background-color var(--ac-motion-fast);\n  }\n\n  .qp-scope-chip:hover {\n    background: rgba(255, 255, 255, 0.18);\n  }\n\n  .qp-scope-chip__icon {\n    font-size: 12px;\n    line-height: 1;\n  }\n\n  .qp-scope-chip__label {\n    font-weight: 600;\n    letter-spacing: 0.2px;\n    white-space: nowrap;\n  }\n\n  .qp-scope-chip__prefix {\n    font-family: var(--ac-font-code);\n    font-size: 10px;\n    padding: 2px 6px;\n    border-radius: 999px;\n    border: 1px solid var(--qp-divider);\n    background: rgba(255, 255, 255, 0.1);\n    color: var(--ac-text-muted);\n  }\n\n  /* Search Input */\n  .qp-search-input {\n    flex: 1;\n    min-width: 0;\n    height: 38px;\n    padding: 0 12px;\n    border-radius: var(--ac-radius-card);\n    border: 1px solid var(--qp-input-border);\n    background: var(--qp-input-bg);\n    color: var(--ac-text);\n    font-family: var(--ac-font-body);\n    font-size: 14px;\n    line-height: 1.2;\n    outline: none;\n    transition: border-color var(--ac-motion-fast);\n  }\n\n  .qp-search-input:focus {\n    border-color: var(--ac-accent);\n  }\n\n  .qp-search-input::placeholder {\n    color: var(--ac-text-placeholder);\n  }\n\n  /* Icon Button (Clear, Close, Action, etc.) */\n  .qp-icon-btn {\n    width: 28px;\n    height: 28px;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    border: var(--ac-border-width) solid var(--qp-divider);\n    background: transparent;\n    color: var(--ac-text-muted);\n    border-radius: var(--ac-radius-button);\n    cursor: pointer;\n    user-select: none;\n    flex: none;\n    transition: background-color var(--ac-motion-fast), color var(--ac-motion-fast), border-color var(--ac-motion-fast);\n  }\n\n  .qp-icon-btn:hover:not(:disabled) {\n    background: var(--ac-hover-bg);\n    color: var(--ac-text);\n  }\n\n  .qp-icon-btn:disabled {\n    cursor: not-allowed;\n    opacity: 0.5;\n  }\n\n  .qp-icon-btn svg {\n    width: 16px;\n    height: 16px;\n  }\n\n  /* Action button variant (send/stop) */\n  .qp-icon-btn--action {\n    width: 32px;\n    height: 32px;\n  }\n\n  .qp-icon-btn--action svg {\n    width: 16px;\n    height: 16px;\n  }\n\n  .qp-icon-btn--primary {\n    background: var(--ac-accent);\n    border-color: var(--ac-accent);\n    color: var(--ac-accent-contrast);\n  }\n\n  .qp-icon-btn--primary:hover:not(:disabled) {\n    background: var(--ac-accent-hover);\n    border-color: var(--ac-accent-hover);\n    color: var(--ac-accent-contrast);\n  }\n\n  .qp-icon-btn--danger {\n    background: var(--ac-danger);\n    border-color: var(--ac-danger);\n    color: #ffffff;\n  }\n\n  .qp-icon-btn--danger:hover:not(:disabled) {\n    background: color-mix(in srgb, var(--ac-danger) 85%, #000);\n    color: #ffffff;\n  }\n\n  /* Quick Entries Grid */\n  .qp-entries {\n    display: grid;\n    grid-template-columns: repeat(4, minmax(0, 1fr));\n    gap: 10px;\n    padding: 10px 2px;\n  }\n\n  .qp-entry {\n    border: var(--ac-border-width) solid var(--qp-divider);\n    background: var(--ac-surface);\n    border-radius: var(--ac-radius-card);\n    padding: 14px 10px;\n    cursor: pointer;\n    user-select: none;\n    color: var(--ac-text);\n    font-family: var(--ac-font-body);\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 8px;\n    transition:\n      background-color var(--ac-motion-fast),\n      border-color var(--ac-motion-fast),\n      box-shadow var(--ac-motion-fast);\n  }\n\n  .qp-entry:hover {\n    background: var(--ac-hover-bg);\n    box-shadow: var(--ac-shadow-card);\n  }\n\n  .qp-entry:active {\n    box-shadow: none;\n  }\n\n  .qp-entry:disabled {\n    cursor: not-allowed;\n    opacity: 0.5;\n  }\n\n  .qp-entry[data-active='true'] {\n    border-color: var(--ac-accent);\n    background: var(--ac-accent-subtle);\n  }\n\n  .qp-entry__icon {\n    width: 40px;\n    height: 40px;\n    border-radius: var(--ac-radius-inner);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: var(--ac-surface-muted);\n    border: var(--ac-border-width) solid var(--qp-divider);\n    font-size: 16px;\n  }\n\n  .qp-entry__label {\n    font-weight: 600;\n    font-size: 12px;\n    letter-spacing: 0.2px;\n  }\n\n  .qp-entry__prefix {\n    font-family: var(--ac-font-code);\n    font-size: 10px;\n    color: var(--ac-text-muted);\n    border: var(--ac-border-width) solid var(--qp-divider);\n    border-radius: 999px;\n    padding: 2px 8px;\n    background: var(--ac-surface-muted);\n  }\n\n  /* View Mount Points */\n  .qp-header-mount,\n  .qp-header-right-mount,\n  .qp-content-mount,\n  .qp-footer-mount {\n    display: contents;\n  }\n\n  .qp-header-mount[hidden],\n  .qp-header-right-mount[hidden],\n  .qp-content-mount[hidden],\n  .qp-footer-mount[hidden] {\n    display: none;\n  }\n\n  /* Footer Hints */\n  .qp-footer-hints {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 16px;\n    padding: 8px 0;\n    font-size: 11px;\n    color: var(--ac-text-muted);\n  }\n\n  .qp-footer-hint {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n  }\n\n  /* ============================================================\n   * Markdown Content Styles (for markstream-vue)\n   * ============================================================ */\n\n  .qp-markdown-content {\n    font-size: 13px;\n    line-height: 1.5;\n    color: var(--ac-text);\n  }\n\n  .qp-markdown-content pre {\n    background-color: var(--ac-surface-muted);\n    border: var(--ac-border-width) solid var(--ac-border);\n    border-radius: var(--ac-radius-inner);\n    padding: 12px;\n    overflow-x: auto;\n    margin: 0.5em 0;\n  }\n\n  .qp-markdown-content code {\n    font-family: var(--ac-font-code);\n    font-size: 0.875em;\n    color: var(--ac-text);\n  }\n\n  .qp-markdown-content :not(pre) > code {\n    background-color: var(--ac-surface-muted);\n    padding: 0.125em 0.25em;\n    border-radius: 4px;\n  }\n\n  .qp-markdown-content p {\n    margin: 0.5em 0;\n  }\n\n  .qp-markdown-content p:first-child {\n    margin-top: 0;\n  }\n\n  .qp-markdown-content p:last-child {\n    margin-bottom: 0;\n  }\n\n  .qp-markdown-content ul,\n  .qp-markdown-content ol {\n    margin: 0.5em 0;\n    padding-left: 1.5em;\n  }\n\n  .qp-markdown-content li {\n    margin: 0.25em 0;\n  }\n\n  .qp-markdown-content h1,\n  .qp-markdown-content h2,\n  .qp-markdown-content h3,\n  .qp-markdown-content h4,\n  .qp-markdown-content h5,\n  .qp-markdown-content h6 {\n    margin: 0.75em 0 0.5em;\n    font-weight: 600;\n    line-height: 1.3;\n  }\n\n  .qp-markdown-content h1 { font-size: 1.5em; }\n  .qp-markdown-content h2 { font-size: 1.3em; }\n  .qp-markdown-content h3 { font-size: 1.15em; }\n  .qp-markdown-content h4 { font-size: 1em; }\n\n  .qp-markdown-content blockquote {\n    border-left: 3px solid var(--ac-border-strong);\n    padding-left: 1em;\n    margin: 0.5em 0;\n    color: var(--ac-text-muted);\n  }\n\n  .qp-markdown-content a {\n    color: var(--ac-link);\n    text-decoration: underline;\n  }\n\n  .qp-markdown-content a:hover {\n    color: var(--ac-link-hover);\n  }\n\n  .qp-markdown-content table {\n    border-collapse: collapse;\n    margin: 0.5em 0;\n    width: 100%;\n    font-size: 0.9em;\n  }\n\n  .qp-markdown-content th,\n  .qp-markdown-content td {\n    border: var(--ac-border-width) solid var(--ac-border);\n    padding: 0.5em;\n    text-align: left;\n  }\n\n  .qp-markdown-content th {\n    background-color: var(--ac-surface-muted);\n    font-weight: 600;\n  }\n\n  .qp-markdown-content hr {\n    border: none;\n    border-top: var(--ac-border-width) solid var(--ac-border);\n    margin: 1em 0;\n  }\n\n  .qp-markdown-content img {\n    max-width: 100%;\n    height: auto;\n    border-radius: var(--ac-radius-inner);\n  }\n\n  .qp-markdown-content strong {\n    font-weight: 600;\n  }\n\n  .qp-markdown-content em {\n    font-style: italic;\n  }\n`;\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/dom-path.ts",
    "content": "/**\n * DOM Path - DOM 路径计算和定位\n *\n * DOM 路径是元素在 DOM 树中的索引路径，用于：\n * - 元素位置追踪\n * - 选择器失效后的快速恢复\n * - 元素比较和验证\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * DOM 路径：从根到目标元素的子元素索引数组\n *\n * @example\n * ```\n * [0, 2, 1] 表示:\n * root\n *  └─ children[0]\n *      └─ children[2]\n *          └─ children[1]  <- 目标元素\n * ```\n */\nexport type DomPath = number[];\n\n// =============================================================================\n// Core Functions\n// =============================================================================\n\n/**\n * 计算元素在 DOM 树中的路径\n *\n * 从目标元素向上遍历到根节点（Document 或 ShadowRoot），\n * 记录每一层在父元素 children 中的索引。\n *\n * @example\n * ```ts\n * const path = computeDomPath(button);\n * // => [0, 2, 1] - 从 body/shadowRoot 开始的路径\n * ```\n */\nexport function computeDomPath(element: Element): DomPath {\n  const path: DomPath = [];\n  let current: Element | null = element;\n\n  while (current) {\n    const parent: Element | null = current.parentElement;\n\n    if (parent) {\n      // 正常父元素\n      const siblings = Array.from(parent.children);\n      const index = siblings.indexOf(current);\n      if (index >= 0) {\n        path.unshift(index);\n      }\n      current = parent;\n      continue;\n    }\n\n    // 检查是否是 ShadowRoot 或 Document 的直接子元素\n    const parentNode = current.parentNode;\n    if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {\n      const children = Array.from(parentNode.children);\n      const index = children.indexOf(current);\n      if (index >= 0) {\n        path.unshift(index);\n      }\n    }\n\n    // 到达根节点，停止遍历\n    break;\n  }\n\n  return path;\n}\n\n/**\n * 根据 DOM 路径定位元素\n *\n * @param root - 查询根节点（Document 或 ShadowRoot）\n * @param path - DOM 路径\n * @returns 找到的元素，如果路径无效则返回 null\n *\n * @example\n * ```ts\n * const element = locateByDomPath(document, [0, 2, 1]);\n * // => 返回 body > children[0] > children[2] > children[1]\n * ```\n */\nexport function locateByDomPath(root: Document | ShadowRoot, path: DomPath): Element | null {\n  if (path.length === 0) {\n    return null;\n  }\n\n  let current: Element | null = root.children[path[0]] ?? null;\n\n  for (let i = 1; i < path.length && current; i++) {\n    const index = path[i];\n    current = current.children[index] ?? null;\n  }\n\n  return current;\n}\n\n/**\n * 比较两个 DOM 路径\n *\n * @returns 包含是否相同和公共前缀长度的结果\n *\n * @example\n * ```ts\n * const result = compareDomPaths([0, 2, 1], [0, 2, 3]);\n * // => { same: false, commonPrefixLength: 2 }\n * ```\n */\nexport function compareDomPaths(\n  a: DomPath,\n  b: DomPath,\n): { same: boolean; commonPrefixLength: number } {\n  const minLen = Math.min(a.length, b.length);\n  let commonPrefixLength = 0;\n\n  for (let i = 0; i < minLen; i++) {\n    if (a[i] === b[i]) {\n      commonPrefixLength++;\n    } else {\n      break;\n    }\n  }\n\n  const same = a.length === b.length && commonPrefixLength === a.length;\n\n  return { same, commonPrefixLength };\n}\n\n/**\n * 检查路径 A 是否是路径 B 的祖先\n *\n * @example\n * ```ts\n * isAncestorPath([0, 2], [0, 2, 1]); // true\n * isAncestorPath([0, 2, 1], [0, 2]); // false\n * ```\n */\nexport function isAncestorPath(ancestor: DomPath, descendant: DomPath): boolean {\n  if (ancestor.length >= descendant.length) {\n    return false;\n  }\n\n  for (let i = 0; i < ancestor.length; i++) {\n    if (ancestor[i] !== descendant[i]) {\n      return false;\n    }\n  }\n\n  return true;\n}\n\n/**\n * 获取从祖先路径到后代路径的相对路径\n *\n * @example\n * ```ts\n * getRelativePath([0, 2], [0, 2, 1, 3]); // [1, 3]\n * ```\n */\nexport function getRelativePath(ancestor: DomPath, descendant: DomPath): DomPath | null {\n  if (!isAncestorPath(ancestor, descendant)) {\n    return null;\n  }\n\n  return descendant.slice(ancestor.length);\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/fingerprint.ts",
    "content": "/**\n * Element Fingerprint - 元素指纹生成和验证\n *\n * 指纹用于元素的模糊匹配和验证，特别是在以下场景：\n * - 选择器匹配到元素后，验证是否是期望的元素\n * - HMR 后元素恢复\n * - 防止\"相同选择器不同元素\"的误匹配\n */\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst FINGERPRINT_TEXT_MAX_LENGTH = 32;\nconst FINGERPRINT_MAX_CLASSES = 8;\nconst FINGERPRINT_SEPARATOR = '|';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface ElementFingerprint {\n  tag: string;\n  id?: string;\n  classes?: string[];\n  text?: string;\n  raw: string;\n}\n\nexport interface FingerprintOptions {\n  textMaxLength?: number;\n  maxClasses?: number;\n}\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\n/**\n * 标准化文本内容：合并空白字符并截取\n */\nfunction normalizeText(text: string, maxLength: number): string {\n  return text.replace(/\\s+/g, ' ').trim().slice(0, maxLength);\n}\n\n// =============================================================================\n// Core Functions\n// =============================================================================\n\n/**\n * 为 DOM 元素计算结构化指纹\n *\n * 指纹格式: `tag|id=xxx|class=a.b.c|text=xxx`\n *\n * @example\n * ```ts\n * const fp = computeFingerprint(buttonElement);\n * // => \"button|id=submit-btn|class=btn.primary|text=Submit\"\n * ```\n */\nexport function computeFingerprint(element: Element, options?: FingerprintOptions): string {\n  const textMaxLength = options?.textMaxLength ?? FINGERPRINT_TEXT_MAX_LENGTH;\n  const maxClasses = options?.maxClasses ?? FINGERPRINT_MAX_CLASSES;\n\n  const parts: string[] = [];\n\n  // 1. Tag name (必须)\n  const tag = element.tagName?.toLowerCase() ?? 'unknown';\n  parts.push(tag);\n\n  // 2. ID (如果存在)\n  const id = element.id?.trim();\n  if (id) {\n    parts.push(`id=${id}`);\n  }\n\n  // 3. Class names (最多 maxClasses 个)\n  const classes = Array.from(element.classList).slice(0, maxClasses);\n  if (classes.length > 0) {\n    parts.push(`class=${classes.join('.')}`);\n  }\n\n  // 4. Text content hint (标准化后截取)\n  const text = normalizeText(element.textContent ?? '', textMaxLength);\n  if (text) {\n    parts.push(`text=${text}`);\n  }\n\n  return parts.join(FINGERPRINT_SEPARATOR);\n}\n\n/**\n * 解析指纹字符串为结构化对象\n *\n * @example\n * ```ts\n * const fp = parseFingerprint(\"button|id=submit|class=btn.primary|text=Submit\");\n * // => { tag: \"button\", id: \"submit\", classes: [\"btn\", \"primary\"], text: \"Submit\", raw: \"...\" }\n * ```\n */\nexport function parseFingerprint(fingerprint: string): ElementFingerprint {\n  const parts = fingerprint.split(FINGERPRINT_SEPARATOR);\n  const result: ElementFingerprint = {\n    tag: parts[0] ?? 'unknown',\n    raw: fingerprint,\n  };\n\n  for (let i = 1; i < parts.length; i++) {\n    const part = parts[i];\n    if (part.startsWith('id=')) {\n      result.id = part.slice(3);\n    } else if (part.startsWith('class=')) {\n      result.classes = part.slice(6).split('.');\n    } else if (part.startsWith('text=')) {\n      result.text = part.slice(5);\n    }\n  }\n\n  return result;\n}\n\n/**\n * 验证元素是否匹配给定的指纹\n *\n * 验证规则：\n * - tag 必须完全匹配\n * - 如果存储的指纹有 id，当前元素的 id 必须匹配\n * - class 和 text 不强制匹配（用于计算相似度）\n *\n * @example\n * ```ts\n * const stored = computeFingerprint(element);\n * // ... 页面变化后\n * const stillMatches = verifyFingerprint(element, stored);\n * ```\n */\nexport function verifyFingerprint(element: Element, fingerprint: string): boolean {\n  const stored = parseFingerprint(fingerprint);\n  const currentTag = element.tagName?.toLowerCase() ?? 'unknown';\n\n  // Tag 必须匹配\n  if (stored.tag !== currentTag) {\n    return false;\n  }\n\n  // 如果存储的指纹有 id，当前元素必须有相同的 id\n  if (stored.id) {\n    const currentId = element.id?.trim();\n    if (stored.id !== currentId) {\n      return false;\n    }\n  }\n\n  return true;\n}\n\n/**\n * 计算两个指纹之间的相似度\n *\n * @returns 相似度分数 0-1，1 表示完全匹配\n *\n * @example\n * ```ts\n * const score = fingerprintSimilarity(fpA, fpB);\n * if (score > 0.8) {\n *   // 高度相似，可能是同一个元素\n * }\n * ```\n */\nexport function fingerprintSimilarity(a: string, b: string): number {\n  const fpA = parseFingerprint(a);\n  const fpB = parseFingerprint(b);\n\n  let score = 0;\n  let weights = 0;\n\n  // Tag 匹配 (权重 0.4)\n  const tagWeight = 0.4;\n  weights += tagWeight;\n  if (fpA.tag === fpB.tag) {\n    score += tagWeight;\n  } else {\n    // Tag 不匹配，直接返回 0\n    return 0;\n  }\n\n  // ID 匹配 (权重 0.3)\n  const idWeight = 0.3;\n  if (fpA.id || fpB.id) {\n    weights += idWeight;\n    if (fpA.id === fpB.id) {\n      score += idWeight;\n    }\n  }\n\n  // Class 匹配 (权重 0.2) - 使用 Jaccard 相似度\n  const classWeight = 0.2;\n  if ((fpA.classes?.length ?? 0) > 0 || (fpB.classes?.length ?? 0) > 0) {\n    weights += classWeight;\n    const setA = new Set(fpA.classes ?? []);\n    const setB = new Set(fpB.classes ?? []);\n    const intersection = [...setA].filter((c) => setB.has(c)).length;\n    const union = new Set([...(fpA.classes ?? []), ...(fpB.classes ?? [])]).size;\n    if (union > 0) {\n      score += classWeight * (intersection / union);\n    }\n  }\n\n  // Text 匹配 (权重 0.1) - 简单包含检查\n  const textWeight = 0.1;\n  if (fpA.text || fpB.text) {\n    weights += textWeight;\n    if (fpA.text && fpB.text) {\n      // 检查是否有重叠\n      const textA = fpA.text.toLowerCase();\n      const textB = fpB.text.toLowerCase();\n      if (textA === textB) {\n        score += textWeight;\n      } else if (textA.includes(textB) || textB.includes(textA)) {\n        score += textWeight * 0.5;\n      }\n    }\n  }\n\n  return weights > 0 ? score / weights : 0;\n}\n\n/**\n * 检查两个指纹是否表示同一个元素\n *\n * 基于相似度阈值判断，默认阈值 0.7\n */\nexport function fingerprintMatches(\n  a: string,\n  b: string,\n  threshold = 0.7,\n): { match: boolean; score: number } {\n  const score = fingerprintSimilarity(a, b);\n  return {\n    match: score >= threshold,\n    score,\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/generator.ts",
    "content": "/**\n * Selector Generator - 选择器生成器\n * 为 DOM 元素生成多个候选选择器\n */\n\nimport type {\n  NonEmptyArray,\n  NormalizedSelectorGenerationOptions,\n  SelectorCandidate,\n  SelectorGenerationOptions,\n  SelectorStrategy,\n  SelectorStrategyContext,\n  SelectorTarget,\n  ExtendedSelectorTarget,\n} from './types';\nimport { compareSelectorCandidates, withStability } from './stability';\nimport { DEFAULT_SELECTOR_STRATEGIES } from './strategies';\nimport { computeDomPath } from './dom-path';\nimport { computeFingerprint } from './fingerprint';\n\nconst DEFAULT_MAX_CANDIDATES = 8;\nconst DEFAULT_TEXT_MAX_LENGTH = 64;\n\nconst DEFAULT_TEXT_TAGS = ['button', 'a', 'summary'] as const;\n\nconst DEFAULT_TESTID_ATTRS = [\n  'data-testid',\n  'data-test-id',\n  'data-testId',\n  'data-test',\n  'data-qa',\n  'data-cy',\n  'name',\n  'title',\n  'alt',\n] as const;\n\nfunction clampInt(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) return min;\n  return Math.min(max, Math.max(min, Math.floor(value)));\n}\n\n/**\n * 标准化选择器生成选项\n */\nexport function normalizeSelectorGenerationOptions(\n  options: SelectorGenerationOptions | undefined,\n): NormalizedSelectorGenerationOptions {\n  return {\n    maxCandidates: clampInt(options?.maxCandidates ?? DEFAULT_MAX_CANDIDATES, 1, 50),\n    includeText: options?.includeText ?? true,\n    includeAria: options?.includeAria ?? true,\n    includeCssUnique: options?.includeCssUnique ?? true,\n    includeCssPath: options?.includeCssPath ?? true,\n    testIdAttributes: options?.testIdAttributes ?? DEFAULT_TESTID_ATTRS,\n    textMaxLength: clampInt(options?.textMaxLength ?? DEFAULT_TEXT_MAX_LENGTH, 1, 256),\n    textTags: options?.textTags ?? DEFAULT_TEXT_TAGS,\n  };\n}\n\n/**\n * CSS 字符串转义\n * Uses native CSS.escape when available; otherwise falls back to a spec-inspired polyfill.\n */\nexport function cssEscape(value: string): string {\n  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value);\n\n  const str = String(value);\n  const len = str.length;\n  if (len === 0) return '';\n\n  let result = '';\n  const firstCodeUnit = str.charCodeAt(0);\n\n  for (let i = 0; i < len; i++) {\n    const codeUnit = str.charCodeAt(i);\n\n    if (codeUnit === 0x0000) {\n      result += '\\uFFFD';\n      continue;\n    }\n\n    if (\n      (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||\n      codeUnit === 0x007f ||\n      (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||\n      (i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)\n    ) {\n      result += `\\\\${codeUnit.toString(16)} `;\n      continue;\n    }\n\n    if (i === 0 && len === 1 && codeUnit === 0x002d) {\n      result += `\\\\${str.charAt(i)}`;\n      continue;\n    }\n\n    const isAsciiAlnum =\n      (codeUnit >= 0x0030 && codeUnit <= 0x0039) ||\n      (codeUnit >= 0x0041 && codeUnit <= 0x005a) ||\n      (codeUnit >= 0x0061 && codeUnit <= 0x007a);\n\n    const isSafe = isAsciiAlnum || codeUnit === 0x002d || codeUnit === 0x005f;\n\n    if (isSafe) result += str.charAt(i);\n    else result += `\\\\${str.charAt(i)}`;\n  }\n\n  return result;\n}\n\nfunction getQueryRoot(element: Element): ParentNode {\n  const root = element.getRootNode?.();\n  if (root instanceof ShadowRoot) return root;\n  if (typeof document !== 'undefined') return document;\n  throw new Error('Selector generator requires a DOM-like environment');\n}\n\nfunction safeQueryAll(root: ParentNode, selector: string): ReadonlyArray<Element> {\n  try {\n    return Array.from(root.querySelectorAll(selector));\n  } catch {\n    return [];\n  }\n}\n\nfunction isUnique(root: ParentNode, selector: string): boolean {\n  try {\n    return root.querySelectorAll(selector).length === 1;\n  } catch {\n    return false;\n  }\n}\n\nfunction candidateKey(c: SelectorCandidate): string {\n  switch (c.type) {\n    case 'text':\n      return `text:${c.value}:${c.tagNameHint ?? ''}:${c.match ?? ''}`;\n    case 'aria':\n      return `aria:${c.role ?? ''}:${c.name ?? ''}:${c.value}`;\n    default:\n      return `${c.type}:${c.value}`;\n  }\n}\n\nexport interface GenerateSelectorTargetOptions extends SelectorGenerationOptions {\n  root?: ParentNode;\n  strategies?: ReadonlyArray<SelectorStrategy>;\n}\n\n/**\n * 为 DOM 元素生成选择器目标\n */\nexport function generateSelectorTarget(\n  element: Element,\n  options: GenerateSelectorTargetOptions = {},\n): SelectorTarget {\n  const normalized = normalizeSelectorGenerationOptions(options);\n  const root = options.root ?? getQueryRoot(element);\n\n  const helpers = {\n    cssEscape,\n    isUnique: (selector: string) => isUnique(root, selector),\n    safeQueryAll: (selector: string) => safeQueryAll(root, selector),\n  };\n\n  const ctx: SelectorStrategyContext = {\n    element,\n    root,\n    options: normalized,\n    helpers,\n  };\n\n  const strategies = options.strategies ?? DEFAULT_SELECTOR_STRATEGIES;\n\n  const raw: SelectorCandidate[] = [];\n  for (const strategy of strategies) {\n    const produced = strategy.generate(ctx);\n    for (const c0 of produced) {\n      raw.push({\n        ...c0,\n        source: c0.source ?? 'generated',\n        strategy: c0.strategy ?? strategy.id,\n      });\n    }\n  }\n\n  // Dedupe (keep first occurrence)\n  const seen = new Set<string>();\n  const deduped: SelectorCandidate[] = [];\n  for (const c of raw) {\n    const key = candidateKey(c);\n    if (seen.has(key)) continue;\n    seen.add(key);\n    deduped.push(withStability(c));\n  }\n\n  // If strategies produced nothing (shouldn't happen), create a minimal fallback.\n  if (deduped.length === 0) {\n    const fallback: SelectorCandidate = withStability({\n      type: 'css',\n      value: 'body',\n      source: 'generated',\n      strategy: 'fallback',\n    });\n    const candidates: NonEmptyArray<SelectorCandidate> = [fallback];\n    return {\n      selector: fallback.value,\n      candidates,\n      tagName: element.tagName?.toLowerCase?.() ?? undefined,\n    };\n  }\n\n  // Sort and truncate\n  const sorted = [...deduped].sort(compareSelectorCandidates).slice(0, normalized.maxCandidates);\n\n  // Primary selector should be directly usable by locator (prefer CSS/attr)\n  const primary = sorted.find((c) => c.type === 'css' || c.type === 'attr') ?? sorted[0];\n\n  const reordered = (() => {\n    const idx = sorted.indexOf(primary);\n    if (idx <= 0) return sorted;\n    return [primary, ...sorted.slice(0, idx), ...sorted.slice(idx + 1)];\n  })();\n\n  const tagName = element.tagName?.toLowerCase?.() ?? undefined;\n\n  return {\n    selector: primary.value,\n    candidates: reordered as NonEmptyArray<SelectorCandidate>,\n    tagName,\n  };\n}\n\n// =============================================================================\n// Extended Selector Target (Phase 1.2)\n// =============================================================================\n\nfunction safeMatches(element: Element, selector: string): boolean {\n  try {\n    return element.matches(selector);\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Pick the best selector for a shadow host element.\n * Prefers unique CSS/attr selectors from the generated candidates.\n */\nfunction pickShadowHostSelector(\n  host: Element,\n  hostRoot: ParentNode,\n  options: GenerateSelectorTargetOptions,\n): string | null {\n  const hostTarget = generateSelectorTarget(host, { ...options, root: hostRoot });\n\n  let fallback: string | null = null;\n\n  // Try to find a unique selector from candidates\n  for (const candidate of hostTarget.candidates) {\n    if (candidate.type !== 'css' && candidate.type !== 'attr') continue;\n\n    const selector = String(candidate.value || '').trim();\n    if (!selector) continue;\n\n    // Verify the selector actually matches the host\n    if (!safeMatches(host, selector)) continue;\n\n    // Check uniqueness in the host's root\n    if (isUnique(hostRoot, selector)) {\n      return selector;\n    }\n\n    // Keep first matching selector as fallback\n    if (!fallback) {\n      fallback = selector;\n    }\n  }\n\n  // Try the primary selector\n  const primary = typeof hostTarget.selector === 'string' ? hostTarget.selector.trim() : '';\n  if (primary && safeMatches(host, primary)) {\n    return primary;\n  }\n\n  return fallback;\n}\n\n/**\n * Compute shadow host selector chain (outer -> inner).\n *\n * Returns an empty array when:\n * - Element is not inside Shadow DOM\n * - A host selector cannot be generated for any boundary\n */\nfunction computeShadowHostChain(\n  element: Element,\n  options: GenerateSelectorTargetOptions,\n): string[] {\n  const chain: string[] = [];\n  let current: Element = element;\n\n  while (true) {\n    const rootNode = current.getRootNode?.();\n    if (!(rootNode instanceof ShadowRoot)) {\n      break;\n    }\n\n    const host = rootNode.host;\n    if (!(host instanceof Element)) {\n      break;\n    }\n\n    const hostRoot = getQueryRoot(host);\n    const hostSelector = pickShadowHostSelector(host, hostRoot, options);\n\n    if (!hostSelector) {\n      // Cannot generate selector for this host, return empty chain\n      return [];\n    }\n\n    chain.unshift(hostSelector);\n    current = host;\n  }\n\n  return chain;\n}\n\n/**\n * Generate selector target with additional metadata (Phase 1.2).\n *\n * This function generates a complete ElementLocator-like structure including:\n * - fingerprint: for fuzzy element matching\n * - domPath: for fast element recovery\n * - shadowHostChain: for Shadow DOM traversal\n *\n * @example\n * ```ts\n * const target = generateExtendedSelectorTarget(buttonElement);\n * // target.fingerprint = \"button|id=submit|class=btn.primary\"\n * // target.domPath = [0, 2, 1]\n * // target.shadowHostChain = [\"my-component\"] or []\n * ```\n */\nexport function generateExtendedSelectorTarget(\n  element: Element,\n  options: GenerateSelectorTargetOptions = {},\n): ExtendedSelectorTarget {\n  const base = generateSelectorTarget(element, options);\n\n  return {\n    ...base,\n    fingerprint: computeFingerprint(element),\n    domPath: computeDomPath(element),\n    shadowHostChain: computeShadowHostChain(element, options),\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/index.ts",
    "content": "/**\n * Selector Engine - Unified selector generation and element location\n *\n * Modules:\n * - types: Type definitions\n * - stability: Stability scoring\n * - strategies: Selector generation strategies\n * - generator: Selector target generation\n * - locator: Element location\n * - fingerprint: Element fingerprinting (Phase 1.2)\n * - dom-path: DOM path computation (Phase 1.2)\n * - shadow-dom: Shadow DOM utilities (Phase 1.2)\n */\n\n// Type exports\nexport * from './types';\n\n// Stability scoring\nexport { computeSelectorStability, withStability, compareSelectorCandidates } from './stability';\n\n// Selector strategies\nexport { DEFAULT_SELECTOR_STRATEGIES } from './strategies';\nexport { anchorRelpathStrategy } from './strategies/anchor-relpath';\nexport { ariaStrategy } from './strategies/aria';\nexport { cssPathStrategy } from './strategies/css-path';\nexport { cssUniqueStrategy } from './strategies/css-unique';\nexport { testIdStrategy } from './strategies/testid';\nexport { textStrategy } from './strategies/text';\n\n// Selector generation\nexport {\n  generateSelectorTarget,\n  generateExtendedSelectorTarget,\n  normalizeSelectorGenerationOptions,\n  cssEscape,\n  type GenerateSelectorTargetOptions,\n} from './generator';\n\n// Element location\nexport {\n  SelectorLocator,\n  createChromeSelectorLocator,\n  createChromeSelectorLocatorTransport,\n  type SelectorLocatorTransport,\n} from './locator';\n\n// Fingerprint utilities (Phase 1.2)\nexport {\n  computeFingerprint,\n  parseFingerprint,\n  verifyFingerprint,\n  fingerprintSimilarity,\n  fingerprintMatches,\n  type ElementFingerprint,\n  type FingerprintOptions,\n} from './fingerprint';\n\n// DOM path utilities (Phase 1.2)\nexport {\n  computeDomPath,\n  locateByDomPath,\n  compareDomPaths,\n  isAncestorPath,\n  getRelativePath,\n  type DomPath,\n} from './dom-path';\n\n// Shadow DOM utilities (Phase 1.2)\nexport {\n  traverseShadowDom,\n  traverseShadowDomWithDetails,\n  queryInShadowDom,\n  queryAllInShadowDom,\n  isUniqueInShadowDom,\n  type ShadowTraversalResult,\n  type ShadowTraversalFailureReason,\n} from './shadow-dom';\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/locator.ts",
    "content": "/**\n * Selector Locator - 元素定位器\n * 使用选择器候选列表定位 DOM 元素\n */\n\nimport { TOOL_MESSAGE_TYPES } from '../../common/message-types';\nimport {\n  composeCompositeSelector,\n  isCompositeSelector,\n  splitCompositeSelector,\n  type LocatedElement,\n  type Point,\n  type SelectorCandidate,\n  type SelectorLocateOptions,\n  type SelectorTarget,\n} from './types';\nimport { compareSelectorCandidates, withStability } from './stability';\n\n// ================================\n// 消息类型定义\n// ================================\n\ninterface EnsureRefForSelectorRequest {\n  action: typeof TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR;\n  selector?: string;\n  useText?: boolean;\n  text?: string;\n  isXPath?: boolean;\n  tagName?: string;\n  allowMultiple?: boolean;\n}\n\ntype EnsureRefForSelectorResponse =\n  | { success: true; ref: string; center: Point; href?: string }\n  | { success: false; error?: string; cancelled?: boolean };\n\ninterface ResolveRefRequest {\n  action: typeof TOOL_MESSAGE_TYPES.RESOLVE_REF;\n  ref: string;\n}\n\ntype ResolveRefResponse =\n  | {\n      success: true;\n      center: Point;\n      rect?: { x: number; y: number; width: number; height: number };\n      selector?: string;\n    }\n  | { success: false; error?: string };\n\ninterface VerifyFingerprintRequest {\n  action: typeof TOOL_MESSAGE_TYPES.VERIFY_FINGERPRINT;\n  ref: string;\n  fingerprint: string;\n}\n\ntype VerifyFingerprintResponse =\n  | { success: true; match: boolean }\n  | { success: false; error?: string };\n\n// ================================\n// 传输层接口\n// ================================\n\nexport interface SelectorLocatorTransport {\n  sendMessage: (\n    tabId: number,\n    message: unknown,\n    options?: { frameId?: number },\n  ) => Promise<unknown>;\n  getAllFrames?: (tabId: number) => Promise<ReadonlyArray<{ frameId: number; url: string }>>;\n}\n\n// ================================\n// 工具函数\n// ================================\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\nfunction isPoint(value: unknown): value is Point {\n  if (!isRecord(value)) return false;\n  return (\n    typeof value.x === 'number' &&\n    Number.isFinite(value.x) &&\n    typeof value.y === 'number' &&\n    Number.isFinite(value.y)\n  );\n}\n\nfunction parseEnsureRefResponse(value: unknown): EnsureRefForSelectorResponse | null {\n  if (!isRecord(value) || typeof value.success !== 'boolean') return null;\n\n  if (value.success) {\n    if (typeof value.ref !== 'string' || !isPoint(value.center)) return null;\n    const href = typeof value.href === 'string' ? value.href : undefined;\n    return { success: true, ref: value.ref, center: value.center, href };\n  }\n\n  const error = typeof value.error === 'string' ? value.error : undefined;\n  const cancelled = typeof value.cancelled === 'boolean' ? value.cancelled : undefined;\n  return { success: false, error, cancelled };\n}\n\nfunction parseResolveRefResponse(value: unknown): ResolveRefResponse | null {\n  if (!isRecord(value) || typeof value.success !== 'boolean') return null;\n\n  if (value.success) {\n    if (!isPoint(value.center)) return null;\n\n    const rect =\n      isRecord(value.rect) &&\n      typeof value.rect.x === 'number' &&\n      typeof value.rect.y === 'number' &&\n      typeof value.rect.width === 'number' &&\n      typeof value.rect.height === 'number'\n        ? {\n            x: value.rect.x,\n            y: value.rect.y,\n            width: value.rect.width,\n            height: value.rect.height,\n          }\n        : undefined;\n\n    const selector = typeof value.selector === 'string' ? value.selector : undefined;\n    return { success: true, center: value.center, rect, selector };\n  }\n\n  const error = typeof value.error === 'string' ? value.error : undefined;\n  return { success: false, error };\n}\n\nfunction parseVerifyFingerprintResponse(value: unknown): VerifyFingerprintResponse | null {\n  if (!isRecord(value) || typeof value.success !== 'boolean') return null;\n\n  if (value.success) {\n    if (typeof value.match !== 'boolean') return null;\n    return { success: true, match: value.match };\n  }\n\n  const error = typeof value.error === 'string' ? value.error : undefined;\n  return { success: false, error };\n}\n\nfunction deriveFrameSelector(target: SelectorTarget): string | undefined {\n  if (typeof target.selector === 'string') {\n    const parts = splitCompositeSelector(target.selector);\n    if (parts) return parts.frameSelector;\n  }\n  for (const c of target.candidates) {\n    const parts = splitCompositeSelector(c.value);\n    if (parts) return parts.frameSelector;\n  }\n  return undefined;\n}\n\nfunction deriveTagNameHint(\n  target: SelectorTarget,\n  candidate: SelectorCandidate | undefined,\n): string | undefined {\n  if (candidate?.type === 'text' && candidate.tagNameHint) return candidate.tagNameHint;\n  return target.tagName;\n}\n\nfunction parseAriaExpr(expr: string): { role?: string; name?: string } {\n  const v = String(expr || '').trim();\n  const m = v.match(/^(\\w+)\\s*\\[\\s*name\\s*=\\s*([^\\]]+)\\s*\\]$/);\n  if (!m) return {};\n  const role = m[1]?.trim();\n  const rawName = m[2]?.trim();\n  const name = rawName ? rawName.replace(/^['\"]|['\"]$/g, '') : undefined;\n  return { role: role || undefined, name: name || undefined };\n}\n\nfunction uniqStrings(items: ReadonlyArray<string>): string[] {\n  const seen = new Set<string>();\n  const out: string[] = [];\n  for (const s of items) {\n    const v = s.trim();\n    if (!v) continue;\n    if (seen.has(v)) continue;\n    seen.add(v);\n    out.push(v);\n  }\n  return out;\n}\n\nfunction ariaToCssSelectors(role: string | undefined, name: string | undefined): string[] {\n  if (!name || !name.trim()) return [];\n  const cleanRole = role?.trim();\n  const cleanName = name.trim();\n  const qName = JSON.stringify(cleanName);\n\n  const out: string[] = [];\n\n  if (cleanRole) out.push(`[role=${JSON.stringify(cleanRole)}][aria-label=${qName}]`);\n\n  if (cleanRole === 'textbox') {\n    out.unshift(\n      `input[aria-label=${qName}]`,\n      `textarea[aria-label=${qName}]`,\n      `[role=\"textbox\"][aria-label=${qName}]`,\n    );\n  } else if (cleanRole === 'button') {\n    out.unshift(`button[aria-label=${qName}]`, `[role=\"button\"][aria-label=${qName}]`);\n  } else if (cleanRole === 'link') {\n    out.unshift(`a[aria-label=${qName}]`, `[role=\"link\"][aria-label=${qName}]`);\n  }\n\n  out.push(`[aria-label=${qName}]`);\n  return uniqStrings(out);\n}\n\n// ================================\n// SelectorLocator 类\n// ================================\n\nexport class SelectorLocator {\n  constructor(private readonly transport: SelectorLocatorTransport) {}\n\n  private async mapHrefToFrameId(\n    tabId: number,\n    href: string | undefined,\n  ): Promise<number | undefined> {\n    if (!href || !this.transport.getAllFrames) return undefined;\n    try {\n      const frames = await this.transport.getAllFrames(tabId);\n      const match = frames.find((f) => f.url === href);\n      return match?.frameId;\n    } catch {\n      return undefined;\n    }\n  }\n\n  private async ensureRef(\n    tabId: number,\n    request: EnsureRefForSelectorRequest,\n    frameId: number | undefined,\n  ): Promise<{ ref: string; center: Point; href?: string } | null> {\n    const selector = request.selector ?? '';\n    const responseRaw = await this.transport.sendMessage(\n      tabId,\n      request,\n      isCompositeSelector(selector) ? undefined : { frameId },\n    );\n    const parsed = parseEnsureRefResponse(responseRaw);\n    if (!parsed || !parsed.success) return null;\n    return { ref: parsed.ref, center: parsed.center, href: parsed.href };\n  }\n\n  private async resolveRef(\n    tabId: number,\n    ref: string,\n    frameId: number | undefined,\n  ): Promise<LocatedElement | null> {\n    const msg = { action: TOOL_MESSAGE_TYPES.RESOLVE_REF, ref } satisfies ResolveRefRequest;\n    const responseRaw = await this.transport.sendMessage(tabId, msg, { frameId });\n    const parsed = parseResolveRefResponse(responseRaw);\n    if (!parsed || !parsed.success) return null;\n    return { ref, center: parsed.center, frameId, resolvedBy: 'ref' };\n  }\n\n  /**\n   * 验证元素是否匹配给定的指纹\n   */\n  private async verifyElementFingerprint(\n    tabId: number,\n    ref: string,\n    fingerprint: string,\n    frameId: number | undefined,\n  ): Promise<boolean> {\n    const msg = {\n      action: TOOL_MESSAGE_TYPES.VERIFY_FINGERPRINT,\n      ref,\n      fingerprint,\n    } satisfies VerifyFingerprintRequest;\n\n    try {\n      const responseRaw = await this.transport.sendMessage(tabId, msg, { frameId });\n      const parsed = parseVerifyFingerprintResponse(responseRaw);\n      if (!parsed || !parsed.success) return false;\n      return parsed.match;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * 定位元素\n   */\n  async locate(\n    tabId: number,\n    target: SelectorTarget,\n    options: SelectorLocateOptions = {},\n  ): Promise<LocatedElement | null> {\n    const frameSelector = deriveFrameSelector(target);\n    const allowMultiple = options.allowMultiple ?? false;\n\n    // 提取指纹验证配置\n    const fingerprintToVerify =\n      options.verifyFingerprint === true && typeof target.fingerprint === 'string'\n        ? target.fingerprint.trim()\n        : undefined;\n\n    // 优先尝试 ref\n    if (options.preferRef && target.ref) {\n      const byRef = await this.resolveRef(tabId, target.ref, options.frameId);\n      if (byRef) return byRef;\n    }\n\n    // 1) Fast path: try target.selector first (assumed CSS / composite CSS)\n    if (typeof target.selector === 'string' && target.selector.trim()) {\n      const sel = target.selector.trim();\n      const ensured = await this.ensureRef(\n        tabId,\n        { action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR, selector: sel, allowMultiple },\n        options.frameId,\n      );\n      if (ensured) {\n        const mappedFrameId = await this.mapHrefToFrameId(tabId, ensured.href);\n        const resolvedFrameId = mappedFrameId ?? options.frameId;\n\n        // 指纹验证：不匹配则跳过，继续尝试其他候选\n        const fingerprintOk =\n          !fingerprintToVerify ||\n          (await this.verifyElementFingerprint(\n            tabId,\n            ensured.ref,\n            fingerprintToVerify,\n            resolvedFrameId,\n          ));\n\n        if (fingerprintOk) {\n          return {\n            ref: ensured.ref,\n            center: ensured.center,\n            frameId: resolvedFrameId,\n            resolvedBy: 'css',\n            selectorUsed: sel,\n          };\n        }\n        // 指纹不匹配，继续尝试候选选择器\n      }\n    }\n\n    // 2) Candidate ordering (stability + weight). Keep text last by type priority.\n    const candidates = [...target.candidates].map(withStability).sort(compareSelectorCandidates);\n\n    for (const candidate of candidates) {\n      const resolved = await this.tryCandidate(\n        tabId,\n        target,\n        candidate,\n        frameSelector,\n        options.frameId,\n        allowMultiple,\n      );\n      if (!resolved) continue;\n\n      // 指纹验证\n      if (fingerprintToVerify) {\n        const isMatch = await this.verifyElementFingerprint(\n          tabId,\n          resolved.ref,\n          fingerprintToVerify,\n          resolved.frameId ?? options.frameId,\n        );\n        if (!isMatch) continue;\n      }\n\n      return resolved;\n    }\n\n    // 3) Ref fallback\n    if (target.ref) {\n      const byRef = await this.resolveRef(tabId, target.ref, options.frameId);\n      if (byRef) return byRef;\n    }\n\n    return null;\n  }\n\n  private async tryCandidate(\n    tabId: number,\n    target: SelectorTarget,\n    candidate: SelectorCandidate,\n    frameSelector: string | undefined,\n    frameId: number | undefined,\n    allowMultiple: boolean,\n  ): Promise<LocatedElement | null> {\n    const tagName = deriveTagNameHint(target, candidate);\n\n    if (candidate.type === 'css' || candidate.type === 'attr') {\n      const selectorToTry =\n        frameSelector && !isCompositeSelector(candidate.value)\n          ? composeCompositeSelector(frameSelector, candidate.value)\n          : candidate.value;\n\n      const ensured = await this.ensureRef(\n        tabId,\n        {\n          action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n          selector: selectorToTry,\n          allowMultiple,\n        },\n        frameId,\n      );\n      if (!ensured) return null;\n\n      const mappedFrameId = await this.mapHrefToFrameId(tabId, ensured.href);\n      return {\n        ref: ensured.ref,\n        center: ensured.center,\n        frameId: mappedFrameId ?? frameId,\n        resolvedBy: candidate.type,\n        selectorUsed: selectorToTry,\n      };\n    }\n\n    if (candidate.type === 'xpath') {\n      const selectorToTry =\n        frameSelector && !isCompositeSelector(candidate.value)\n          ? composeCompositeSelector(frameSelector, candidate.value)\n          : candidate.value;\n\n      const ensured = await this.ensureRef(\n        tabId,\n        {\n          action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n          selector: selectorToTry,\n          isXPath: true,\n          allowMultiple,\n        },\n        frameId,\n      );\n      if (!ensured) return null;\n\n      const mappedFrameId = await this.mapHrefToFrameId(tabId, ensured.href);\n      return {\n        ref: ensured.ref,\n        center: ensured.center,\n        frameId: mappedFrameId ?? frameId,\n        resolvedBy: 'xpath',\n        selectorUsed: selectorToTry,\n      };\n    }\n\n    if (candidate.type === 'aria') {\n      const parsed = parseAriaExpr(candidate.value);\n      const role = candidate.role ?? parsed.role;\n      const name = candidate.name ?? parsed.name;\n      const selectors = ariaToCssSelectors(role, name);\n\n      for (const cssSel of selectors) {\n        const selectorToTry = frameSelector\n          ? composeCompositeSelector(frameSelector, cssSel)\n          : cssSel;\n        const ensured = await this.ensureRef(\n          tabId,\n          {\n            action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n            selector: selectorToTry,\n            allowMultiple,\n          },\n          frameId,\n        );\n        if (!ensured) continue;\n\n        const mappedFrameId = await this.mapHrefToFrameId(tabId, ensured.href);\n        return {\n          ref: ensured.ref,\n          center: ensured.center,\n          frameId: mappedFrameId ?? frameId,\n          resolvedBy: 'aria',\n          selectorUsed: selectorToTry,\n        };\n      }\n      return null;\n    }\n\n    // text\n    const textValue = candidate.value.trim();\n    if (!textValue) return null;\n\n    // NOTE: In composite mode, the helper expects the inner \"selector\" string to carry the text query.\n    const compositeSelector = frameSelector\n      ? composeCompositeSelector(frameSelector, textValue)\n      : undefined;\n\n    const ensured = await this.ensureRef(\n      tabId,\n      {\n        action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,\n        selector: compositeSelector, // for iframe-text: becomes \"<frame> |> <text>\"\n        useText: true,\n        text: frameSelector ? undefined : textValue, // non-iframe: use request.text\n        tagName: tagName ?? '',\n        allowMultiple,\n      },\n      frameId,\n    );\n\n    if (!ensured) return null;\n\n    const mappedFrameId = await this.mapHrefToFrameId(tabId, ensured.href);\n    return {\n      ref: ensured.ref,\n      center: ensured.center,\n      frameId: mappedFrameId ?? frameId,\n      resolvedBy: 'text',\n      selectorUsed: frameSelector ? compositeSelector : textValue,\n    };\n  }\n}\n\n// ================================\n// 工厂函数\n// ================================\n\n/**\n * 创建 Chrome 扩展的传输层\n */\nexport function createChromeSelectorLocatorTransport(): SelectorLocatorTransport {\n  return {\n    sendMessage: async (tabId, message, options) => {\n      if (options && typeof options.frameId === 'number') {\n        return await chrome.tabs.sendMessage(tabId, message, { frameId: options.frameId });\n      }\n      return await chrome.tabs.sendMessage(tabId, message);\n    },\n    getAllFrames: async (tabId) => {\n      const frames = await chrome.webNavigation.getAllFrames({ tabId });\n      return (frames ?? []).map((f) => ({ frameId: f.frameId, url: f.url ?? '' }));\n    },\n  };\n}\n\n/**\n * 创建 Chrome 扩展的选择器定位器\n */\nexport function createChromeSelectorLocator(): SelectorLocator {\n  return new SelectorLocator(createChromeSelectorLocatorTransport());\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/shadow-dom.ts",
    "content": "/**\n * Shadow DOM Utilities - Chain traversal and scoped querying.\n *\n * This module provides utilities for traversing Shadow DOM boundaries\n * and querying elements within shadow roots.\n *\n * Design principles:\n * - This module only handles traversal and querying, NOT selector generation\n * - Selector generation for shadow hosts belongs in generator.ts to avoid circular deps\n * - All operations require unique selector matches for safety\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Possible failure reasons during shadow DOM traversal */\nexport type ShadowTraversalFailureReason =\n  | 'empty_chain'\n  | 'no_root'\n  | 'invalid_selector'\n  | 'no_match'\n  | 'multiple_matches'\n  | 'no_shadow_root';\n\n/**\n * Result of shadow DOM traversal with detailed error information\n */\nexport interface ShadowTraversalResult {\n  success: boolean;\n  shadowRoot: ShadowRoot | null;\n  /** Index of the first failing selector in the chain (-1 if no chain processing occurred) */\n  failedAt: number;\n  /** Reason for failure if not successful */\n  reason?: ShadowTraversalFailureReason;\n}\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\nfunction getDefaultRoot(): Document | null {\n  if (typeof document !== 'undefined') {\n    return document;\n  }\n  return null;\n}\n\nfunction safeQuerySelector(root: Document | ShadowRoot, selector: string): Element | null {\n  try {\n    return root.querySelector(selector);\n  } catch {\n    return null;\n  }\n}\n\nfunction safeQuerySelectorAll(\n  root: Document | ShadowRoot,\n  selector: string,\n): NodeListOf<Element> | null {\n  try {\n    return root.querySelectorAll(selector);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Query result with match count for detailed error reporting\n */\ninterface QueryResult {\n  element: Element | null;\n  matchCount: number;\n  isValid: boolean;\n}\n\nfunction queryWithDetails(root: Document | ShadowRoot, selector: string): QueryResult {\n  const elements = safeQuerySelectorAll(root, selector);\n  if (elements === null) {\n    return { element: null, matchCount: 0, isValid: false };\n  }\n  return {\n    element: elements.length > 0 ? elements[0] : null,\n    matchCount: elements.length,\n    isValid: true,\n  };\n}\n\nfunction isUnique(root: Document | ShadowRoot, selector: string): boolean {\n  const result = queryWithDetails(root, selector);\n  return result.isValid && result.matchCount === 1;\n}\n\n// =============================================================================\n// Core Functions\n// =============================================================================\n\n/**\n * Traverse a Shadow DOM host selector chain and return detailed result.\n *\n * @param hostChain - Shadow host selectors ordered from outermost to innermost\n * @param root - Starting query root (defaults to document)\n * @returns Detailed traversal result with success status and error info\n *\n * @example\n * ```ts\n * const result = traverseShadowDomWithDetails(\n *   ['my-component', 'inner-component'],\n *   document\n * );\n * if (result.success) {\n *   // query within result.shadowRoot\n * }\n * ```\n */\nexport function traverseShadowDomWithDetails(\n  hostChain: ReadonlyArray<string>,\n  root?: Document | ShadowRoot,\n): ShadowTraversalResult {\n  // Empty chain means no shadow boundary\n  if (!Array.isArray(hostChain) || hostChain.length === 0) {\n    return { success: false, shadowRoot: null, failedAt: -1, reason: 'empty_chain' };\n  }\n\n  const initialRoot = root ?? getDefaultRoot();\n  if (!initialRoot) {\n    return { success: false, shadowRoot: null, failedAt: -1, reason: 'no_root' };\n  }\n\n  let queryRoot: Document | ShadowRoot = initialRoot;\n\n  for (let i = 0; i < hostChain.length; i++) {\n    const rawSelector = hostChain[i];\n    const hostSelector = typeof rawSelector === 'string' ? rawSelector.trim() : '';\n\n    if (!hostSelector) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'invalid_selector' };\n    }\n\n    // Use queryWithDetails for precise error reporting\n    const queryResult = queryWithDetails(queryRoot, hostSelector);\n\n    if (!queryResult.isValid) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'invalid_selector' };\n    }\n\n    if (queryResult.matchCount === 0) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'no_match' };\n    }\n\n    if (queryResult.matchCount > 1) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'multiple_matches' };\n    }\n\n    const host = queryResult.element;\n    if (!host) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'no_match' };\n    }\n\n    // Only open shadow roots are accessible via .shadowRoot\n    const shadowRoot = host.shadowRoot;\n    if (!shadowRoot) {\n      return { success: false, shadowRoot: null, failedAt: i, reason: 'no_shadow_root' };\n    }\n\n    queryRoot = shadowRoot;\n  }\n\n  if (queryRoot instanceof ShadowRoot) {\n    return { success: true, shadowRoot: queryRoot, failedAt: -1 };\n  }\n\n  return {\n    success: false,\n    shadowRoot: null,\n    failedAt: hostChain.length - 1,\n    reason: 'no_shadow_root',\n  };\n}\n\n/**\n * Traverse a Shadow DOM host selector chain and return the innermost ShadowRoot.\n *\n * This is the simplified version of traverseShadowDomWithDetails.\n *\n * @param hostChain - Shadow host selectors ordered from outermost to innermost\n * @param root - Starting query root (defaults to document)\n * @returns The innermost ShadowRoot, or null if traversal fails or chain is empty\n *\n * @example\n * ```ts\n * const shadowRoot = traverseShadowDom(['my-component', 'inner-component']);\n * if (shadowRoot) {\n *   const button = shadowRoot.querySelector('button');\n * }\n * ```\n */\nexport function traverseShadowDom(\n  hostChain: ReadonlyArray<string>,\n  root?: Document | ShadowRoot,\n): ShadowRoot | null {\n  const result = traverseShadowDomWithDetails(hostChain, root);\n  return result.shadowRoot;\n}\n\n/**\n * Query an element within the innermost ShadowRoot resolved by a host chain.\n *\n * @param selector - CSS selector to query within the resolved ShadowRoot\n * @param hostChain - Shadow host selectors ordered from outermost to innermost\n * @param root - Starting query root (defaults to document)\n * @returns The first matched element, or null if traversal fails or no match\n *\n * @example\n * ```ts\n * const button = queryInShadowDom(\n *   'button.submit',\n *   ['my-component', 'form-wrapper']\n * );\n * ```\n */\nexport function queryInShadowDom(\n  selector: string,\n  hostChain: ReadonlyArray<string>,\n  root?: Document | ShadowRoot,\n): Element | null {\n  const sel = typeof selector === 'string' ? selector.trim() : '';\n  if (!sel) {\n    return null;\n  }\n\n  const shadowRoot = traverseShadowDom(hostChain, root);\n  if (!shadowRoot) {\n    return null;\n  }\n\n  return safeQuerySelector(shadowRoot, sel);\n}\n\n/**\n * Query all matching elements within the innermost ShadowRoot resolved by a host chain.\n *\n * @param selector - CSS selector to query within the resolved ShadowRoot\n * @param hostChain - Shadow host selectors ordered from outermost to innermost\n * @param root - Starting query root (defaults to document)\n * @returns Array of matched elements, or empty array if traversal fails\n */\nexport function queryAllInShadowDom(\n  selector: string,\n  hostChain: ReadonlyArray<string>,\n  root?: Document | ShadowRoot,\n): Element[] {\n  const sel = typeof selector === 'string' ? selector.trim() : '';\n  if (!sel) {\n    return [];\n  }\n\n  const shadowRoot = traverseShadowDom(hostChain, root);\n  if (!shadowRoot) {\n    return [];\n  }\n\n  const elements = safeQuerySelectorAll(shadowRoot, sel);\n  return elements ? Array.from(elements) : [];\n}\n\n/**\n * Check if a selector uniquely matches an element within the shadow chain.\n *\n * @param selector - CSS selector to check\n * @param hostChain - Shadow host selectors ordered from outermost to innermost\n * @param root - Starting query root (defaults to document)\n * @returns true if selector matches exactly one element\n */\nexport function isUniqueInShadowDom(\n  selector: string,\n  hostChain: ReadonlyArray<string>,\n  root?: Document | ShadowRoot,\n): boolean {\n  const sel = typeof selector === 'string' ? selector.trim() : '';\n  if (!sel) {\n    return false;\n  }\n\n  const shadowRoot = traverseShadowDom(hostChain, root);\n  if (!shadowRoot) {\n    return false;\n  }\n\n  return isUnique(shadowRoot, sel);\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/stability.ts",
    "content": "/**\n * Selector Stability - 选择器稳定性评估\n */\n\nimport type {\n  SelectorCandidate,\n  SelectorStability,\n  SelectorStabilitySignals,\n  SelectorType,\n} from './types';\nimport { splitCompositeSelector } from './types';\n\nconst TESTID_ATTR_NAMES = [\n  'data-testid',\n  'data-test-id',\n  'data-test',\n  'data-qa',\n  'data-cy',\n] as const;\n\nfunction clamp01(n: number): number {\n  if (!Number.isFinite(n)) return 0;\n  return Math.min(1, Math.max(0, n));\n}\n\nfunction mergeSignals(\n  a: SelectorStabilitySignals,\n  b: SelectorStabilitySignals,\n): SelectorStabilitySignals {\n  return {\n    usesId: a.usesId || b.usesId || undefined,\n    usesTestId: a.usesTestId || b.usesTestId || undefined,\n    usesAria: a.usesAria || b.usesAria || undefined,\n    usesText: a.usesText || b.usesText || undefined,\n    usesNthOfType: a.usesNthOfType || b.usesNthOfType || undefined,\n    usesAttributes: a.usesAttributes || b.usesAttributes || undefined,\n    usesClass: a.usesClass || b.usesClass || undefined,\n  };\n}\n\nfunction analyzeCssLike(selector: string): SelectorStabilitySignals {\n  const s = String(selector || '');\n  const usesNthOfType = /:nth-of-type\\(/i.test(s);\n  const usesAttributes = /\\[[^\\]]+\\]/.test(s);\n  const usesAria = /\\[\\s*aria-[^=]+\\s*=|\\[\\s*role\\s*=|\\brole\\s*=\\s*/i.test(s);\n\n  // Avoid counting `#` inside attribute values (e.g. href=\"#...\") by requiring a token-ish pattern.\n  const usesId = /(^|[\\s>+~])#[^\\s>+~.:#[]+/.test(s);\n  const usesClass = /(^|[\\s>+~])\\.[^\\s>+~.:#[]+/.test(s);\n\n  const lower = s.toLowerCase();\n  const usesTestId = TESTID_ATTR_NAMES.some((a) => lower.includes(`[${a}`));\n\n  return {\n    usesId: usesId || undefined,\n    usesTestId: usesTestId || undefined,\n    usesAria: usesAria || undefined,\n    usesNthOfType: usesNthOfType || undefined,\n    usesAttributes: usesAttributes || undefined,\n    usesClass: usesClass || undefined,\n  };\n}\n\nfunction baseScoreForCssSignals(signals: SelectorStabilitySignals): number {\n  if (signals.usesTestId) return 0.95;\n  if (signals.usesId) return 0.9;\n  if (signals.usesAria) return 0.8;\n  if (signals.usesAttributes) return 0.75;\n  if (signals.usesClass) return 0.65;\n  return 0.5;\n}\n\nfunction lengthPenalty(value: string): number {\n  const len = value.length;\n  if (len <= 60) return 0;\n  if (len <= 120) return 0.05;\n  if (len <= 200) return 0.1;\n  return 0.18;\n}\n\n/**\n * 计算选择器稳定性评分\n */\nexport function computeSelectorStability(candidate: SelectorCandidate): SelectorStability {\n  if (candidate.type === 'css' || candidate.type === 'attr') {\n    const composite = splitCompositeSelector(candidate.value);\n    if (composite) {\n      const a = analyzeCssLike(composite.frameSelector);\n      const b = analyzeCssLike(composite.innerSelector);\n      const merged = mergeSignals(a, b);\n\n      let score = baseScoreForCssSignals(merged);\n      score -= 0.05; // iframe coupling penalty\n      if (merged.usesNthOfType) score -= 0.2;\n      score -= lengthPenalty(candidate.value);\n\n      return { score: clamp01(score), signals: merged, note: 'composite' };\n    }\n\n    const signals = analyzeCssLike(candidate.value);\n    let score = baseScoreForCssSignals(signals);\n    if (signals.usesNthOfType) score -= 0.2;\n    score -= lengthPenalty(candidate.value);\n\n    return { score: clamp01(score), signals };\n  }\n\n  if (candidate.type === 'xpath') {\n    const s = String(candidate.value || '');\n    const signals: SelectorStabilitySignals = {\n      usesAttributes: /@[\\w-]+\\s*=/.test(s) || undefined,\n      usesId: /@id\\s*=/.test(s) || undefined,\n      usesTestId: /@data-testid\\s*=/.test(s) || undefined,\n    };\n\n    let score = 0.42;\n    if (signals.usesTestId) score = 0.85;\n    else if (signals.usesId) score = 0.75;\n    else if (signals.usesAttributes) score = 0.55;\n\n    score -= lengthPenalty(s);\n    return { score: clamp01(score), signals };\n  }\n\n  if (candidate.type === 'aria') {\n    const hasName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;\n    const hasRole = typeof candidate.role === 'string' && candidate.role.trim().length > 0;\n\n    const signals: SelectorStabilitySignals = { usesAria: true };\n    let score = hasName && hasRole ? 0.8 : hasName ? 0.72 : 0.6;\n    score -= lengthPenalty(candidate.value);\n\n    return { score: clamp01(score), signals };\n  }\n\n  // text\n  const text = String(candidate.value || '').trim();\n  const signals: SelectorStabilitySignals = { usesText: true };\n  let score = 0.35;\n\n  // Very short texts tend to be ambiguous; very long texts are unstable.\n  if (text.length >= 6 && text.length <= 48) score = 0.45;\n  if (text.length > 80) score = 0.3;\n\n  return { score: clamp01(score), signals };\n}\n\n/**\n * 为选择器候选添加稳定性评分\n */\nexport function withStability(candidate: SelectorCandidate): SelectorCandidate {\n  if (candidate.stability) return candidate;\n  return { ...candidate, stability: computeSelectorStability(candidate) };\n}\n\nfunction typePriority(type: SelectorType): number {\n  switch (type) {\n    case 'attr':\n      return 5;\n    case 'css':\n      return 4;\n    case 'aria':\n      return 3;\n    case 'xpath':\n      return 2;\n    case 'text':\n      return 1;\n    default:\n      return 0;\n  }\n}\n\n/**\n * 比较两个选择器候选的优先级\n * 返回负数表示 a 优先，正数表示 b 优先\n */\nexport function compareSelectorCandidates(a: SelectorCandidate, b: SelectorCandidate): number {\n  // 1. 用户指定的权重优先\n  const aw = a.weight ?? 0;\n  const bw = b.weight ?? 0;\n  if (aw !== bw) return bw - aw;\n\n  // 2. 稳定性评分\n  const as = a.stability?.score ?? computeSelectorStability(a).score;\n  const bs = b.stability?.score ?? computeSelectorStability(b).score;\n  if (as !== bs) return bs - as;\n\n  // 3. 类型优先级\n  const ap = typePriority(a.type);\n  const bp = typePriority(b.type);\n  if (ap !== bp) return bp - ap;\n\n  // 4. 长度（越短越好）\n  const alen = String(a.value || '').length;\n  const blen = String(b.value || '').length;\n  return alen - blen;\n}\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/anchor-relpath.ts",
    "content": "/**\n * Anchor + Relative Path Strategy\n *\n * This strategy generates selectors by finding a stable ancestor \"anchor\"\n * (element with unique id or data-testid/data-qa/etc.) and building a\n * relative path from that anchor to the target element.\n *\n * Use case: When the target element itself has no unique identifiers,\n * but a nearby ancestor does.\n *\n * Example output: '[data-testid=\"card\"] div > span:nth-of-type(2) > button'\n * (anchor selector + descendant combinator + relative path with child combinators)\n */\n\nimport type { SelectorCandidate, SelectorStrategy, SelectorStrategyContext } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Maximum ancestor depth to search for an anchor */\nconst MAX_ANCHOR_DEPTH = 20;\n\n/** Data attributes eligible for anchor selection (stable, test-friendly) */\nconst ANCHOR_DATA_ATTRS = [\n  'data-testid',\n  'data-test-id',\n  'data-test',\n  'data-qa',\n  'data-cy',\n] as const;\n\n/**\n * Weight penalty for anchor-relpath candidates.\n * This ensures they rank lower than direct selectors (id, testid, class)\n * but higher than pure text selectors.\n */\nconst ANCHOR_RELPATH_WEIGHT = -10;\n\n// =============================================================================\n// Internal Helpers\n// =============================================================================\n\nfunction safeQuerySelector(root: ParentNode, selector: string): Element | null {\n  try {\n    return root.querySelector(selector);\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Get siblings from the appropriate parent context\n */\nfunction getSiblings(element: Element): Element[] {\n  const parent = element.parentElement;\n  if (parent) {\n    return Array.from(parent.children);\n  }\n\n  const parentNode = element.parentNode;\n  if (parentNode instanceof ShadowRoot || parentNode instanceof Document) {\n    return Array.from(parentNode.children);\n  }\n\n  return [];\n}\n\n/**\n * Try to build a unique anchor selector for an element.\n * Only uses stable identifiers: id or ANCHOR_DATA_ATTRS.\n */\nfunction tryAnchorSelector(element: Element, ctx: SelectorStrategyContext): string | null {\n  const { helpers } = ctx;\n  const tag = element.tagName.toLowerCase();\n\n  // Try ID first (highest priority)\n  const id = element.id?.trim();\n  if (id) {\n    const idSelector = `#${helpers.cssEscape(id)}`;\n    if (helpers.isUnique(idSelector)) {\n      return idSelector;\n    }\n  }\n\n  // Try stable data attributes\n  for (const attr of ANCHOR_DATA_ATTRS) {\n    const value = element.getAttribute(attr)?.trim();\n    if (!value) continue;\n\n    const escaped = helpers.cssEscape(value);\n\n    // Try attribute-only selector\n    const attrOnly = `[${attr}=\"${escaped}\"]`;\n    if (helpers.isUnique(attrOnly)) {\n      return attrOnly;\n    }\n\n    // Try with tag prefix for disambiguation\n    const withTag = `${tag}${attrOnly}`;\n    if (helpers.isUnique(withTag)) {\n      return withTag;\n    }\n  }\n\n  return null;\n}\n\n/**\n * Build a relative path selector from an ancestor to a target element.\n * Uses tag names with :nth-of-type() for disambiguation.\n *\n * @returns Selector string like \"div > span:nth-of-type(2) > button\", or null if failed\n */\nfunction buildRelativePathSelector(\n  ancestor: Element,\n  target: Element,\n  root: Document | ShadowRoot,\n): string | null {\n  const segments: string[] = [];\n  let current: Element | null = target;\n\n  for (let depth = 0; current && current !== ancestor && depth < MAX_ANCHOR_DEPTH; depth++) {\n    const tag = current.tagName.toLowerCase();\n    let segment = tag;\n\n    // Calculate nth-of-type index if there are siblings with same tag\n    const siblings = getSiblings(current);\n    const sameTagSiblings = siblings.filter((s) => s.tagName === current!.tagName);\n\n    if (sameTagSiblings.length > 1) {\n      const index = sameTagSiblings.indexOf(current) + 1;\n      segment += `:nth-of-type(${index})`;\n    }\n\n    segments.unshift(segment);\n\n    // Move to parent\n    const parentEl: Element | null = current.parentElement;\n    if (!parentEl) {\n      // Check if we've reached the root boundary\n      const parentNode = current.parentNode;\n      if (parentNode === root) break;\n      break;\n    }\n\n    current = parentEl;\n  }\n\n  // Verify we reached the ancestor\n  if (current !== ancestor) {\n    return null;\n  }\n\n  return segments.length > 0 ? segments.join(' > ') : null;\n}\n\n/**\n * Build an \"anchor + relative path\" selector for an element.\n *\n * Algorithm:\n * 1. Walk up from target's parent, looking for an anchor\n * 2. For each potential anchor, build the relative path\n * 3. Verify the composed selector uniquely matches the target\n */\nfunction buildAnchorRelPathSelector(element: Element, ctx: SelectorStrategyContext): string | null {\n  const { root } = ctx;\n\n  // Ensure root is a valid query context\n  if (!(root instanceof Document || root instanceof ShadowRoot)) {\n    return null;\n  }\n\n  let current: Element | null = element.parentElement;\n\n  for (let depth = 0; current && depth < MAX_ANCHOR_DEPTH; depth++) {\n    // Skip document root elements\n    const tagUpper = current.tagName.toUpperCase();\n    if (tagUpper === 'HTML' || tagUpper === 'BODY') {\n      break;\n    }\n\n    // Try to use this element as an anchor\n    const anchor = tryAnchorSelector(current, ctx);\n    if (!anchor) {\n      current = current.parentElement;\n      continue;\n    }\n\n    // Build relative path from anchor to target\n    const relativePath = buildRelativePathSelector(current, element, root);\n    if (!relativePath) {\n      current = current.parentElement;\n      continue;\n    }\n\n    // Compose the full selector\n    const composed = `${anchor} ${relativePath}`;\n\n    // Verify uniqueness\n    if (!ctx.helpers.isUnique(composed)) {\n      current = current.parentElement;\n      continue;\n    }\n\n    // Final verification: ensure we match the exact element\n    const found = safeQuerySelector(root, composed);\n    if (found === element) {\n      return composed;\n    }\n\n    current = current.parentElement;\n  }\n\n  return null;\n}\n\n// =============================================================================\n// Strategy Export\n// =============================================================================\n\nexport const anchorRelpathStrategy: SelectorStrategy = {\n  id: 'anchor-relpath',\n\n  generate(ctx: SelectorStrategyContext): ReadonlyArray<SelectorCandidate> {\n    const selector = buildAnchorRelPathSelector(ctx.element, ctx);\n\n    if (!selector) {\n      return [];\n    }\n\n    return [\n      {\n        type: 'css',\n        value: selector,\n        weight: ANCHOR_RELPATH_WEIGHT,\n        source: 'generated',\n        strategy: 'anchor-relpath',\n      },\n    ];\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/aria.ts",
    "content": "/**\n * ARIA Strategy - 基于无障碍属性的选择器策略\n * 使用 aria-label, role 等属性生成选择器\n */\n\nimport type { SelectorCandidate, SelectorStrategy } from '../types';\n\nfunction guessRoleByTag(tag: string): string | undefined {\n  if (tag === 'input' || tag === 'textarea') return 'textbox';\n  if (tag === 'button') return 'button';\n  if (tag === 'a') return 'link';\n  return undefined;\n}\n\nfunction uniqStrings(items: ReadonlyArray<string>): string[] {\n  const seen = new Set<string>();\n  const out: string[] = [];\n  for (const s of items) {\n    const v = s.trim();\n    if (!v) continue;\n    if (seen.has(v)) continue;\n    seen.add(v);\n    out.push(v);\n  }\n  return out;\n}\n\nexport const ariaStrategy: SelectorStrategy = {\n  id: 'aria',\n  generate(ctx) {\n    if (!ctx.options.includeAria) return [];\n\n    const { element, helpers } = ctx;\n    const out: SelectorCandidate[] = [];\n\n    const name = element.getAttribute('aria-label')?.trim();\n    if (!name) return out;\n\n    const tag = element.tagName?.toLowerCase?.() ?? '';\n    const role = element.getAttribute('role')?.trim() || guessRoleByTag(tag);\n\n    const qName = JSON.stringify(name);\n    const selectors: string[] = [];\n\n    if (role) selectors.push(`[role=${JSON.stringify(role)}][aria-label=${qName}]`);\n    selectors.push(`[aria-label=${qName}]`);\n\n    if (role === 'textbox') {\n      selectors.unshift(\n        `input[aria-label=${qName}]`,\n        `textarea[aria-label=${qName}]`,\n        `[role=\"textbox\"][aria-label=${qName}]`,\n      );\n    } else if (role === 'button') {\n      selectors.unshift(`button[aria-label=${qName}]`, `[role=\"button\"][aria-label=${qName}]`);\n    } else if (role === 'link') {\n      selectors.unshift(`a[aria-label=${qName}]`, `[role=\"link\"][aria-label=${qName}]`);\n    }\n\n    for (const sel of uniqStrings(selectors)) {\n      if (helpers.isUnique(sel)) {\n        out.push({ type: 'attr', value: sel, source: 'generated', strategy: 'aria' });\n      }\n    }\n\n    // Structured aria candidate for UI/debugging (locator can translate it too).\n    out.push({\n      type: 'aria',\n      value: `${role ?? 'element'}[name=${JSON.stringify(name)}]`,\n      role,\n      name,\n      source: 'generated',\n      strategy: 'aria',\n    });\n\n    return out;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/css-path.ts",
    "content": "/**\n * CSS Path Strategy - 基于 DOM 路径的选择器策略\n * 使用 nth-of-type 生成完整的 CSS 路径\n */\n\nimport type { SelectorCandidate, SelectorStrategy } from '../types';\n\nexport const cssPathStrategy: SelectorStrategy = {\n  id: 'css-path',\n  generate(ctx) {\n    if (!ctx.options.includeCssPath) return [];\n\n    const { element } = ctx;\n\n    const segments: string[] = [];\n    let current: Element | null = element;\n\n    while (current) {\n      const tag = current.tagName?.toLowerCase?.() ?? '';\n      if (!tag) break;\n\n      let segment = tag;\n\n      const parent = current.parentElement;\n      if (parent) {\n        const siblings = Array.from(parent.children).filter((c) => c.tagName === current!.tagName);\n        if (siblings.length > 1) {\n          const index = siblings.indexOf(current) + 1;\n          if (index > 0) segment += `:nth-of-type(${index})`;\n        }\n      }\n\n      segments.unshift(segment);\n\n      if (tag === 'body') break;\n      current = parent;\n    }\n\n    const selector = segments.length ? segments.join(' > ') : 'body';\n\n    const out: SelectorCandidate[] = [\n      { type: 'css', value: selector, source: 'generated', strategy: 'css-path' },\n    ];\n    return out;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/css-unique.ts",
    "content": "/**\n * CSS Unique Strategy - 基于唯一 ID 或 class 组合的选择器策略\n */\n\nimport type { SelectorCandidate, SelectorStrategy } from '../types';\n\nconst MAX_CLASS_COUNT = 24;\nconst MAX_COMBO_CLASSES = 8;\nconst MAX_CANDIDATES = 6;\n\nfunction isValidClassToken(token: string): boolean {\n  return /^[a-zA-Z0-9_-]+$/.test(token);\n}\n\nexport const cssUniqueStrategy: SelectorStrategy = {\n  id: 'css-unique',\n  generate(ctx) {\n    if (!ctx.options.includeCssUnique) return [];\n\n    const { element, helpers } = ctx;\n    const out: SelectorCandidate[] = [];\n\n    const tag = element.tagName?.toLowerCase?.() ?? '';\n\n    // 1) Unique ID selector\n    const id = element.id?.trim();\n    if (id) {\n      const sel = `#${helpers.cssEscape(id)}`;\n      if (helpers.isUnique(sel)) {\n        out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });\n      }\n    }\n\n    if (out.length >= MAX_CANDIDATES) return out;\n\n    // 2) Unique class selectors\n    const classList = Array.from(element.classList || [])\n      .map((c) => String(c).trim())\n      .filter((c) => c.length > 0 && isValidClassToken(c))\n      .slice(0, MAX_CLASS_COUNT);\n\n    for (const cls of classList) {\n      if (out.length >= MAX_CANDIDATES) break;\n      const sel = `.${helpers.cssEscape(cls)}`;\n      if (helpers.isUnique(sel)) {\n        out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });\n      }\n    }\n\n    if (tag) {\n      for (const cls of classList) {\n        if (out.length >= MAX_CANDIDATES) break;\n        const sel = `${tag}.${helpers.cssEscape(cls)}`;\n        if (helpers.isUnique(sel)) {\n          out.push({ type: 'css', value: sel, source: 'generated', strategy: 'css-unique' });\n        }\n      }\n    }\n\n    if (out.length >= MAX_CANDIDATES) return out;\n\n    // 3) Class combinations (depth 2/3), limited to avoid heavy queries.\n    const comboSource = classList.slice(0, MAX_COMBO_CLASSES).map((c) => helpers.cssEscape(c));\n\n    const tryPush = (selector: string): void => {\n      if (out.length >= MAX_CANDIDATES) return;\n      if (!helpers.isUnique(selector)) return;\n      out.push({ type: 'css', value: selector, source: 'generated', strategy: 'css-unique' });\n    };\n\n    const tryPushWithTag = (selector: string): void => {\n      tryPush(selector);\n      if (tag) tryPush(`${tag}${selector}`);\n    };\n\n    // Depth 2\n    for (let i = 0; i < comboSource.length && out.length < MAX_CANDIDATES; i++) {\n      for (let j = i + 1; j < comboSource.length && out.length < MAX_CANDIDATES; j++) {\n        tryPushWithTag(`.${comboSource[i]}.${comboSource[j]}`);\n      }\n    }\n\n    // Depth 3\n    for (let i = 0; i < comboSource.length && out.length < MAX_CANDIDATES; i++) {\n      for (let j = i + 1; j < comboSource.length && out.length < MAX_CANDIDATES; j++) {\n        for (let k = j + 1; k < comboSource.length && out.length < MAX_CANDIDATES; k++) {\n          tryPushWithTag(`.${comboSource[i]}.${comboSource[j]}.${comboSource[k]}`);\n        }\n      }\n    }\n\n    return out;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/index.ts",
    "content": "/**\n * Selector Strategies - Strategy exports and default configuration\n */\n\nimport type { SelectorStrategy } from '../types';\nimport { anchorRelpathStrategy } from './anchor-relpath';\nimport { ariaStrategy } from './aria';\nimport { cssPathStrategy } from './css-path';\nimport { cssUniqueStrategy } from './css-unique';\nimport { testIdStrategy } from './testid';\nimport { textStrategy } from './text';\n\n/**\n * Default selector strategy list (ordered by priority).\n *\n * Strategy order:\n * 1. testid - Stable test attributes (data-testid, name, title, alt)\n * 2. aria - Accessibility attributes (aria-label, role)\n * 3. css-unique - Unique CSS selectors (id, class combinations)\n * 4. css-path - Structural path selector (nth-of-type)\n * 5. anchor-relpath - Anchor + relative path (fallback for elements without unique attrs)\n * 6. text - Text content selector (lowest priority)\n *\n * Note: Final candidate order is determined by stability scoring,\n * but strategy order affects which candidates are generated first.\n */\nexport const DEFAULT_SELECTOR_STRATEGIES: ReadonlyArray<SelectorStrategy> = [\n  testIdStrategy,\n  ariaStrategy,\n  cssUniqueStrategy,\n  cssPathStrategy,\n  anchorRelpathStrategy,\n  textStrategy,\n];\n\nexport { anchorRelpathStrategy } from './anchor-relpath';\nexport { ariaStrategy } from './aria';\nexport { cssPathStrategy } from './css-path';\nexport { cssUniqueStrategy } from './css-unique';\nexport { testIdStrategy } from './testid';\nexport { textStrategy } from './text';\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/testid.ts",
    "content": "/**\n * TestID Strategy - Attribute-based selector strategy\n *\n * Generates selectors based on stable attributes like data-testid, data-cy,\n * as well as semantic attributes like name, title, and alt.\n */\n\nimport type { SelectorCandidate, SelectorStrategy } from '../types';\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Tags that commonly use form-related attributes */\nconst FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'button']);\n\n/** Tags that commonly use the 'alt' attribute */\nconst ALT_ATTRIBUTE_TAGS = new Set(['img', 'area']);\n\n/** Tags that commonly use the 'title' attribute (most elements can have it) */\nconst TITLE_ATTRIBUTE_TAGS = new Set(['img', 'a', 'abbr', 'iframe', 'link']);\n\n/**\n * Mapping of attributes to their preferred tag prefixes.\n * When an attribute-only selector is not unique, we try tag-prefixed form\n * only for elements where that attribute is semantically meaningful.\n */\nconst ATTR_TAG_PREFERENCES: Record<string, Set<string>> = {\n  name: FORM_ELEMENT_TAGS,\n  alt: ALT_ATTRIBUTE_TAGS,\n  title: TITLE_ATTRIBUTE_TAGS,\n};\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction makeAttrSelector(attr: string, value: string, cssEscape: (v: string) => string): string {\n  return `[${attr}=\"${cssEscape(value)}\"]`;\n}\n\n/**\n * Determine if tag prefix should be tried for disambiguation.\n *\n * Rules:\n * - data-* attributes: try for form elements only\n * - name: try for form elements (input, textarea, select, button)\n * - alt: try for img, area, input[type=image]\n * - title: try for common elements that use title semantically\n * - Default: try for any tag\n */\nfunction shouldTryTagPrefix(attr: string, tag: string, element: Element): boolean {\n  if (!tag) return false;\n\n  // For data-* test attributes, use form element heuristic\n  if (attr.startsWith('data-')) {\n    return FORM_ELEMENT_TAGS.has(tag);\n  }\n\n  // For semantic attributes, check the preference mapping\n  const preferredTags = ATTR_TAG_PREFERENCES[attr];\n  if (preferredTags) {\n    if (preferredTags.has(tag)) return true;\n\n    // Special case: input[type=image] also uses alt\n    if (attr === 'alt' && tag === 'input') {\n      const type = element.getAttribute('type');\n      return type === 'image';\n    }\n\n    return false;\n  }\n\n  // Default: try tag prefix for any element\n  return true;\n}\n\n// =============================================================================\n// Strategy Export\n// =============================================================================\n\nexport const testIdStrategy: SelectorStrategy = {\n  id: 'testid',\n\n  generate(ctx) {\n    const { element, options, helpers } = ctx;\n    const out: SelectorCandidate[] = [];\n    const tag = element.tagName?.toLowerCase?.() ?? '';\n\n    for (const attr of options.testIdAttributes) {\n      const raw = element.getAttribute(attr);\n      const value = raw?.trim();\n      if (!value) continue;\n\n      const attrOnly = makeAttrSelector(attr, value, helpers.cssEscape);\n\n      // Try attribute-only selector first\n      if (helpers.isUnique(attrOnly)) {\n        out.push({\n          type: 'attr',\n          value: attrOnly,\n          source: 'generated',\n          strategy: 'testid',\n        });\n        continue;\n      }\n\n      // Try tag-prefixed form if appropriate for this attribute/element combo\n      if (shouldTryTagPrefix(attr, tag, element)) {\n        const withTag = `${tag}${attrOnly}`;\n        if (helpers.isUnique(withTag)) {\n          out.push({\n            type: 'attr',\n            value: withTag,\n            source: 'generated',\n            strategy: 'testid',\n          });\n        }\n      }\n    }\n\n    return out;\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/strategies/text.ts",
    "content": "/**\n * Text Strategy - Text content based selector strategy\n *\n * This is the lowest priority fallback strategy. Text selectors are less\n * stable than attribute-based or structural selectors because text content\n * is more likely to change (i18n, dynamic content, etc.).\n */\n\nimport type { SelectorCandidate, SelectorStrategy } from '../types';\n\n/**\n * Weight penalty for text selectors.\n * This ensures text selectors rank after all other strategies including anchor-relpath.\n * anchor-relpath uses -10, so text uses -20 to rank lower.\n */\nconst TEXT_STRATEGY_WEIGHT = -20;\n\nfunction normalizeText(value: string): string {\n  return String(value || '')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nexport const textStrategy: SelectorStrategy = {\n  id: 'text',\n\n  generate(ctx) {\n    if (!ctx.options.includeText) return [];\n\n    const { element, options } = ctx;\n    const tag = element.tagName?.toLowerCase?.() ?? '';\n    if (!tag || !options.textTags.includes(tag)) return [];\n\n    const raw = element.textContent || '';\n    const text = normalizeText(raw).slice(0, options.textMaxLength);\n    if (!text) return [];\n\n    return [\n      {\n        type: 'text',\n        value: text,\n        match: 'contains',\n        tagNameHint: tag,\n        weight: TEXT_STRATEGY_WEIGHT,\n        source: 'generated',\n        strategy: 'text',\n      },\n    ];\n  },\n};\n"
  },
  {
    "path": "app/chrome-extension/shared/selector/types.ts",
    "content": "/**\n * Shared selector engine types.\n *\n * Goals:\n * - JSON-serializable (store in flows / send across message boundary)\n * - Reusable from both content scripts and background\n *\n * Composite selector format:\n *   \"<frameSelector> |> <innerSelector>\"\n * This is kept for backward compatibility with the existing recorder and\n * accessibility-tree helper.\n */\n\nexport type NonEmptyArray<T> = [T, ...T[]];\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport type SelectorType = 'css' | 'xpath' | 'attr' | 'aria' | 'text';\nexport type SelectorCandidateSource = 'recorded' | 'user' | 'generated';\n\nexport interface SelectorStabilitySignals {\n  usesId?: boolean;\n  usesTestId?: boolean;\n  usesAria?: boolean;\n  usesText?: boolean;\n  usesNthOfType?: boolean;\n  usesAttributes?: boolean;\n  usesClass?: boolean;\n}\n\nexport interface SelectorStability {\n  /** Stability score in range [0, 1]. Higher is more stable. */\n  score: number;\n  signals?: SelectorStabilitySignals;\n  note?: string;\n}\n\nexport interface SelectorCandidateBase {\n  type: SelectorType;\n  /**\n   * Primary representation:\n   * - css/attr: CSS selector string\n   * - xpath: XPath expression string\n   * - text: visible text query string\n   * - aria: human-readable expression for debugging/UI\n   */\n  value: string;\n  /** Optional user-adjustable priority. Higher wins when ordering candidates. */\n  weight?: number;\n  /** Where this candidate came from. */\n  source?: SelectorCandidateSource;\n  /** Strategy identifier that produced this candidate. */\n  strategy?: string;\n  /** Optional computed stability. */\n  stability?: SelectorStability;\n}\n\nexport type TextMatchMode = 'exact' | 'contains';\n\nexport type SelectorCandidate =\n  | (SelectorCandidateBase & { type: 'css' | 'attr' })\n  | (SelectorCandidateBase & { type: 'xpath' })\n  | (SelectorCandidateBase & { type: 'text'; match?: TextMatchMode; tagNameHint?: string })\n  | (SelectorCandidateBase & { type: 'aria'; role?: string; name?: string });\n\nexport interface SelectorTarget {\n  /**\n   * Optional primary selector string.\n   * This is the fast path for locating (usually CSS). May be composite.\n   */\n  selector?: string;\n  /** Ordered candidates; must be non-empty. */\n  candidates: NonEmptyArray<SelectorCandidate>;\n  /** Optional tag name hint used for text search. */\n  tagName?: string;\n  /** Optional ephemeral element ref, when available. */\n  ref?: string;\n\n  // --------------------------------\n  // Extended Locator Metadata (Phase 1.2)\n  // --------------------------------\n  // These fields are generated and carried across message/storage boundaries,\n  // but the background-side SelectorLocator may not fully use them until\n  // Phase 2 wires the DOM-side protocol (fingerprint verification, shadow traversal).\n\n  /**\n   * Structural fingerprint for fuzzy element matching.\n   * Format: \"tag|id=xxx|class=a.b.c|text=xxx\"\n   */\n  fingerprint?: string;\n\n  /**\n   * Child-index path relative to the current root (Document/ShadowRoot).\n   * Used for fast element recovery when selectors fail.\n   */\n  domPath?: number[];\n\n  /**\n   * Shadow host selector chain (outer -> inner).\n   * When present, selectors/domPath are relative to the innermost ShadowRoot.\n   */\n  shadowHostChain?: string[];\n}\n\n/**\n * SelectorTarget with required extended locator metadata.\n *\n * Use this type when all extended fields must be present (e.g., for reliable\n * cross-session persistence or HMR recovery).\n *\n * Note: Phase 1.2 only guarantees generation/transport; behavioral enforcement\n * (fingerprint verification, shadow traversal) depends on Phase 2 integration.\n */\nexport interface ExtendedSelectorTarget extends SelectorTarget {\n  fingerprint: string;\n  domPath: number[];\n  /** May be empty array if element is not inside Shadow DOM */\n  shadowHostChain: string[];\n}\n\nexport interface LocatedElement {\n  ref: string;\n  center: Point;\n  /** Resolved frameId in the tab (when inside an iframe). */\n  frameId?: number;\n  resolvedBy: 'ref' | SelectorType;\n  selectorUsed?: string;\n}\n\nexport interface SelectorLocateOptions {\n  /** Frame context for non-composite selectors (default: top frame). */\n  frameId?: number;\n  /** Whether to try resolving `target.ref` before selectors. */\n  preferRef?: boolean;\n  /** Forwarded to helper uniqueness checks. */\n  allowMultiple?: boolean;\n  /**\n   * Whether to verify target.fingerprint when available.\n   *\n   * Note: Phase 1.2 exposes this option but may not fully enforce it until\n   * the DOM-side protocol is wired (Phase 2).\n   */\n  verifyFingerprint?: boolean;\n}\n\n// ================================\n// Composite Selector Utilities\n// ================================\n\nexport const COMPOSITE_SELECTOR_SEPARATOR = '|>' as const;\n\nexport interface CompositeSelectorParts {\n  frameSelector: string;\n  innerSelector: string;\n}\n\nexport function splitCompositeSelector(selector: string): CompositeSelectorParts | null {\n  if (typeof selector !== 'string') return null;\n\n  const parts = selector\n    .split(COMPOSITE_SELECTOR_SEPARATOR)\n    .map((s) => s.trim())\n    .filter(Boolean);\n\n  if (parts.length < 2) return null;\n\n  return {\n    frameSelector: parts[0],\n    innerSelector: parts.slice(1).join(` ${COMPOSITE_SELECTOR_SEPARATOR} `),\n  };\n}\n\nexport function isCompositeSelector(selector: string): boolean {\n  return splitCompositeSelector(selector) !== null;\n}\n\nexport function composeCompositeSelector(frameSelector: string, innerSelector: string): string {\n  return `${String(frameSelector).trim()} ${COMPOSITE_SELECTOR_SEPARATOR} ${String(innerSelector).trim()}`.trim();\n}\n\n// ================================\n// Strategy Pattern Types\n// ================================\n\nexport interface NormalizedSelectorGenerationOptions {\n  maxCandidates: number;\n  includeText: boolean;\n  includeAria: boolean;\n  includeCssUnique: boolean;\n  includeCssPath: boolean;\n  testIdAttributes: ReadonlyArray<string>;\n  textMaxLength: number;\n  textTags: ReadonlyArray<string>;\n}\n\nexport interface SelectorGenerationOptions {\n  maxCandidates?: number;\n  includeText?: boolean;\n  includeAria?: boolean;\n  includeCssUnique?: boolean;\n  includeCssPath?: boolean;\n  testIdAttributes?: ReadonlyArray<string>;\n  textMaxLength?: number;\n  textTags?: ReadonlyArray<string>;\n}\n\nexport interface SelectorStrategyHelpers {\n  cssEscape: (value: string) => string;\n  isUnique: (selector: string) => boolean;\n  safeQueryAll: (selector: string) => ReadonlyArray<Element>;\n}\n\nexport interface SelectorStrategyContext {\n  element: Element;\n  root: ParentNode;\n  options: NormalizedSelectorGenerationOptions;\n  helpers: SelectorStrategyHelpers;\n}\n\nexport interface SelectorStrategy {\n  /** Stable id used for debugging/analytics. */\n  id: string;\n  generate: (ctx: SelectorStrategyContext) => ReadonlyArray<SelectorCandidate>;\n}\n"
  },
  {
    "path": "app/chrome-extension/tailwind.config.ts",
    "content": "import type { Config } from 'tailwindcss';\n\n// Tailwind v4 config (TypeScript). The Vite plugin `@tailwindcss/vite`\n// will auto-detect and load this file. No `content` field is required in v4.\nexport default {\n  theme: {\n    extend: {\n      colors: {\n        brand: {\n          DEFAULT: '#7C3AED',\n          dark: '#5B21B6',\n          light: '#A78BFA',\n        },\n      },\n      boxShadow: {\n        card: '0 6px 20px rgba(0,0,0,0.08)',\n      },\n      borderRadius: {\n        xl: '12px',\n      },\n    },\n  },\n  plugins: [],\n} satisfies Config;\n"
  },
  {
    "path": "app/chrome-extension/tests/__mocks__/hnswlib-wasm-static.ts",
    "content": "/**\n * @fileoverview Mock for hnswlib-wasm-static\n * @description Provides a stub for vector database in test environment\n */\n\nexport const HierarchicalNSW = class MockHierarchicalNSW {\n  constructor() {}\n  initIndex() {}\n  addPoint() {}\n  searchKnn() {\n    return { neighbors: [], distances: [] };\n  }\n  getCurrentCount() {\n    return 0;\n  }\n  resizeIndex() {}\n  getPoint() {\n    return [];\n  }\n  markDelete() {}\n};\n\nexport default { HierarchicalNSW };\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/_test-helpers.ts",
    "content": "/**\n * Test helpers for record-replay contract tests.\n *\n * Provides minimal factories and mocks for testing the execution pipeline\n * without requiring real browser or tool dependencies.\n */\n\nimport { vi } from 'vitest';\nimport type { ExecCtx } from '@/entrypoints/background/record-replay/nodes/types';\nimport type { ActionExecutionContext } from '@/entrypoints/background/record-replay/actions/types';\n\n/**\n * Create a minimal ExecCtx for testing\n */\nexport function createMockExecCtx(overrides: Partial<ExecCtx> = {}): ExecCtx {\n  return {\n    vars: {},\n    logger: vi.fn(),\n    ...overrides,\n  };\n}\n\n/**\n * Create a minimal ActionExecutionContext for testing\n */\nexport function createMockActionCtx(\n  overrides: Partial<ActionExecutionContext> = {},\n): ActionExecutionContext {\n  return {\n    vars: {},\n    tabId: 1,\n    log: vi.fn(),\n    ...overrides,\n  };\n}\n\n/**\n * Create a minimal Step for testing\n */\nexport function createMockStep(type: string, overrides: Record<string, unknown> = {}): any {\n  return {\n    id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n    type,\n    ...overrides,\n  };\n}\n\n/**\n * Create a minimal Flow for testing (with nodes/edges for scheduler)\n */\nexport function createMockFlow(overrides: Record<string, unknown> = {}): any {\n  const id = `flow_${Date.now()}`;\n  return {\n    id,\n    name: 'Test Flow',\n    version: 1,\n    steps: [],\n    nodes: [],\n    edges: [],\n    variables: [],\n    meta: {\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    },\n    ...overrides,\n  };\n}\n\n/**\n * Create a mock ActionRegistry for testing\n */\nexport function createMockRegistry(handlers: Map<string, any> = new Map()) {\n  const executeFn = vi.fn(async () => ({ status: 'success' as const }));\n\n  return {\n    get: vi.fn((type: string) => handlers.get(type) || { type }),\n    execute: executeFn,\n    register: vi.fn(),\n    has: vi.fn((type: string) => handlers.has(type)),\n    _executeFn: executeFn, // Expose for assertions\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/adapter-policy.contract.test.ts",
    "content": "/**\n * Adapter Policy Contract Tests\n *\n * Verifies that skipRetry and skipNavWait flags correctly modify\n * action execution behavior:\n * - skipRetry: removes action.policy.retry before execution\n * - skipNavWait: sets ctx.execution.skipNavWait for handlers\n */\n\nimport { describe, expect, it, vi, beforeEach } from 'vitest';\nimport { createStepExecutor } from '@/entrypoints/background/record-replay/actions/adapter';\nimport { createMockExecCtx, createMockStep } from './_test-helpers';\n\ndescribe('adapter policy flags contract', () => {\n  let registryExecute: ReturnType<typeof vi.fn>;\n  let mockRegistry: any;\n\n  beforeEach(() => {\n    registryExecute = vi.fn(async () => ({ status: 'success' }));\n    mockRegistry = {\n      get: vi.fn(() => ({ type: 'fill' })), // Returns truthy = handler exists\n      execute: registryExecute,\n    };\n  });\n\n  describe('skipRetry flag', () => {\n    it('removes action.policy.retry when skipRetry is true', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('fill', {\n          retry: { count: 3, intervalMs: 100, backoff: 'exp' },\n          target: { candidates: [{ type: 'css', value: '#input' }] },\n          value: 'test',\n        }),\n        1, // tabId\n        { skipRetry: true },\n      );\n\n      expect(registryExecute).toHaveBeenCalledTimes(1);\n      const [, action] = registryExecute.mock.calls[0];\n      expect(action.policy?.retry).toBeUndefined();\n    });\n\n    it('preserves action.policy.retry when skipRetry is false', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('fill', {\n          retry: { count: 3, intervalMs: 100, backoff: 'exp' },\n          target: { candidates: [{ type: 'css', value: '#input' }] },\n          value: 'test',\n        }),\n        1,\n        { skipRetry: false },\n      );\n\n      expect(registryExecute).toHaveBeenCalledTimes(1);\n      const [, action] = registryExecute.mock.calls[0];\n      expect(action.policy?.retry).toBeDefined();\n      expect(action.policy.retry.retries).toBe(3);\n    });\n\n    it('preserves action.policy.retry when skipRetry is not specified', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('fill', {\n          retry: { count: 2, intervalMs: 50 },\n          target: { candidates: [{ type: 'css', value: '#input' }] },\n          value: 'test',\n        }),\n        1,\n        {}, // No skipRetry\n      );\n\n      const [, action] = registryExecute.mock.calls[0];\n      expect(action.policy?.retry).toBeDefined();\n    });\n  });\n\n  describe('skipNavWait flag', () => {\n    it('sets ctx.execution.skipNavWait when skipNavWait is true', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('click', {\n          target: { candidates: [{ type: 'css', value: '#btn' }] },\n        }),\n        1,\n        { skipNavWait: true },\n      );\n\n      expect(registryExecute).toHaveBeenCalledTimes(1);\n      const [actionCtx] = registryExecute.mock.calls[0];\n      expect(actionCtx.execution?.skipNavWait).toBe(true);\n    });\n\n    it('does not set ctx.execution when skipNavWait is false', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('click', {\n          target: { candidates: [{ type: 'css', value: '#btn' }] },\n        }),\n        1,\n        { skipNavWait: false },\n      );\n\n      const [actionCtx] = registryExecute.mock.calls[0];\n      expect(actionCtx.execution).toBeUndefined();\n    });\n\n    it('does not set ctx.execution when skipNavWait is not specified', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('navigate', {\n          url: 'https://example.com',\n        }),\n        1,\n        {}, // No skipNavWait\n      );\n\n      const [actionCtx] = registryExecute.mock.calls[0];\n      expect(actionCtx.execution).toBeUndefined();\n    });\n  });\n\n  describe('combined flags', () => {\n    it('applies both skipRetry and skipNavWait together', async () => {\n      const executor = createStepExecutor(mockRegistry);\n\n      await executor(\n        createMockExecCtx(),\n        createMockStep('click', {\n          retry: { count: 5, intervalMs: 200 },\n          target: { candidates: [{ type: 'css', value: '#btn' }] },\n        }),\n        1,\n        { skipRetry: true, skipNavWait: true },\n      );\n\n      const [actionCtx, action] = registryExecute.mock.calls[0];\n      expect(action.policy?.retry).toBeUndefined();\n      expect(actionCtx.execution?.skipNavWait).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/flow-store-strip-steps.contract.test.ts",
    "content": "/**\n * Flow Store Steps Stripping Contract Tests\n *\n * Verifies that flow-store correctly strips deprecated steps field before persistence:\n * - saveFlow() strips steps after normalization\n * - lazyNormalize() strips steps when persisting normalized flow\n * - importFlowFromJson() strips steps via saveFlow()\n *\n * These tests ensure new saves only contain the DAG model (nodes/edges).\n */\n\nimport { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';\n\n// Use vi.hoisted to ensure mocks are available before module load\nconst mocks = vi.hoisted(() => ({\n  save: vi.fn(),\n  get: vi.fn(),\n  list: vi.fn(),\n  delete: vi.fn(),\n  ensureMigratedFromLocal: vi.fn(),\n  sendMessage: vi.fn(),\n}));\n\n// Mock IndexedDbStorage before importing flow-store\nvi.mock('@/entrypoints/background/record-replay/storage/indexeddb-manager', () => ({\n  ensureMigratedFromLocal: mocks.ensureMigratedFromLocal.mockResolvedValue(undefined),\n  IndexedDbStorage: {\n    flows: {\n      save: mocks.save,\n      get: mocks.get,\n      list: mocks.list,\n      delete: mocks.delete,\n    },\n    runs: { list: vi.fn().mockResolvedValue([]), replaceAll: vi.fn() },\n    published: { list: vi.fn().mockResolvedValue([]), save: vi.fn(), delete: vi.fn() },\n    schedules: { list: vi.fn().mockResolvedValue([]), save: vi.fn(), delete: vi.fn() },\n  },\n}));\n\n// Mock chrome.runtime.sendMessage\nvi.stubGlobal('chrome', {\n  runtime: {\n    sendMessage: mocks.sendMessage.mockResolvedValue(undefined),\n  },\n});\n\nimport {\n  saveFlow,\n  getFlow,\n  importFlowFromJson,\n} from '@/entrypoints/background/record-replay/flow-store';\nimport type { Flow } from '@/entrypoints/background/record-replay/types';\n\nfunction createTestFlow(overrides: Partial<Flow> = {}): Flow {\n  return {\n    id: `test_flow_${Date.now()}`,\n    name: 'Test Flow',\n    version: 1,\n    meta: {\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    },\n    ...overrides,\n  };\n}\n\ndescribe('Flow Store steps stripping', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mocks.save.mockResolvedValue(undefined);\n    mocks.get.mockResolvedValue(undefined);\n    mocks.list.mockResolvedValue([]);\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe('saveFlow strips steps', () => {\n    it('saves flow without steps field when steps is present', async () => {\n      const flow = createTestFlow({\n        steps: [{ id: 's1', type: 'click' } as any],\n        nodes: [{ id: 's1', type: 'click', config: {} }],\n        edges: [],\n      });\n\n      await saveFlow(flow);\n\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(1);\n    });\n\n    it('preserves flow without steps when no steps present', async () => {\n      const flow = createTestFlow({\n        nodes: [{ id: 's1', type: 'click', config: {} }],\n        edges: [],\n      });\n\n      await saveFlow(flow);\n\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(1);\n    });\n\n    it('normalizes and strips: generates nodes from steps then removes steps', async () => {\n      // Flow with only steps (no nodes) - should normalize to nodes then strip steps\n      const flow = createTestFlow({\n        steps: [\n          { id: 's1', type: 'click' } as any,\n          { id: 's2', type: 'fill', value: 'test' } as any,\n        ],\n      });\n\n      await saveFlow(flow);\n\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(2);\n      expect(savedFlow.edges).toHaveLength(1);\n    });\n  });\n\n  describe('getFlow lazy normalize strips steps', () => {\n    it('strips steps when lazy normalizing legacy flow', async () => {\n      // Mock a legacy flow with only steps (no nodes)\n      const legacyFlow = createTestFlow({\n        id: 'legacy_flow',\n        steps: [{ id: 's1', type: 'click' } as any],\n        nodes: undefined,\n      });\n      mocks.get.mockResolvedValue(legacyFlow);\n\n      const result = await getFlow('legacy_flow');\n\n      // Flow returned to caller has nodes but NOT steps\n      expect(result).toBeDefined();\n      expect(result!.nodes).toHaveLength(1);\n      expect(result).not.toHaveProperty('steps');\n\n      // Saved flow should also not have steps\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(1);\n    });\n\n    it('does not save but still strips steps when flow already has nodes', async () => {\n      // Flow with nodes and steps - should not save but should strip steps on return\n      const normalFlow = createTestFlow({\n        id: 'normal_flow',\n        steps: [{ id: 's1', type: 'click' } as any], // legacy steps still in storage\n        nodes: [{ id: 's1', type: 'click', config: {} }],\n        edges: [],\n      });\n      mocks.get.mockResolvedValue(normalFlow);\n\n      const result = await getFlow('normal_flow');\n\n      expect(result).toBeDefined();\n      expect(result).not.toHaveProperty('steps'); // returned flow should NOT have steps\n      expect(result!.nodes).toHaveLength(1);\n      expect(mocks.save).not.toHaveBeenCalled(); // no re-save needed\n    });\n  });\n\n  describe('importFlowFromJson strips steps', () => {\n    it('imports flow with steps, saves without steps', async () => {\n      const json = JSON.stringify({\n        id: 'imported_flow',\n        name: 'Imported Flow',\n        version: 1,\n        steps: [\n          { id: 's1', type: 'click' },\n          { id: 's2', type: 'fill', value: 'hello' },\n        ],\n      });\n\n      await importFlowFromJson(json);\n\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(2);\n      expect(savedFlow.edges).toHaveLength(1);\n    });\n\n    it('imports flow with nodes, saves without steps', async () => {\n      const json = JSON.stringify({\n        id: 'imported_flow',\n        name: 'Imported Flow',\n        version: 1,\n        nodes: [\n          { id: 'n1', type: 'click', config: {} },\n          { id: 'n2', type: 'fill', config: { value: 'hello' } },\n        ],\n        edges: [{ id: 'e1', from: 'n1', to: 'n2' }],\n      });\n\n      await importFlowFromJson(json);\n\n      expect(mocks.save).toHaveBeenCalledTimes(1);\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n      expect(savedFlow.nodes).toHaveLength(2);\n    });\n\n    it('handles flow array import', async () => {\n      const json = JSON.stringify([\n        { id: 'f1', name: 'Flow 1', steps: [{ id: 's1', type: 'click' }] },\n        { id: 'f2', name: 'Flow 2', nodes: [{ id: 'n1', type: 'fill', config: {} }], edges: [] },\n      ]);\n\n      await importFlowFromJson(json);\n\n      expect(mocks.save).toHaveBeenCalledTimes(2);\n\n      // First flow: steps normalized and stripped\n      const savedFlow1 = mocks.save.mock.calls[0][0];\n      expect(savedFlow1.id).toBe('f1');\n      expect(savedFlow1).not.toHaveProperty('steps');\n      expect(savedFlow1.nodes).toHaveLength(1);\n\n      // Second flow: already has nodes, no steps\n      const savedFlow2 = mocks.save.mock.calls[1][0];\n      expect(savedFlow2.id).toBe('f2');\n      expect(savedFlow2).not.toHaveProperty('steps');\n      expect(savedFlow2.nodes).toHaveLength(1);\n    });\n  });\n\n  describe('edge cases', () => {\n    it('handles empty steps array', async () => {\n      const flow = createTestFlow({\n        steps: [],\n        nodes: [{ id: 'n1', type: 'click', config: {} }],\n        edges: [],\n      });\n\n      await saveFlow(flow);\n\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow).not.toHaveProperty('steps');\n    });\n\n    it('preserves all other flow properties', async () => {\n      const flow = createTestFlow({\n        id: 'preserve_test',\n        name: 'Preserve Test',\n        description: 'Test description',\n        version: 5,\n        steps: [{ id: 's1', type: 'click' } as any],\n        nodes: [{ id: 's1', type: 'click', config: {} }],\n        edges: [],\n        variables: [{ key: 'var1', type: 'string' }],\n        meta: {\n          createdAt: '2024-01-01T00:00:00Z',\n          updatedAt: '2024-01-02T00:00:00Z',\n          domain: 'example.com',\n          tags: ['test', 'example'],\n        },\n      });\n\n      await saveFlow(flow);\n\n      const savedFlow = mocks.save.mock.calls[0][0];\n      expect(savedFlow.id).toBe('preserve_test');\n      expect(savedFlow.name).toBe('Preserve Test');\n      expect(savedFlow.description).toBe('Test description');\n      expect(savedFlow.version).toBe(5);\n      expect(savedFlow.variables).toHaveLength(1);\n      expect(savedFlow.meta.domain).toBe('example.com');\n      expect(savedFlow.meta.tags).toEqual(['test', 'example']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/high-risk-actions.integration.test.ts",
    "content": "/**\n * High Risk Actions Integration Tests (M3-full batch 2)\n *\n * Purpose:\n *   Verify that high-risk step types (click, navigate, tabs) are properly routed\n *   based on hybrid allowlist configuration, and that skipNavWait policy works correctly.\n *\n * Test Strategy:\n *   - Use real HybridStepExecutor + real ActionRegistry + real handlers\n *   - Mock only environment boundaries:\n *     - chrome.* APIs (tabs, windows)\n *     - handleCallTool (tool bridge)\n *     - selectorLocator.locate (element location)\n *     - navigation wait functions\n *\n * Coverage:\n *   - Default hybrid: click/navigate/openTab/switchTab route to legacy\n *   - Opt-in: click/navigate can route to actions with custom allowlist\n *   - skipNavWait: controls whether navigate handler does internal nav-wait\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\n// =============================================================================\n// Mock Setup (using vi.hoisted for proper hoisting)\n// =============================================================================\n\nconst mocks = vi.hoisted(() => ({\n  handleCallTool: vi.fn(),\n  locate: vi.fn(),\n  tabsSendMessage: vi.fn(),\n  tabsGet: vi.fn(),\n  tabsQuery: vi.fn(),\n  tabsCreate: vi.fn(),\n  tabsUpdate: vi.fn(),\n  windowsCreate: vi.fn(),\n  windowsUpdate: vi.fn(),\n  waitForNavigationDone: vi.fn(),\n  ensureReadPageIfWeb: vi.fn(),\n  maybeQuickWaitForNav: vi.fn(),\n  waitForNetworkIdle: vi.fn(),\n}));\n\n// Mock tool bridge\nvi.mock('@/entrypoints/background/tools', () => ({\n  handleCallTool: mocks.handleCallTool,\n}));\n\n// Mock selector locator\nvi.mock('@/shared/selector', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/shared/selector')>();\n  return {\n    ...actual,\n    createChromeSelectorLocator: () => ({\n      locate: mocks.locate,\n    }),\n  };\n});\n\n// Mock navigation wait wrappers to avoid real webNavigation waiting\nvi.mock('@/entrypoints/background/record-replay/engine/policies/wait', () => ({\n  waitForNavigationDone: mocks.waitForNavigationDone,\n  ensureReadPageIfWeb: mocks.ensureReadPageIfWeb,\n  maybeQuickWaitForNav: mocks.maybeQuickWaitForNav,\n  waitForNetworkIdle: mocks.waitForNetworkIdle,\n}));\n\n// =============================================================================\n// Imports (after mocks)\n// =============================================================================\n\nimport { createMockExecCtx } from './_test-helpers';\nimport { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode';\nimport { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor';\nimport { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions';\n\n// =============================================================================\n// Test Constants\n// =============================================================================\n\nconst TAB_ID = 1;\nconst OTHER_TAB_ID = 2;\nconst FRAME_ID = 0;\n\n// =============================================================================\n// Helper Types and Functions\n// =============================================================================\n\ninterface TestStep {\n  id: string;\n  type: string;\n  [key: string]: unknown;\n}\n\n/**\n * Create executor with configurable hybrid config\n */\nfunction createExecutor(overrides?: Parameters<typeof createHybridConfig>[0]): HybridStepExecutor {\n  const registry = createReplayActionRegistry();\n  const config = createHybridConfig(overrides);\n  return new HybridStepExecutor(registry, config);\n}\n\n/**\n * Setup default mock responses for handleCallTool\n */\nfunction setupDefaultToolMock(): void {\n  mocks.handleCallTool.mockImplementation(async () => ({}));\n}\n\n/**\n * Setup default mock responses for chrome.tabs.sendMessage\n */\nfunction setupDefaultTabsMessageMock(): void {\n  mocks.tabsSendMessage.mockImplementation(async (_tabId: number, message: unknown) => {\n    const msg = message as { action?: string };\n    switch (msg.action) {\n      case TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR:\n        return { success: true, ref: 'ref_from_selector', center: { x: 1, y: 1 } };\n      case TOOL_MESSAGE_TYPES.RESOLVE_REF:\n        return { success: true, rect: { width: 100, height: 20 }, center: { x: 1, y: 1 } };\n      default:\n        return { success: true };\n    }\n  });\n}\n\n/**\n * Setup default mock responses for chrome.tabs.query\n */\nfunction setupDefaultTabsQueryMock(): void {\n  mocks.tabsQuery.mockImplementation(async (queryInfo?: unknown) => {\n    const q = queryInfo as Record<string, unknown> | undefined;\n    if (q?.active === true) {\n      return [{ id: TAB_ID, url: 'https://example.com/', status: 'complete', windowId: 1 }];\n    }\n    return [\n      {\n        id: TAB_ID,\n        url: 'https://example.com/',\n        title: 'Example',\n        status: 'complete',\n        windowId: 1,\n      },\n      {\n        id: OTHER_TAB_ID,\n        url: 'https://other.example.com/',\n        title: 'Other',\n        status: 'complete',\n        windowId: 2,\n      },\n    ];\n  });\n}\n\n/**\n * Setup default mock responses for chrome.tabs.get\n */\nfunction setupDefaultTabsGetMock(): void {\n  mocks.tabsGet.mockImplementation(async (tabId: number) => {\n    if (tabId === TAB_ID) {\n      return { id: TAB_ID, url: 'https://before.example/', status: 'complete', windowId: 1 };\n    }\n    if (tabId === OTHER_TAB_ID) {\n      return {\n        id: OTHER_TAB_ID,\n        url: 'https://other.example.com/',\n        status: 'complete',\n        windowId: 2,\n      };\n    }\n    return { id: tabId, url: 'https://unknown.example/', status: 'complete', windowId: 1 };\n  });\n}\n\n// =============================================================================\n// Test Suite\n// =============================================================================\n\ndescribe('high-risk actions integration (M3-full batch 2)', () => {\n  beforeEach(() => {\n    // Reset all mocks\n    Object.values(mocks).forEach((mock) => mock.mockReset());\n\n    // Default behaviors\n    setupDefaultToolMock();\n    setupDefaultTabsMessageMock();\n    setupDefaultTabsQueryMock();\n    setupDefaultTabsGetMock();\n\n    // Default selector locate result\n    mocks.locate.mockResolvedValue({ ref: 'ref_default', frameId: FRAME_ID, resolvedBy: 'css' });\n\n    // Default tab/window operations\n    mocks.tabsCreate.mockResolvedValue({ id: OTHER_TAB_ID });\n    mocks.tabsUpdate.mockResolvedValue({});\n    mocks.windowsCreate.mockResolvedValue({ tabs: [{ id: OTHER_TAB_ID }] });\n    mocks.windowsUpdate.mockResolvedValue({});\n\n    // Default wait wrappers (no-op)\n    mocks.waitForNavigationDone.mockResolvedValue(undefined);\n    mocks.ensureReadPageIfWeb.mockResolvedValue(undefined);\n    mocks.maybeQuickWaitForNav.mockResolvedValue(undefined);\n    mocks.waitForNetworkIdle.mockResolvedValue(undefined);\n\n    // Stub chrome.* globals\n    vi.stubGlobal('chrome', {\n      tabs: {\n        sendMessage: mocks.tabsSendMessage,\n        get: mocks.tabsGet,\n        query: mocks.tabsQuery,\n        create: mocks.tabsCreate,\n        update: mocks.tabsUpdate,\n      },\n      windows: {\n        create: mocks.windowsCreate,\n        update: mocks.windowsUpdate,\n      },\n      webNavigation: {\n        getAllFrames: vi.fn(async () => []),\n      },\n    });\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n    vi.useRealTimers();\n  });\n\n  // ===========================================================================\n  // Routing Tests (default hybrid allowlist)\n  // ===========================================================================\n\n  describe('routing (default hybrid allowlist)', () => {\n    it('click routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'click_routing_legacy',\n        type: 'click',\n        target: { candidates: [{ type: 'css', value: '#btn' }] },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      // Actions locator is only used by ActionRegistry handlers\n      expect(mocks.locate).not.toHaveBeenCalled();\n    });\n\n    it('dblclick routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'dblclick_routing_legacy',\n        type: 'dblclick',\n        target: { candidates: [{ type: 'css', value: '#btn' }] },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      expect(mocks.locate).not.toHaveBeenCalled();\n    });\n\n    it('navigate routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'navigate_routing_legacy',\n        type: 'navigate',\n        url: 'https://example.com/next',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('openTab routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'openTab_routing_legacy',\n        type: 'openTab',\n        url: 'https://example.com/new',\n        newWindow: false,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('switchTab routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchTab_routing_legacy',\n        type: 'switchTab',\n        urlContains: 'other.example.com',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n  });\n\n  // ===========================================================================\n  // Opt-in Actions Tests\n  // ===========================================================================\n\n  describe('click/navigate actions opt-in', () => {\n    it('click routes to actions when allowlisted', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['click']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'click_allowlisted_actions',\n        type: 'click',\n        target: { candidates: [{ type: 'css', value: '#btn' }] },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.locate).toHaveBeenCalled();\n\n      const toolCalls = mocks.handleCallTool.mock.calls.map(\n        ([arg]) => (arg as { name: string }).name,\n      );\n      expect(toolCalls).toContain(TOOL_NAMES.BROWSER.READ_PAGE);\n      expect(toolCalls).toContain(TOOL_NAMES.BROWSER.CLICK);\n\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.CLICK,\n          args: expect.objectContaining({ tabId: TAB_ID }),\n        }),\n      );\n    });\n\n    it('navigate skipNavWait=true skips beforeUrl read', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['navigate']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'navigate_skipNavWait_true',\n        type: 'navigate',\n        url: 'https://example.com/next',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // When skipNavWait=true (default), handler skips reading beforeUrl\n      expect(mocks.tabsGet).not.toHaveBeenCalled();\n      expect(mocks.waitForNavigationDone).not.toHaveBeenCalled();\n      expect(mocks.ensureReadPageIfWeb).not.toHaveBeenCalled();\n\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.NAVIGATE,\n          args: expect.objectContaining({ url: 'https://example.com/next', tabId: TAB_ID }),\n        }),\n      );\n    });\n\n    it('navigate skipNavWait=false does nav-wait', async () => {\n      const executor = createExecutor({\n        actionsAllowlist: new Set(['navigate']),\n        skipActionsNavWait: false,\n      });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'navigate_skipNavWait_false',\n        type: 'navigate',\n        url: 'https://example.com/next',\n        timeoutMs: 5000,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // When skipNavWait=false, handler reads beforeUrl and does nav-wait\n      expect(mocks.tabsGet).toHaveBeenCalled();\n      expect(mocks.waitForNavigationDone).toHaveBeenCalledWith(\n        'https://before.example/',\n        expect.any(Number),\n      );\n      expect(mocks.ensureReadPageIfWeb).toHaveBeenCalled();\n\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.NAVIGATE,\n          args: expect.objectContaining({ url: 'https://example.com/next', tabId: TAB_ID }),\n        }),\n      );\n    });\n\n    it('navigate with refresh=true calls NAVIGATE tool with refresh', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['navigate']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'navigate_refresh',\n        type: 'navigate',\n        refresh: true,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.NAVIGATE,\n          args: expect.objectContaining({ refresh: true, tabId: TAB_ID }),\n        }),\n      );\n    });\n\n    it('click fails when element not visible', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['click']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // Mock resolveRef to return element not visible\n      mocks.tabsSendMessage.mockImplementation(async (_tabId: number, message: unknown) => {\n        const msg = message as { action?: string };\n        if (msg.action === TOOL_MESSAGE_TYPES.RESOLVE_REF) {\n          return { success: true, rect: { width: 0, height: 0 }, center: { x: 0, y: 0 } };\n        }\n        return { success: true };\n      });\n\n      const step: TestStep = {\n        id: 'click_not_visible',\n        type: 'click',\n        target: { candidates: [{ type: 'css', value: '#hidden-btn' }] },\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /not visible|ELEMENT_NOT_VISIBLE/i,\n      );\n    });\n\n    it('click fails when CLICK tool returns error', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['click']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // Mock CLICK tool to return error\n      mocks.handleCallTool.mockImplementation(async (req: { name: string }) => {\n        if (req.name === TOOL_NAMES.BROWSER.CLICK) {\n          return {\n            isError: true,\n            content: [{ text: 'Element not found in DOM' }],\n          };\n        }\n        return {};\n      });\n\n      const step: TestStep = {\n        id: 'click_tool_error',\n        type: 'click',\n        target: { candidates: [{ type: 'css', value: '#missing' }] },\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /Element not found|failed|error/i,\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/hybrid-actions.integration.test.ts",
    "content": "/**\n * Hybrid Actions Integration Tests (M3-full batch 1)\n *\n * Purpose:\n *   Verify that HybridStepExecutor correctly routes allowlisted action types\n *   through the ActionRegistry pipeline, exercising real handlers while\n *   mocking only environment boundaries.\n *\n * Test Strategy:\n *   - Use real HybridStepExecutor + real ActionRegistry + real handlers\n *   - Mock only environment boundaries:\n *     - chrome.* APIs (tabs.sendMessage, scripting.executeScript, etc.)\n *     - handleCallTool (tool bridge to content scripts)\n *     - selectorLocator.locate (element location)\n *\n * Coverage:\n *   - routing sanity: verify allowlist routing works\n *   - fill, key, scroll, wait, delay, assert, screenshot, drag\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { TOOL_NAMES } from 'chrome-mcp-shared';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\n// =============================================================================\n// Mock Setup (using vi.hoisted for proper hoisting)\n// =============================================================================\n\nconst mocks = vi.hoisted(() => ({\n  handleCallTool: vi.fn(),\n  locate: vi.fn(),\n  tabsSendMessage: vi.fn(),\n  tabsGet: vi.fn(),\n  scriptingExecuteScript: vi.fn(),\n}));\n\n// Mock tool bridge - all action handlers communicate with content scripts via this\nvi.mock('@/entrypoints/background/tools', () => ({\n  handleCallTool: mocks.handleCallTool,\n}));\n\n// Mock selector locator - prevents real DOM queries\nvi.mock('@/shared/selector', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/shared/selector')>();\n  return {\n    ...actual,\n    createChromeSelectorLocator: () => ({\n      locate: mocks.locate,\n    }),\n  };\n});\n\n// =============================================================================\n// Imports (after mocks)\n// =============================================================================\n\nimport { createMockExecCtx } from './_test-helpers';\nimport { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode';\nimport { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor';\nimport { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions';\n\n// =============================================================================\n// Test Constants\n// =============================================================================\n\nconst TAB_ID = 1;\nconst FRAME_ID = 0;\n\n// =============================================================================\n// Helper Types and Functions\n// =============================================================================\n\ninterface TestStep {\n  id: string;\n  type: string;\n  [key: string]: unknown;\n}\n\n/**\n * Create executor with default hybrid config (MINIMAL_HYBRID_ACTION_TYPES)\n */\nfunction createExecutor(): HybridStepExecutor {\n  const registry = createReplayActionRegistry();\n  const config = createHybridConfig();\n  return new HybridStepExecutor(registry, config);\n}\n\n/**\n * Setup default mock responses for common chrome.tabs.sendMessage actions\n */\nfunction setupDefaultTabsMessageMock(): void {\n  mocks.tabsSendMessage.mockImplementation(async (_tabId: number, message: unknown) => {\n    const msg = message as { action?: string };\n\n    switch (msg.action) {\n      case TOOL_MESSAGE_TYPES.RESOLVE_REF:\n        return { success: true, selector: '#resolved', rect: { width: 100, height: 20 } };\n      case 'getAttributeForSelector':\n        return { value: 'text' }; // Not a file input\n      case 'focusByRef':\n        return { success: true };\n      case 'waitForSelector':\n      case 'waitForText':\n        return { success: true };\n      default:\n        return { success: true };\n    }\n  });\n}\n\n/**\n * Setup default mock responses for handleCallTool\n */\nfunction setupDefaultToolMock(): void {\n  mocks.handleCallTool.mockImplementation(async (req: { name: string }) => {\n    if (req.name === TOOL_NAMES.BROWSER.SCREENSHOT) {\n      return {\n        content: [{ type: 'text', text: JSON.stringify({ base64Data: 'dGVzdGRhdGE=' }) }],\n      };\n    }\n    return {};\n  });\n}\n\n/**\n * Setup default mock responses for chrome.scripting.executeScript\n */\nfunction setupDefaultScriptingMock(): void {\n  mocks.scriptingExecuteScript.mockImplementation(\n    async (details: { files?: string[]; args?: unknown[] }) => {\n      // wait-helper injection path\n      if (Array.isArray(details.files) && details.files.length > 0) {\n        return [];\n      }\n\n      // assert handler expects { passed: boolean } result\n      const firstArg = details.args?.[0];\n      if (firstArg && typeof firstArg === 'object' && firstArg !== null && 'kind' in firstArg) {\n        return [{ result: { passed: true } }];\n      }\n\n      // scroll handler expects boolean true\n      return [{ result: true }];\n    },\n  );\n}\n\n// =============================================================================\n// Test Suite\n// =============================================================================\n\ndescribe('hybrid mode actions integration (M3-full batch 1)', () => {\n  beforeEach(() => {\n    // Reset all mocks\n    Object.values(mocks).forEach((mock) => mock.mockReset());\n\n    // Setup default behaviors\n    setupDefaultToolMock();\n    setupDefaultTabsMessageMock();\n    setupDefaultScriptingMock();\n\n    // Default selector locate result\n    mocks.locate.mockResolvedValue({ ref: 'ref_default', frameId: FRAME_ID, resolvedBy: 'css' });\n    mocks.tabsGet.mockResolvedValue({ id: TAB_ID, url: 'https://example.com/' });\n\n    // Stub chrome.* globals\n    vi.stubGlobal('chrome', {\n      tabs: {\n        sendMessage: mocks.tabsSendMessage,\n        get: mocks.tabsGet,\n        query: vi.fn(async () => [{ id: TAB_ID, url: 'https://example.com/' }]),\n      },\n      scripting: {\n        executeScript: mocks.scriptingExecuteScript,\n      },\n      webNavigation: {\n        getAllFrames: vi.fn(async () => []),\n      },\n      windows: {\n        create: vi.fn(),\n        update: vi.fn(),\n      },\n    });\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n    vi.useRealTimers();\n  });\n\n  // ===========================================================================\n  // Routing Sanity Tests\n  // ===========================================================================\n\n  describe('routing sanity', () => {\n    it('routes allowlisted types to actions executor', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // delay is in MINIMAL_HYBRID_ACTION_TYPES\n      const step: TestStep = { id: 'delay_routing_test', type: 'delay', sleep: 0 };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n    });\n\n    it('routes non-allowlisted types to legacy executor', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // click is NOT in MINIMAL_HYBRID_ACTION_TYPES (high-risk)\n      const step: TestStep = {\n        id: 'click_routing_test',\n        type: 'click',\n        target: { candidates: [{ type: 'css', value: '#btn' }] },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      // Verify actions path was NOT taken (selectorLocator is only used by action handlers)\n      expect(mocks.locate).not.toHaveBeenCalled();\n    });\n  });\n\n  // ===========================================================================\n  // Fill Action Tests\n  // ===========================================================================\n\n  describe('fill action', () => {\n    it('routes through ActionRegistry and calls READ_PAGE + FILL tools', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      mocks.locate.mockResolvedValueOnce({ ref: 'ref_fill', frameId: FRAME_ID, resolvedBy: 'css' });\n\n      const step: TestStep = {\n        id: 'fill_test',\n        type: 'fill',\n        target: { candidates: [{ type: 'css', value: '#name' }] },\n        value: 'test input',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n\n      // Verify tool calls\n      const toolCalls = mocks.handleCallTool.mock.calls.map(([arg]) => arg.name);\n      expect(toolCalls).toContain(TOOL_NAMES.BROWSER.READ_PAGE);\n      expect(toolCalls).toContain(TOOL_NAMES.BROWSER.FILL);\n\n      // Verify FILL was called with correct parameters\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.FILL,\n          args: expect.objectContaining({\n            tabId: TAB_ID,\n            frameId: FRAME_ID,\n            ref: 'ref_fill',\n            value: 'test input',\n          }),\n        }),\n      );\n    });\n\n    it('handles variable interpolation in fill value', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({\n        frameId: FRAME_ID,\n        vars: { username: 'john_doe' },\n      });\n\n      mocks.locate.mockResolvedValueOnce({ ref: 'ref_fill', frameId: FRAME_ID, resolvedBy: 'css' });\n\n      const step: TestStep = {\n        id: 'fill_var_test',\n        type: 'fill',\n        target: { candidates: [{ type: 'css', value: '#username' }] },\n        value: '{username}',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.FILL,\n          args: expect.objectContaining({ value: 'john_doe' }),\n        }),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Key Action Tests\n  // ===========================================================================\n\n  describe('key action', () => {\n    it('routes to actions and calls KEYBOARD tool', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = { id: 'key_test', type: 'key', keys: 'Enter' };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.KEYBOARD,\n          args: expect.objectContaining({ tabId: TAB_ID, keys: 'Enter' }),\n        }),\n      );\n    });\n\n    it('supports complex key combinations', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = { id: 'key_combo_test', type: 'key', keys: 'Control+a' };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.KEYBOARD,\n          args: expect.objectContaining({ keys: 'Control+a' }),\n        }),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Scroll Action Tests\n  // ===========================================================================\n\n  describe('scroll action', () => {\n    it('executes window scroll via chrome.scripting in offset mode', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'scroll_offset_test',\n        type: 'scroll',\n        mode: 'offset',\n        offset: { x: 0, y: 200 },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.scriptingExecuteScript).toHaveBeenCalledWith(\n        expect.objectContaining({\n          target: expect.objectContaining({ tabId: TAB_ID }),\n          world: 'MAIN',\n        }),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Wait Action Tests\n  // ===========================================================================\n\n  describe('wait action', () => {\n    it('injects helper and sends waitForSelector message', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'wait_selector_test',\n        type: 'wait',\n        condition: { kind: 'selector', selector: '#ready', visible: true },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n\n      // Verify wait helper injection\n      expect(mocks.scriptingExecuteScript).toHaveBeenCalledWith(\n        expect.objectContaining({\n          files: ['inject-scripts/wait-helper.js'],\n          world: 'ISOLATED',\n        }),\n      );\n\n      // Verify wait request sent to content script\n      expect(mocks.tabsSendMessage).toHaveBeenCalledWith(\n        TAB_ID,\n        expect.objectContaining({ action: 'waitForSelector', selector: '#ready' }),\n        expect.objectContaining({ frameId: FRAME_ID }),\n      );\n    });\n\n    it('supports text wait condition', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'wait_text_test',\n        type: 'wait',\n        condition: { kind: 'text', text: 'Loading complete' },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.tabsSendMessage).toHaveBeenCalledWith(\n        TAB_ID,\n        expect.objectContaining({ action: 'waitForText', text: 'Loading complete' }),\n        expect.anything(),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Delay Action Tests\n  // ===========================================================================\n\n  describe('delay action', () => {\n    it('awaits specified time using timers', async () => {\n      vi.useFakeTimers();\n\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = { id: 'delay_test', type: 'delay', sleep: 250 };\n\n      const promise = executor.execute(ctx, step as never, { tabId: TAB_ID });\n      await vi.advanceTimersByTimeAsync(250);\n      const result = await promise;\n\n      expect(result.executor).toBe('actions');\n    });\n\n    it('handles zero delay', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = { id: 'delay_zero_test', type: 'delay', sleep: 0 };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n    });\n  });\n\n  // ===========================================================================\n  // Assert Action Tests\n  // ===========================================================================\n\n  describe('assert action', () => {\n    it.each(['exists', 'visible'] as const)('handles %s assertion kind', async (kind) => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: `assert_${kind}_test`,\n        type: 'assert',\n        assert: { kind, selector: '#target' },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.scriptingExecuteScript).toHaveBeenCalledWith(\n        expect.objectContaining({\n          args: [expect.objectContaining({ kind })],\n        }),\n      );\n    });\n\n    it('propagates assertion failure as thrown error', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // Mock all calls to return failed assertion (assert handler polls)\n      mocks.scriptingExecuteScript.mockImplementation(\n        async (details: { files?: string[]; args?: unknown[] }) => {\n          // wait-helper injection path - return empty for file injections\n          if (Array.isArray(details.files) && details.files.length > 0) {\n            return [];\n          }\n          // Always return assertion failed\n          return [{ result: { passed: false, message: 'Element not found' } }];\n        },\n      );\n\n      // Use very short timeout to minimize test duration\n      // (adapter reads step.timeoutMs, default is 5000ms)\n      const step: TestStep = {\n        id: 'assert_fail_test',\n        type: 'assert',\n        assert: { kind: 'exists', selector: '#missing' },\n        failStrategy: 'stop',\n        timeoutMs: 50, // Very short to speed up test\n      };\n\n      // When assertion fails (or times out), adapter throws an error\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /ASSERTION_FAILED|Element not found|Timeout/i,\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Screenshot Action Tests\n  // ===========================================================================\n\n  describe('screenshot action', () => {\n    it('stores base64 data in ctx.vars when saveAs is specified', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'screenshot_test',\n        type: 'screenshot',\n        fullPage: false,\n        saveAs: 'capturedImage',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(ctx.vars.capturedImage).toBe('dGVzdGRhdGE=');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.SCREENSHOT,\n          args: expect.objectContaining({ tabId: TAB_ID, storeBase64: true }),\n        }),\n      );\n    });\n\n    it('supports fullPage option', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'screenshot_fullpage_test',\n        type: 'screenshot',\n        fullPage: true,\n        saveAs: 'fullCapture',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.SCREENSHOT,\n          args: expect.objectContaining({ fullPage: true }),\n        }),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Drag Action Tests\n  // ===========================================================================\n\n  describe('drag action', () => {\n    it('locates start/end targets and calls COMPUTER left_click_drag', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      // Mock separate locate calls for start and end\n      mocks.locate\n        .mockResolvedValueOnce({ ref: 'ref_start', frameId: FRAME_ID, resolvedBy: 'css' })\n        .mockResolvedValueOnce({ ref: 'ref_end', frameId: FRAME_ID, resolvedBy: 'css' });\n\n      const step: TestStep = {\n        id: 'drag_test',\n        type: 'drag',\n        start: { candidates: [{ type: 'css', value: '#drag-source' }] },\n        end: { candidates: [{ type: 'css', value: '#drop-target' }] },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n\n      // Verify both endpoints were located\n      expect(mocks.locate).toHaveBeenCalledTimes(2);\n\n      // Verify first call was for start element\n      expect(mocks.locate.mock.calls[0]?.[1]).toMatchObject({\n        candidates: expect.arrayContaining([expect.objectContaining({ value: '#drag-source' })]),\n      });\n\n      // Verify second call was for end element\n      expect(mocks.locate.mock.calls[1]?.[1]).toMatchObject({\n        candidates: expect.arrayContaining([expect.objectContaining({ value: '#drop-target' })]),\n      });\n\n      // Verify COMPUTER tool called with drag action\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          name: TOOL_NAMES.BROWSER.COMPUTER,\n          args: expect.objectContaining({\n            action: 'left_click_drag',\n            tabId: TAB_ID,\n            startRef: 'ref_start',\n            ref: 'ref_end',\n          }),\n        }),\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/script-control-flow.integration.test.ts",
    "content": "/**\n * Script & Control-Flow Integration Tests (M3-full batch 3)\n *\n * Purpose:\n *   Verify that script and control-flow step types are properly routed and executed\n *   based on hybrid allowlist configuration.\n *\n * Test Strategy:\n *   - Use real HybridStepExecutor + real ActionRegistry + real handlers\n *   - Mock only environment boundaries:\n *     - chrome.scripting.executeScript (for script execution)\n *     - chrome.webNavigation.getAllFrames (for switchFrame)\n *     - handleCallTool (tool bridge, not used by these handlers)\n *\n * Coverage:\n *   - Default hybrid: script/if/foreach/while/switchFrame route to legacy\n *   - Script defer semantics: when='after' returns deferAfterScript (legacy behavior)\n *   - Script opt-in: when='before' can route to actions with custom allowlist\n *   - Control-flow opt-in: if/foreach/while/switchFrame with actions allowlist\n *\n * Key Behavior Difference:\n *   Legacy script handler: when='after' returns { deferAfterScript: step }\n *   Actions script handler: executes immediately (no defer support)\n *   This difference is intentional - script with when='after' should stay on legacy.\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\n// =============================================================================\n// Mock Setup (using vi.hoisted for proper hoisting)\n// =============================================================================\n\nconst mocks = vi.hoisted(() => ({\n  handleCallTool: vi.fn(),\n  locate: vi.fn(),\n  executeScript: vi.fn(),\n  getAllFrames: vi.fn(),\n  tabsQuery: vi.fn(),\n  tabsGet: vi.fn(),\n}));\n\n// Mock tool bridge\nvi.mock('@/entrypoints/background/tools', () => ({\n  handleCallTool: mocks.handleCallTool,\n}));\n\n// Mock selector locator\nvi.mock('@/shared/selector', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/shared/selector')>();\n  return {\n    ...actual,\n    createChromeSelectorLocator: () => ({\n      locate: mocks.locate,\n    }),\n  };\n});\n\n// =============================================================================\n// Imports (after mocks)\n// =============================================================================\n\nimport { createMockExecCtx } from './_test-helpers';\nimport { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode';\nimport { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor';\nimport { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions';\n\n// =============================================================================\n// Test Constants\n// =============================================================================\n\nconst TAB_ID = 1;\nconst FRAME_ID = 0;\nconst CHILD_FRAME_ID = 123;\n\n// =============================================================================\n// Helper Types and Functions\n// =============================================================================\n\ninterface TestStep {\n  id: string;\n  type: string;\n  [key: string]: unknown;\n}\n\n/**\n * Create executor with configurable hybrid config\n */\nfunction createExecutor(overrides?: Parameters<typeof createHybridConfig>[0]): HybridStepExecutor {\n  const registry = createReplayActionRegistry();\n  const config = createHybridConfig(overrides);\n  return new HybridStepExecutor(registry, config);\n}\n\n/**\n * Setup default mock responses for handleCallTool\n */\nfunction setupDefaultToolMock(): void {\n  mocks.handleCallTool.mockImplementation(async () => ({}));\n}\n\n/**\n * Setup default mock for chrome.scripting.executeScript\n * Returns a successful script execution result\n */\nfunction setupDefaultScriptMock(): void {\n  mocks.executeScript.mockImplementation(async () => [\n    { result: { success: true, result: 'script_result' } },\n  ]);\n}\n\n/**\n * Setup default mock for chrome.webNavigation.getAllFrames\n */\nfunction setupDefaultFramesMock(): void {\n  mocks.getAllFrames.mockImplementation(async () => [\n    { frameId: 0, url: 'https://example.com/', parentFrameId: -1 },\n    { frameId: CHILD_FRAME_ID, url: 'https://example.com/iframe', parentFrameId: 0 },\n    { frameId: 456, url: 'https://ads.example.com/', parentFrameId: 0 },\n  ]);\n}\n\n/**\n * Setup default mock for chrome.tabs.query (needed by legacy handlers)\n */\nfunction setupDefaultTabsQueryMock(): void {\n  mocks.tabsQuery.mockImplementation(async (queryInfo?: unknown) => {\n    const q = queryInfo as Record<string, unknown> | undefined;\n    if (q?.active === true) {\n      return [{ id: TAB_ID, url: 'https://example.com/', status: 'complete', windowId: 1 }];\n    }\n    return [{ id: TAB_ID, url: 'https://example.com/', status: 'complete', windowId: 1 }];\n  });\n}\n\n/**\n * Setup default mock for chrome.tabs.get (needed by legacy handlers)\n */\nfunction setupDefaultTabsGetMock(): void {\n  mocks.tabsGet.mockImplementation(async (tabId: number) => ({\n    id: tabId,\n    url: 'https://example.com/',\n    status: 'complete',\n    windowId: 1,\n  }));\n}\n\n// =============================================================================\n// Test Suite\n// =============================================================================\n\ndescribe('script & control-flow integration (M3-full batch 3)', () => {\n  beforeEach(() => {\n    // Reset all mocks\n    Object.values(mocks).forEach((mock) => mock.mockReset());\n\n    // Default behaviors\n    setupDefaultToolMock();\n    setupDefaultScriptMock();\n    setupDefaultFramesMock();\n    setupDefaultTabsQueryMock();\n    setupDefaultTabsGetMock();\n\n    // Default selector locate result\n    mocks.locate.mockResolvedValue({ ref: 'ref_default', frameId: FRAME_ID, resolvedBy: 'css' });\n\n    // Stub chrome.* globals\n    vi.stubGlobal('chrome', {\n      scripting: {\n        executeScript: mocks.executeScript,\n      },\n      webNavigation: {\n        getAllFrames: mocks.getAllFrames,\n      },\n      tabs: {\n        query: mocks.tabsQuery,\n        get: mocks.tabsGet,\n      },\n    });\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  // ===========================================================================\n  // Routing Tests (default hybrid allowlist)\n  // ===========================================================================\n\n  describe('routing (default hybrid allowlist)', () => {\n    it('script routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_routing_legacy',\n        type: 'script',\n        code: 'return 42;',\n        world: 'MAIN',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('if routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { testVar: true } });\n\n      const step: TestStep = {\n        id: 'if_routing_legacy',\n        type: 'if',\n        condition: { type: 'truthy', value: '{{testVar}}' },\n        then: [],\n        else: [],\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('foreach routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [] } });\n\n      const step: TestStep = {\n        id: 'foreach_routing_legacy',\n        type: 'foreach',\n        listVar: 'items',\n        itemVar: 'item',\n        subflowId: 'subflow_1',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('while routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { counter: 0 } });\n\n      const step: TestStep = {\n        id: 'while_routing_legacy',\n        type: 'while',\n        condition: { type: 'compare', left: '{{counter}}', op: 'lt', right: 10 },\n        subflowId: 'subflow_1',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n\n    it('switchFrame routes to legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_routing_legacy',\n        type: 'switchFrame',\n        target: { kind: 'top' },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n    });\n  });\n\n  // ===========================================================================\n  // Script Defer Semantics (Legacy Behavior)\n  // ===========================================================================\n\n  describe('script defer semantics (legacy behavior)', () => {\n    it('script when=after returns deferAfterScript, not executed immediately', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_defer_after',\n        type: 'script',\n        code: 'console.log(\"deferred\");',\n        when: 'after',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      // Legacy behavior: when='after' returns deferAfterScript instead of executing\n      expect(result.result.deferAfterScript).toBeDefined();\n      // Script should NOT have been executed\n      expect(mocks.executeScript).not.toHaveBeenCalled();\n    });\n\n    it('script when=before executes immediately in legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_when_before_legacy',\n        type: 'script',\n        code: 'return \"immediate\";',\n        when: 'before',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      // Legacy executes when='before' scripts immediately\n      expect(mocks.executeScript).toHaveBeenCalled();\n      expect(result.result.deferAfterScript).toBeUndefined();\n    });\n\n    it('script without when executes immediately in legacy', async () => {\n      const executor = createExecutor();\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_no_when_legacy',\n        type: 'script',\n        code: 'return \"immediate\";',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('legacy');\n      expect(mocks.executeScript).toHaveBeenCalled();\n      expect(result.result.deferAfterScript).toBeUndefined();\n    });\n  });\n\n  // ===========================================================================\n  // Script Actions Opt-in Tests\n  // ===========================================================================\n\n  describe('script actions opt-in', () => {\n    it('script when=before routes to actions when allowlisted', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['script']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_actions_opt_in',\n        type: 'script',\n        code: 'return \"via_actions\";',\n        when: 'before',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.executeScript).toHaveBeenCalled();\n    });\n\n    it('script without when routes to actions when allowlisted', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['script']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_actions_no_when',\n        type: 'script',\n        code: 'return \"via_actions\";',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.executeScript).toHaveBeenCalled();\n    });\n\n    /**\n     * IMPORTANT: Even when script is allowlisted, when='after' should NOT be\n     * handled by actions because actions handler doesn't support defer semantics.\n     * This test documents the expected behavior - script with when='after' falls\n     * back to legacy even when script type is in allowlist.\n     */\n    it('script when=after falls back to legacy even when allowlisted (defer not supported)', async () => {\n      // This test documents expected behavior: actions handler validates when param\n      // but doesn't implement defer, so it will execute immediately if it handles it.\n      // The proper fix would be to add explicit step-level routing for when='after'.\n      // For now, this documents the current behavior.\n      const executor = createExecutor({ actionsAllowlist: new Set(['script']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'script_after_allowlisted',\n        type: 'script',\n        code: 'console.log(\"should defer\");',\n        when: 'after',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      // Note: Current behavior routes to actions and executes immediately.\n      // This is a known limitation documented in execution-mode.ts.\n      // Ideal behavior: should fall back to legacy for when='after'.\n      expect(result.executor).toBe('actions');\n    });\n\n    it('script with saveAs captures result', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['script']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: {} });\n\n      mocks.executeScript.mockResolvedValueOnce([\n        { result: { success: true, result: { data: 'captured' } } },\n      ]);\n\n      const step: TestStep = {\n        id: 'script_save_as',\n        type: 'script',\n        code: 'return { data: \"captured\" };',\n        saveAs: 'scriptOutput',\n      };\n\n      await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      // Actions handler stores result in ctx.vars\n      expect(ctx.vars.scriptOutput).toEqual({ data: 'captured' });\n    });\n  });\n\n  // ===========================================================================\n  // Control-Flow Actions Opt-in Tests\n  // ===========================================================================\n\n  describe('control-flow actions opt-in', () => {\n    it('if binary condition evaluates correctly in actions', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['if']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { isEnabled: true } });\n\n      const step: TestStep = {\n        id: 'if_binary_actions',\n        type: 'if',\n        mode: 'binary',\n        // Use correct VarValue format: { kind: 'var', ref: { name: 'varName' } }\n        condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'isEnabled' } } },\n        trueLabel: 'yes',\n        falseLabel: 'no',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(result.result.nextLabel).toBe('yes');\n    });\n\n    it('if binary condition false path', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['if']) });\n      // Set isEnabled to a falsy value (empty string)\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { isEnabled: '' } });\n\n      const step: TestStep = {\n        id: 'if_binary_false_actions',\n        type: 'if',\n        mode: 'binary',\n        // truthy check on empty string should return false\n        condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'isEnabled' } } },\n        trueLabel: 'yes',\n        falseLabel: 'no',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(result.result.nextLabel).toBe('no');\n    });\n\n    it('foreach with empty array returns success without control directive', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [] } });\n\n      const step: TestStep = {\n        id: 'foreach_empty_actions',\n        type: 'foreach',\n        listVar: 'items',\n        itemVar: 'item',\n        subflowId: 'sub_1',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // Empty array = no iteration needed\n      expect(result.result.control).toBeUndefined();\n    });\n\n    it('foreach with non-empty array returns control directive', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [1, 2, 3] } });\n\n      const step: TestStep = {\n        id: 'foreach_non_empty_actions',\n        type: 'foreach',\n        listVar: 'items',\n        itemVar: 'current',\n        subflowId: 'sub_1',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(result.result.control).toMatchObject({\n        kind: 'foreach',\n        listVar: 'items',\n        itemVar: 'current',\n        subflowId: 'sub_1',\n      });\n    });\n\n    it('while with false condition returns success without control directive', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['while']) });\n      // shouldLoop=false will make truthy check return false\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { shouldLoop: false } });\n\n      const step: TestStep = {\n        id: 'while_false_actions',\n        type: 'while',\n        // truthy check on false will evaluate to false\n        condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'shouldLoop' } } },\n        subflowId: 'sub_1',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // shouldLoop=false, so truthy condition is false, no loop\n      expect(result.result.control).toBeUndefined();\n    });\n\n    it('while with true condition returns control directive', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['while']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { shouldLoop: true } });\n\n      const step: TestStep = {\n        id: 'while_true_actions',\n        type: 'while',\n        // Use truthy condition which should evaluate to true\n        condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'shouldLoop' } } },\n        subflowId: 'sub_1',\n        maxIterations: 50,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(result.result.control).toMatchObject({\n        kind: 'while',\n        subflowId: 'sub_1',\n        maxIterations: 50,\n      });\n    });\n\n    it('switchFrame to top frame', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: CHILD_FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_top_actions',\n        type: 'switchFrame',\n        target: { kind: 'top' },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // ctx.frameId should be updated to 0 (top frame)\n      expect(ctx.frameId).toBe(0);\n    });\n\n    it('switchFrame by urlContains', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_url_actions',\n        type: 'switchFrame',\n        target: { kind: 'urlContains', value: 'ads.example.com' },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // ctx.frameId should be updated to the matching frame (456)\n      expect(ctx.frameId).toBe(456);\n    });\n\n    it('switchFrame by index', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_index_actions',\n        type: 'switchFrame',\n        target: { kind: 'index', index: 0 },\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      // First child frame (excluding main frame) is at index 0\n      // Our mock returns frameId 123 as first child\n      expect(ctx.frameId).toBe(CHILD_FRAME_ID);\n    });\n\n    it('switchFrame fails when no matching frame found', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_not_found',\n        type: 'switchFrame',\n        target: { kind: 'urlContains', value: 'nonexistent.com' },\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /FRAME_NOT_FOUND|no matching frame/i,\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Error Handling Tests\n  // ===========================================================================\n\n  describe('error handling', () => {\n    it('script fails when execution throws', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['script']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      mocks.executeScript.mockRejectedValueOnce(new Error('Script execution blocked'));\n\n      const step: TestStep = {\n        id: 'script_error',\n        type: 'script',\n        code: 'throw new Error(\"test\");',\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /Script execution|failed/i,\n      );\n    });\n\n    it('foreach fails when listVar is not an array', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: 'not an array' } });\n\n      const step: TestStep = {\n        id: 'foreach_invalid_list',\n        type: 'foreach',\n        listVar: 'items',\n        itemVar: 'item',\n        subflowId: 'sub_1',\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /not an array|VALIDATION_ERROR/i,\n      );\n    });\n\n    it('switchFrame fails when tab has no frames', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      mocks.getAllFrames.mockResolvedValueOnce([]);\n\n      const step: TestStep = {\n        id: 'switchFrame_no_frames',\n        type: 'switchFrame',\n        target: { kind: 'index', index: 0 },\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /FRAME_NOT_FOUND|no frames/i,\n      );\n    });\n\n    it('switchFrame fails when index out of bounds', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });\n      const ctx = createMockExecCtx({ frameId: FRAME_ID });\n\n      const step: TestStep = {\n        id: 'switchFrame_out_of_bounds',\n        type: 'switchFrame',\n        target: { kind: 'index', index: 999 },\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /FRAME_NOT_FOUND|out of bounds/i,\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/session-dag-sync.contract.test.ts",
    "content": "/**\n * Session DAG Sync Contract Tests\n *\n * Verifies that RecordingSessionManager correctly maintains flow.nodes/edges\n * during recording:\n * - New step → create node + edge from previous node\n * - Upsert step → update node.config and node.type\n * - Invariant violation → fallback to linear DAG rebuild\n *\n * Note: flow.steps is no longer written. Nodes are the source of truth.\n */\n\nimport { describe, expect, it, beforeEach } from 'vitest';\nimport { RecordingSessionManager } from '@/entrypoints/background/record-replay/recording/session-manager';\nimport type { Flow, Step } from '@/entrypoints/background/record-replay/types';\n\nfunction createTestFlow(overrides: Partial<Flow> = {}): Flow {\n  return {\n    id: `test_flow_${Date.now()}`,\n    name: 'Test Flow',\n    version: 1,\n    steps: [],\n    meta: {\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n    },\n    ...overrides,\n  };\n}\n\nfunction createTestStep(type: string, id?: string, overrides: Record<string, unknown> = {}): Step {\n  return {\n    id: id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n    type,\n    ...overrides,\n  } as Step;\n}\n\ndescribe('RecordingSessionManager DAG sync', () => {\n  let manager: RecordingSessionManager;\n\n  beforeEach(async () => {\n    manager = new RecordingSessionManager();\n  });\n\n  describe('appendSteps creates nodes/edges', () => {\n    it('creates node for first step without edge', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([createTestStep('click', 'step1')]);\n\n      const f = manager.getFlow()!;\n      expect(f.nodes).toHaveLength(1);\n      expect(f.nodes![0].id).toBe('step1');\n      expect(f.nodes![0].type).toBe('click');\n      expect(f.edges).toHaveLength(0); // No edge for first step\n    });\n\n    it('creates node and edge for subsequent steps', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([createTestStep('click', 'step1')]);\n      manager.appendSteps([createTestStep('fill', 'step2', { value: 'hello' })]);\n\n      const f = manager.getFlow()!;\n      expect(f.nodes).toHaveLength(2);\n      expect(f.nodes![1].id).toBe('step2');\n      expect(f.nodes![1].type).toBe('fill');\n\n      expect(f.edges).toHaveLength(1);\n      expect(f.edges![0].from).toBe('step1');\n      expect(f.edges![0].to).toBe('step2');\n    });\n\n    it('creates correct chain for multiple steps in single batch', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([\n        createTestStep('navigate', 'step1', { url: 'https://example.com' }),\n        createTestStep('click', 'step2'),\n        createTestStep('fill', 'step3', { value: 'test' }),\n      ]);\n\n      const f = manager.getFlow()!;\n      // Note: flow.steps is no longer written, nodes are the source of truth\n      expect(f.nodes).toHaveLength(3);\n      expect(f.edges).toHaveLength(2);\n\n      // Verify chain: step1 → step2 → step3\n      expect(f.edges![0].from).toBe('step1');\n      expect(f.edges![0].to).toBe('step2');\n      expect(f.edges![1].from).toBe('step2');\n      expect(f.edges![1].to).toBe('step3');\n    });\n  });\n\n  describe('upsert updates node config', () => {\n    it('updates node config when step is upserted', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      // Initial step\n      manager.appendSteps([createTestStep('fill', 'step1', { value: 'initial' })]);\n\n      // Upsert with new value\n      manager.appendSteps([createTestStep('fill', 'step1', { value: 'updated' })]);\n\n      const f = manager.getFlow()!;\n      // Note: flow.steps is no longer written, nodes are the source of truth\n      expect(f.nodes).toHaveLength(1);\n      expect(f.nodes![0].config?.value).toBe('updated');\n    });\n\n    it('preserves edges when upserting', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([\n        createTestStep('click', 'step1'),\n        createTestStep('fill', 'step2', { value: 'initial' }),\n      ]);\n\n      // Upsert step2\n      manager.appendSteps([createTestStep('fill', 'step2', { value: 'updated' })]);\n\n      const f = manager.getFlow()!;\n      expect(f.edges).toHaveLength(1);\n      expect(f.edges![0].from).toBe('step1');\n      expect(f.edges![0].to).toBe('step2');\n    });\n  });\n\n  describe('invariant handling', () => {\n    it('rebuilds DAG from legacy steps when nodes missing', async () => {\n      // Create flow with steps but no nodes (legacy scenario)\n      const flow = createTestFlow({\n        steps: [\n          { id: 'existing1', type: 'click' } as any,\n          { id: 'existing2', type: 'fill', value: 'test' } as any,\n        ],\n        nodes: undefined,\n        edges: undefined,\n      });\n      await manager.startSession(flow, 1);\n\n      // Append new step - should trigger rebuild from legacy steps first\n      manager.appendSteps([createTestStep('navigate', 'step3', { url: 'https://test.com' })]);\n\n      const f = manager.getFlow()!;\n      // Should have rebuilt: 2 existing (from legacy steps) + 1 new = 3\n      expect(f.nodes).toHaveLength(3);\n      expect(f.edges).toHaveLength(2);\n    });\n\n    it('handles empty flow gracefully', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      // Empty appendSteps should be no-op\n      manager.appendSteps([]);\n\n      const f = manager.getFlow()!;\n      // nodes/edges may be undefined when no steps added, that's valid\n      expect(f.nodes?.length ?? 0).toBe(0);\n      expect(f.edges?.length ?? 0).toBe(0);\n    });\n  });\n\n  describe('session lifecycle', () => {\n    it('clears caches on session stop', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([createTestStep('click', 'step1')]);\n\n      const stoppedFlow = await manager.stopSession();\n\n      expect(stoppedFlow).not.toBeNull();\n      expect(stoppedFlow!.nodes).toHaveLength(1);\n\n      // After stop, manager should have no flow\n      expect(manager.getFlow()).toBeNull();\n    });\n\n    it('reinitializes caches on new session', async () => {\n      // First session\n      const flow1 = createTestFlow({ id: 'flow1' });\n      await manager.startSession(flow1, 1);\n      manager.appendSteps([createTestStep('click', 'step1')]);\n      await manager.stopSession();\n\n      // Second session - should have fresh state\n      const flow2 = createTestFlow({ id: 'flow2' });\n      await manager.startSession(flow2, 2);\n      manager.appendSteps([createTestStep('fill', 'step2')]);\n\n      const f = manager.getFlow()!;\n      expect(f.id).toBe('flow2');\n      // Note: flow.steps is no longer written, nodes are the source of truth\n      expect(f.nodes).toHaveLength(1);\n      expect(f.nodes![0].id).toBe('step2');\n    });\n  });\n\n  describe('node type conversion', () => {\n    it('converts valid step types to node types', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([\n        createTestStep('click', 'step1'),\n        createTestStep('fill', 'step2'),\n        createTestStep('navigate', 'step3'),\n        createTestStep('scroll', 'step4'),\n      ]);\n\n      const f = manager.getFlow()!;\n      expect(f.nodes![0].type).toBe('click');\n      expect(f.nodes![1].type).toBe('fill');\n      expect(f.nodes![2].type).toBe('navigate');\n      expect(f.nodes![3].type).toBe('scroll');\n    });\n\n    it('falls back to script for unknown types', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([createTestStep('unknown_type_xyz', 'step1')]);\n\n      const f = manager.getFlow()!;\n      expect(f.nodes![0].type).toBe('script');\n    });\n  });\n\n  describe('edge id uniqueness', () => {\n    it('generates unique edge ids', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      manager.appendSteps([\n        createTestStep('click', 's1'),\n        createTestStep('click', 's2'),\n        createTestStep('click', 's3'),\n        createTestStep('click', 's4'),\n      ]);\n\n      const f = manager.getFlow()!;\n      const edgeIds = f.edges!.map((e) => e.id);\n      const uniqueIds = new Set(edgeIds);\n\n      expect(uniqueIds.size).toBe(edgeIds.length);\n    });\n\n    it('uses monotonic sequence for edge ids', async () => {\n      const flow = createTestFlow();\n      await manager.startSession(flow, 1);\n\n      // Add steps in multiple batches\n      manager.appendSteps([createTestStep('click', 's1')]);\n      manager.appendSteps([createTestStep('click', 's2')]);\n      manager.appendSteps([createTestStep('click', 's3')]);\n\n      const f = manager.getFlow()!;\n      // Edge ids should contain sequential numbers\n      expect(f.edges![0].id).toMatch(/^e_0_/);\n      expect(f.edges![1].id).toMatch(/^e_1_/);\n    });\n  });\n\n  describe('edge invariant handling', () => {\n    it('rechains edges when edges are missing', async () => {\n      // Create flow with nodes but missing edges\n      const flow = createTestFlow({\n        nodes: [\n          { id: 's1', type: 'click', config: {} },\n          { id: 's2', type: 'fill', config: {} },\n        ],\n        edges: [], // Missing edges!\n      });\n      await manager.startSession(flow, 1);\n\n      // Append should trigger rechain due to edge invariant violation\n      manager.appendSteps([createTestStep('navigate', 's3')]);\n\n      const f = manager.getFlow()!;\n      expect(f.nodes).toHaveLength(3);\n      // Should have rechained edges: s1→s2→s3\n      expect(f.edges).toHaveLength(2);\n    });\n\n    it('rechains edges when last edge points to wrong node', async () => {\n      // Create flow with corrupted edge pointing to wrong target\n      const flow = createTestFlow({\n        nodes: [\n          { id: 's1', type: 'click', config: {} },\n          { id: 's2', type: 'fill', config: {} },\n        ],\n        edges: [{ id: 'e_0', from: 's1', to: 'wrong_id' }], // Wrong target!\n      });\n      await manager.startSession(flow, 1);\n\n      // Append should trigger rechain due to edge invariant violation\n      manager.appendSteps([createTestStep('navigate', 's3')]);\n\n      const f = manager.getFlow()!;\n      expect(f.edges).toHaveLength(2);\n      // Last edge should point to last node\n      expect(f.edges![1].to).toBe('s3');\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/step-executor.contract.test.ts",
    "content": "/**\n * Step Executor Routing Contract Tests\n *\n * Verifies that step execution routes correctly based on ExecutionModeConfig:\n * - legacy mode: always uses legacy executeStep\n * - hybrid mode: uses actions for allowlisted types, legacy for others\n * - actions mode: always uses ActionRegistry (strict)\n */\n\nimport { describe, expect, it, vi, beforeEach } from 'vitest';\n\n// Mock legacy executeStep - must be defined inline in vi.mock factory\nvi.mock('@/entrypoints/background/record-replay/nodes', () => ({\n  executeStep: vi.fn(async () => ({})),\n}));\n\n// Mock createStepExecutor from adapter - must be defined inline in vi.mock factory\nvi.mock('@/entrypoints/background/record-replay/actions/adapter', () => ({\n  createStepExecutor: vi.fn(() => vi.fn(async () => ({ supported: true, result: {} }))),\n  isActionSupported: vi.fn((type: string) => {\n    const supported = ['fill', 'key', 'scroll', 'click', 'navigate', 'delay', 'wait'];\n    return supported.includes(type);\n  }),\n}));\n\nimport { createMockExecCtx, createMockStep, createMockRegistry } from './_test-helpers';\nimport {\n  DEFAULT_EXECUTION_MODE_CONFIG,\n  createHybridConfig,\n  createActionsOnlyConfig,\n  MINIMAL_HYBRID_ACTION_TYPES,\n} from '@/entrypoints/background/record-replay/engine/execution-mode';\nimport {\n  LegacyStepExecutor,\n  ActionsStepExecutor,\n  HybridStepExecutor,\n  createExecutor,\n} from '@/entrypoints/background/record-replay/engine/runners/step-executor';\nimport { executeStep as legacyExecuteStep } from '@/entrypoints/background/record-replay/nodes';\nimport { createStepExecutor as createAdapterExecutor } from '@/entrypoints/background/record-replay/actions/adapter';\n\ndescribe('ExecutionModeConfig contract', () => {\n  describe('DEFAULT_EXECUTION_MODE_CONFIG', () => {\n    it('defaults to legacy mode', () => {\n      expect(DEFAULT_EXECUTION_MODE_CONFIG.mode).toBe('legacy');\n    });\n\n    it('defaults skipActionsRetry to true', () => {\n      expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsRetry).toBe(true);\n    });\n\n    it('defaults skipActionsNavWait to true', () => {\n      expect(DEFAULT_EXECUTION_MODE_CONFIG.skipActionsNavWait).toBe(true);\n    });\n  });\n\n  describe('createHybridConfig', () => {\n    it('sets mode to hybrid', () => {\n      const config = createHybridConfig();\n      expect(config.mode).toBe('hybrid');\n    });\n\n    it('uses MINIMAL_HYBRID_ACTION_TYPES as default allowlist', () => {\n      const config = createHybridConfig();\n      expect(config.actionsAllowlist).toBeDefined();\n      expect(config.actionsAllowlist?.has('fill')).toBe(true);\n      expect(config.actionsAllowlist?.has('key')).toBe(true);\n      expect(config.actionsAllowlist?.has('scroll')).toBe(true);\n      // High-risk types should NOT be in minimal allowlist\n      expect(config.actionsAllowlist?.has('click')).toBe(false);\n      expect(config.actionsAllowlist?.has('navigate')).toBe(false);\n    });\n\n    it('allows overriding actionsAllowlist', () => {\n      const config = createHybridConfig({\n        actionsAllowlist: new Set(['fill', 'click']),\n      });\n      expect(config.actionsAllowlist?.has('fill')).toBe(true);\n      expect(config.actionsAllowlist?.has('click')).toBe(true);\n      expect(config.actionsAllowlist?.has('key')).toBe(false);\n    });\n  });\n\n  describe('createActionsOnlyConfig', () => {\n    it('sets mode to actions', () => {\n      const config = createActionsOnlyConfig();\n      expect(config.mode).toBe('actions');\n    });\n\n    it('keeps StepRunner as policy authority (skip flags true)', () => {\n      const config = createActionsOnlyConfig();\n      expect(config.skipActionsRetry).toBe(true);\n      expect(config.skipActionsNavWait).toBe(true);\n    });\n  });\n});\n\ndescribe('LegacyStepExecutor', () => {\n  const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;\n\n  beforeEach(() => {\n    mockLegacyExecuteStep.mockClear();\n  });\n\n  it('always uses legacy executeStep', async () => {\n    const executor = new LegacyStepExecutor();\n    const ctx = createMockExecCtx();\n    const step = createMockStep('fill');\n\n    await executor.execute(ctx, step, { tabId: 1 });\n\n    expect(mockLegacyExecuteStep).toHaveBeenCalledWith(ctx, step);\n  });\n\n  it('returns executor type as legacy', async () => {\n    const executor = new LegacyStepExecutor();\n    const result = await executor.execute(createMockExecCtx(), createMockStep('click'), {\n      tabId: 1,\n    });\n\n    expect(result.executor).toBe('legacy');\n  });\n\n  it('supports all step types', () => {\n    const executor = new LegacyStepExecutor();\n    expect(executor.supports('fill')).toBe(true);\n    expect(executor.supports('unknown_type')).toBe(true);\n  });\n});\n\ndescribe('HybridStepExecutor routing', () => {\n  const mockLegacyExecuteStep = legacyExecuteStep as ReturnType<typeof vi.fn>;\n\n  beforeEach(() => {\n    mockLegacyExecuteStep.mockClear();\n  });\n\n  it('uses legacy for non-allowlisted types', async () => {\n    const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });\n    const mockReg = createMockRegistry();\n    const executor = new HybridStepExecutor(mockReg as any, config);\n\n    await executor.execute(\n      createMockExecCtx(),\n      createMockStep('click', { target: { candidates: [] } }),\n      { tabId: 1 },\n    );\n\n    expect(mockLegacyExecuteStep).toHaveBeenCalled();\n  });\n\n  it('returns legacy executor type for non-allowlisted types', async () => {\n    const config = createHybridConfig({ actionsAllowlist: new Set(['fill']) });\n    const mockReg = createMockRegistry();\n    const executor = new HybridStepExecutor(mockReg as any, config);\n\n    const result = await executor.execute(\n      createMockExecCtx(),\n      createMockStep('navigate', { url: 'https://example.com' }),\n      { tabId: 1 },\n    );\n\n    expect(result.executor).toBe('legacy');\n  });\n});\n\ndescribe('createExecutor factory', () => {\n  it('creates LegacyStepExecutor for legacy mode', () => {\n    const executor = createExecutor({ ...DEFAULT_EXECUTION_MODE_CONFIG, mode: 'legacy' });\n    expect(executor).toBeInstanceOf(LegacyStepExecutor);\n  });\n\n  it('creates ActionsStepExecutor for actions mode', () => {\n    const mockReg = createMockRegistry();\n    const executor = createExecutor(createActionsOnlyConfig(), mockReg as any);\n    expect(executor).toBeInstanceOf(ActionsStepExecutor);\n  });\n\n  it('creates HybridStepExecutor for hybrid mode', () => {\n    const mockReg = createMockRegistry();\n    const executor = createExecutor(createHybridConfig(), mockReg as any);\n    expect(executor).toBeInstanceOf(HybridStepExecutor);\n  });\n\n  it('throws if actions mode has no registry', () => {\n    expect(() => createExecutor(createActionsOnlyConfig())).toThrow(\n      'ActionRegistry required for actions execution mode',\n    );\n  });\n\n  it('throws if hybrid mode has no registry', () => {\n    expect(() => createExecutor(createHybridConfig())).toThrow(\n      'ActionRegistry required for hybrid execution mode',\n    );\n  });\n});\n\ndescribe('MINIMAL_HYBRID_ACTION_TYPES', () => {\n  it('contains only low-risk action types', () => {\n    const expected = ['fill', 'key', 'scroll', 'drag', 'wait', 'delay', 'screenshot', 'assert'];\n    for (const type of expected) {\n      expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(true);\n    }\n  });\n\n  it('excludes high-risk types (navigate, click, tab management)', () => {\n    const excluded = ['navigate', 'click', 'dblclick', 'openTab', 'switchTab', 'closeTab'];\n    for (const type of excluded) {\n      expect(MINIMAL_HYBRID_ACTION_TYPES.has(type)).toBe(false);\n    }\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay/tab-cursor.integration.test.ts",
    "content": "/**\n * Tab Cursor Integration Tests (M3-full batch 2)\n *\n * Purpose:\n *   Test tab management operations (openTab, switchTab) and verify their behavior,\n *   including ctx.tabId cursor updates after tab operations (M3 requirement).\n *\n * Test Strategy:\n *   - Use real HybridStepExecutor + real ActionRegistry + real tab handlers\n *   - Mock only environment boundaries (chrome.* APIs)\n *\n * Coverage:\n *   - Basic tab operations: openTab with newWindow, switchTab by urlContains\n *   - Tab cursor sync: ctx.tabId updated and used by subsequent steps\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\n// =============================================================================\n// Mock Setup (using vi.hoisted for proper hoisting)\n// =============================================================================\n\nconst mocks = vi.hoisted(() => ({\n  handleCallTool: vi.fn(),\n  locate: vi.fn(),\n  tabsQuery: vi.fn(),\n  tabsGet: vi.fn(),\n  tabsCreate: vi.fn(),\n  tabsUpdate: vi.fn(),\n  windowsCreate: vi.fn(),\n  windowsUpdate: vi.fn(),\n}));\n\n// Mock tool bridge\nvi.mock('@/entrypoints/background/tools', () => ({\n  handleCallTool: mocks.handleCallTool,\n}));\n\n// Mock selector locator\nvi.mock('@/shared/selector', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('@/shared/selector')>();\n  return {\n    ...actual,\n    createChromeSelectorLocator: () => ({\n      locate: mocks.locate,\n    }),\n  };\n});\n\n// =============================================================================\n// Imports (after mocks)\n// =============================================================================\n\nimport { createMockExecCtx } from './_test-helpers';\nimport { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode';\nimport { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor';\nimport { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions';\n\n// =============================================================================\n// Test Constants\n// =============================================================================\n\nconst TAB_ID = 1;\nconst NEW_TAB_ID = 101;\nconst TARGET_TAB_ID = 42;\nconst TARGET_WINDOW_ID = 999;\n\n// =============================================================================\n// Helper Types and Functions\n// =============================================================================\n\ninterface TestStep {\n  id: string;\n  type: string;\n  [key: string]: unknown;\n}\n\n/**\n * Create executor with configurable hybrid config\n */\nfunction createExecutor(overrides?: Parameters<typeof createHybridConfig>[0]): HybridStepExecutor {\n  const registry = createReplayActionRegistry();\n  const config = createHybridConfig(overrides);\n  return new HybridStepExecutor(registry, config);\n}\n\n/**\n * Setup default mock responses for handleCallTool\n */\nfunction setupDefaultToolMock(): void {\n  mocks.handleCallTool.mockImplementation(async () => ({}));\n}\n\n// =============================================================================\n// Test Suite\n// =============================================================================\n\ndescribe('tab cursor integration (M3-full batch 2)', () => {\n  beforeEach(() => {\n    // Reset all mocks\n    Object.values(mocks).forEach((mock) => mock.mockReset());\n    setupDefaultToolMock();\n\n    // Default selector locate result\n    mocks.locate.mockResolvedValue({ ref: 'ref_default', frameId: 0, resolvedBy: 'css' });\n\n    // Default tabs.query returns current tab\n    mocks.tabsQuery.mockResolvedValue([\n      {\n        id: TAB_ID,\n        url: 'https://example.com/',\n        title: 'Example',\n        windowId: 1,\n        status: 'complete',\n      },\n    ]);\n\n    // Default tabs.get returns tab info\n    mocks.tabsGet.mockImplementation(async (tabId: number) => ({\n      id: tabId,\n      url: 'https://example.com/',\n      windowId: TARGET_WINDOW_ID,\n      status: 'complete',\n    }));\n\n    // Default tab/window creation\n    mocks.tabsCreate.mockResolvedValue({ id: NEW_TAB_ID });\n    mocks.tabsUpdate.mockResolvedValue({});\n    mocks.windowsCreate.mockResolvedValue({ tabs: [{ id: NEW_TAB_ID }] });\n    mocks.windowsUpdate.mockResolvedValue({});\n\n    // Stub chrome.* globals\n    vi.stubGlobal('chrome', {\n      tabs: {\n        query: mocks.tabsQuery,\n        get: mocks.tabsGet,\n        create: mocks.tabsCreate,\n        update: mocks.tabsUpdate,\n      },\n      windows: {\n        create: mocks.windowsCreate,\n        update: mocks.windowsUpdate,\n      },\n    });\n  });\n\n  afterEach(() => {\n    vi.unstubAllGlobals();\n  });\n\n  // ===========================================================================\n  // ctx.tabId Sync Tests\n  // ===========================================================================\n\n  describe('ctx.tabId sync after tab operations', () => {\n    it('openTab updates ctx.tabId for subsequent steps', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['openTab', 'click']) });\n      const ctx = createMockExecCtx({ tabId: TAB_ID });\n\n      const openStep: TestStep = {\n        id: 'openTab_updates_ctx_tabId',\n        type: 'openTab',\n        newWindow: false,\n      };\n\n      await executor.execute(ctx, openStep as never, { tabId: ctx.tabId ?? TAB_ID });\n\n      // ctx.tabId should be updated to the new tab\n      expect(ctx.tabId).toBe(NEW_TAB_ID);\n\n      // Verify subsequent step uses the new tabId\n      mocks.locate.mockResolvedValueOnce(undefined);\n\n      const clickStep: TestStep = {\n        id: 'click_after_openTab',\n        type: 'click',\n        target: {\n          candidates: [{ type: 'css', value: '#btn' }],\n        },\n      };\n\n      await executor.execute(ctx, clickStep as never, { tabId: ctx.tabId ?? TAB_ID });\n\n      // The click tool should be called with the NEW_TAB_ID\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          args: expect.objectContaining({ tabId: NEW_TAB_ID }),\n        }),\n      );\n    });\n\n    it('switchTab updates ctx.tabId for subsequent steps', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchTab', 'click']) });\n      const ctx = createMockExecCtx({ tabId: TAB_ID });\n\n      // Setup tabs.query to return multiple tabs\n      mocks.tabsQuery.mockResolvedValueOnce([\n        {\n          id: TAB_ID,\n          url: 'https://example.com/',\n          title: 'Example',\n          windowId: 1,\n          status: 'complete',\n        },\n        {\n          id: TARGET_TAB_ID,\n          url: 'https://docs.example.com/',\n          title: 'Docs',\n          windowId: TARGET_WINDOW_ID,\n          status: 'complete',\n        },\n      ]);\n\n      const switchStep: TestStep = {\n        id: 'switchTab_updates_ctx_tabId',\n        type: 'switchTab',\n        urlContains: 'docs.example.com',\n      };\n\n      await executor.execute(ctx, switchStep as never, { tabId: ctx.tabId ?? TAB_ID });\n\n      // ctx.tabId should be updated to the target tab\n      expect(ctx.tabId).toBe(TARGET_TAB_ID);\n\n      // Verify subsequent step uses the new tabId\n      mocks.locate.mockResolvedValueOnce(undefined);\n\n      const clickStep: TestStep = {\n        id: 'click_after_switchTab',\n        type: 'click',\n        target: {\n          candidates: [{ type: 'css', value: '#btn' }],\n        },\n      };\n\n      await executor.execute(ctx, clickStep as never, { tabId: ctx.tabId ?? TAB_ID });\n\n      // The click tool should be called with the TARGET_TAB_ID\n      expect(mocks.handleCallTool).toHaveBeenCalledWith(\n        expect.objectContaining({\n          args: expect.objectContaining({ tabId: TARGET_TAB_ID }),\n        }),\n      );\n    });\n  });\n\n  // ===========================================================================\n  // Basic Tab Operations Tests\n  // ===========================================================================\n\n  describe('basic tab operations', () => {\n    it('openTab success with new window', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['openTab']) });\n      const ctx = createMockExecCtx();\n\n      const step: TestStep = {\n        id: 'openTab_newWindow_success',\n        type: 'openTab',\n        newWindow: true,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.windowsCreate).toHaveBeenCalledWith(\n        expect.objectContaining({ url: 'about:blank', focused: true }),\n      );\n    });\n\n    it('openTab success with new tab in current window', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['openTab']) });\n      const ctx = createMockExecCtx();\n\n      const step: TestStep = {\n        id: 'openTab_newTab_success',\n        type: 'openTab',\n        url: 'https://example.com/new-page',\n        newWindow: false,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.tabsCreate).toHaveBeenCalledWith(\n        expect.objectContaining({ url: 'https://example.com/new-page', active: true }),\n      );\n    });\n\n    it('switchTab finds tab by urlContains', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchTab']) });\n      const ctx = createMockExecCtx();\n\n      // Setup tabs.query to return multiple tabs\n      mocks.tabsQuery.mockResolvedValueOnce([\n        {\n          id: TAB_ID,\n          url: 'https://example.com/',\n          title: 'Example',\n          windowId: 1,\n          status: 'complete',\n        },\n        {\n          id: TARGET_TAB_ID,\n          url: 'https://docs.example.com/',\n          title: 'Docs',\n          windowId: TARGET_WINDOW_ID,\n          status: 'complete',\n        },\n      ]);\n\n      // Setup tabs.get to return the target tab\n      mocks.tabsGet.mockResolvedValueOnce({\n        id: TARGET_TAB_ID,\n        url: 'https://docs.example.com/',\n        windowId: TARGET_WINDOW_ID,\n        status: 'complete',\n      });\n\n      const step: TestStep = {\n        id: 'switchTab_urlContains_success',\n        type: 'switchTab',\n        urlContains: 'docs.example.com',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.tabsUpdate).toHaveBeenCalledWith(TARGET_TAB_ID, { active: true });\n      expect(mocks.windowsUpdate).toHaveBeenCalledWith(TARGET_WINDOW_ID, { focused: true });\n    });\n\n    it('switchTab finds tab by titleContains', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchTab']) });\n      const ctx = createMockExecCtx();\n\n      // Setup tabs.query to return multiple tabs\n      mocks.tabsQuery.mockResolvedValueOnce([\n        {\n          id: TAB_ID,\n          url: 'https://example.com/',\n          title: 'Home Page',\n          windowId: 1,\n          status: 'complete',\n        },\n        {\n          id: TARGET_TAB_ID,\n          url: 'https://example.com/settings',\n          title: 'Settings - My Account',\n          windowId: TARGET_WINDOW_ID,\n          status: 'complete',\n        },\n      ]);\n\n      mocks.tabsGet.mockResolvedValueOnce({\n        id: TARGET_TAB_ID,\n        url: 'https://example.com/settings',\n        windowId: TARGET_WINDOW_ID,\n        status: 'complete',\n      });\n\n      const step: TestStep = {\n        id: 'switchTab_titleContains_success',\n        type: 'switchTab',\n        titleContains: 'Settings',\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.tabsUpdate).toHaveBeenCalledWith(TARGET_TAB_ID, { active: true });\n    });\n\n    it('switchTab by explicit tabId', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchTab']) });\n      const ctx = createMockExecCtx();\n\n      mocks.tabsGet.mockResolvedValueOnce({\n        id: TARGET_TAB_ID,\n        url: 'https://example.com/',\n        windowId: TARGET_WINDOW_ID,\n        status: 'complete',\n      });\n\n      const step: TestStep = {\n        id: 'switchTab_byId_success',\n        type: 'switchTab',\n        tabId: TARGET_TAB_ID,\n      };\n\n      const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });\n\n      expect(result.executor).toBe('actions');\n      expect(mocks.tabsUpdate).toHaveBeenCalledWith(TARGET_TAB_ID, { active: true });\n      expect(mocks.windowsUpdate).toHaveBeenCalledWith(TARGET_WINDOW_ID, { focused: true });\n    });\n\n    it('switchTab fails when no matching tab found', async () => {\n      const executor = createExecutor({ actionsAllowlist: new Set(['switchTab']) });\n      const ctx = createMockExecCtx();\n\n      // Setup tabs.query to return only tabs that don't match\n      mocks.tabsQuery.mockResolvedValueOnce([\n        {\n          id: TAB_ID,\n          url: 'https://example.com/',\n          title: 'Example',\n          windowId: 1,\n          status: 'complete',\n        },\n      ]);\n\n      const step: TestStep = {\n        id: 'switchTab_not_found',\n        type: 'switchTab',\n        urlContains: 'nonexistent.example.com',\n      };\n\n      await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(\n        /TAB_NOT_FOUND|no matching tab/i,\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/command-trigger.test.ts",
    "content": "/**\n * @fileoverview Command Trigger Handler 测试 (P4-04)\n * @description\n * Tests for:\n * - Command event handling\n * - Listener lifecycle\n * - CommandKey mapping\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createCommandTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/command-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface CommandsMock {\n  onCommand: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emitCommand: (command: string, tab?: { id?: number; url?: string }) => void;\n}\n\nfunction createCommandsMock(): CommandsMock {\n  const listeners = new Set<(command: string, tab?: { id?: number; url?: string }) => void>();\n\n  const onCommand = {\n    addListener: vi.fn((cb: (command: string, tab?: { id?: number; url?: string }) => void) => {\n      listeners.add(cb);\n    }),\n    removeListener: vi.fn((cb: (command: string, tab?: { id?: number; url?: string }) => void) => {\n      listeners.delete(cb);\n    }),\n  };\n\n  return {\n    onCommand,\n    emitCommand: (command, tab) => {\n      for (const cb of listeners) cb(command, tab);\n    },\n  };\n}\n\n// ==================== Command Trigger Tests ====================\n\ndescribe('V3 CommandTriggerHandler', () => {\n  let commandsMock: CommandsMock;\n\n  beforeEach(() => {\n    commandsMock = createCommandsMock();\n    (globalThis.chrome as unknown as { commands: unknown }).commands = {\n      onCommand: commandsMock.onCommand,\n    };\n  });\n\n  describe('Command handling', () => {\n    it('fires on matching command', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'run-flow-1',\n      };\n\n      await handler.install(trigger);\n\n      commandsMock.emitCommand('run-flow-1', { id: 123, url: 'https://example.com' });\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: 123,\n        sourceUrl: 'https://example.com',\n      });\n    });\n\n    it('ignores non-matching command', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'run-flow-1',\n      };\n\n      await handler.install(trigger);\n\n      commandsMock.emitCommand('run-flow-2');\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('handles command without tab info', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'run-flow-1',\n      };\n\n      await handler.install(trigger);\n\n      commandsMock.emitCommand('run-flow-1');\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: undefined,\n        sourceUrl: undefined,\n      });\n    });\n  });\n\n  describe('Multiple triggers', () => {\n    it('handles multiple command triggers', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'cmd-1',\n      };\n\n      const t2: TriggerSpecByKind<'command'> = {\n        id: 't2' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        commandKey: 'cmd-2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      commandsMock.emitCommand('cmd-1');\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', expect.anything());\n\n      commandsMock.emitCommand('cmd-2');\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t2', expect.anything());\n    });\n\n    it('overwrites when same commandKey used', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const warnFn = vi.fn();\n      const handler = createCommandTriggerHandlerFactory({\n        logger: { ...createSilentLogger(), warn: warnFn },\n      })(fireCallback);\n\n      const t1: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'same-cmd',\n      };\n\n      const t2: TriggerSpecByKind<'command'> = {\n        id: 't2' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        commandKey: 'same-cmd',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      // Should warn about overwriting\n      expect(warnFn).toHaveBeenCalled();\n\n      // Only t2 should be called\n      commandsMock.emitCommand('same-cmd');\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t2', expect.anything());\n\n      // t1 should be removed from installed\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n\n  describe('Listener lifecycle', () => {\n    it('registers listener on first install', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'cmd-1',\n      };\n\n      await handler.install(trigger);\n\n      expect(commandsMock.onCommand.addListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('removes listener when all triggers uninstalled', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'cmd-1',\n      };\n\n      const t2: TriggerSpecByKind<'command'> = {\n        id: 't2' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        commandKey: 'cmd-2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      await handler.uninstall('t1');\n      expect(commandsMock.onCommand.removeListener).not.toHaveBeenCalled();\n\n      await handler.uninstall('t2');\n      expect(commandsMock.onCommand.removeListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('removes listener on uninstallAll', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'cmd-1',\n      };\n\n      await handler.install(trigger);\n      await handler.uninstallAll();\n\n      expect(commandsMock.onCommand.removeListener).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns installed trigger IDs', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCommandTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'command'> = {\n        id: 't1' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        commandKey: 'cmd-1',\n      };\n\n      const t2: TriggerSpecByKind<'command'> = {\n        id: 't2' as never,\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        commandKey: 'cmd-2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n\n      await handler.uninstall('t1');\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/context-menu-trigger.test.ts",
    "content": "/**\n * @fileoverview ContextMenu Trigger Handler 测试 (P4-05)\n * @description\n * Tests for:\n * - Menu item creation and removal\n * - Click event handling\n * - Listener lifecycle\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createContextMenuTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/context-menu-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface ContextMenusMock {\n  create: ReturnType<typeof vi.fn>;\n  remove: ReturnType<typeof vi.fn>;\n  onClicked: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emitClicked: (\n    info: { menuItemId: string | number; pageUrl?: string },\n    tab?: { id?: number; url?: string },\n  ) => void;\n  createdItems: Map<string, { title: string; contexts: string[] }>;\n}\n\nfunction createContextMenusMock(): ContextMenusMock {\n  const listeners = new Set<\n    (\n      info: { menuItemId: string | number; pageUrl?: string },\n      tab?: { id?: number; url?: string },\n    ) => void\n  >();\n  const createdItems = new Map<string, { title: string; contexts: string[] }>();\n\n  const create = vi.fn(\n    (props: { id: string; title: string; contexts: string[] }, callback?: () => void) => {\n      createdItems.set(props.id, { title: props.title, contexts: props.contexts });\n      if (callback) {\n        // Simulate async callback\n        setTimeout(() => callback(), 0);\n      }\n      return props.id;\n    },\n  );\n\n  const remove = vi.fn((menuItemId: string, callback?: () => void) => {\n    createdItems.delete(menuItemId);\n    if (callback) {\n      setTimeout(() => callback(), 0);\n    }\n  });\n\n  const onClicked = {\n    addListener: vi.fn(\n      (\n        cb: (\n          info: { menuItemId: string | number; pageUrl?: string },\n          tab?: { id?: number; url?: string },\n        ) => void,\n      ) => {\n        listeners.add(cb);\n      },\n    ),\n    removeListener: vi.fn(\n      (\n        cb: (\n          info: { menuItemId: string | number; pageUrl?: string },\n          tab?: { id?: number; url?: string },\n        ) => void,\n      ) => {\n        listeners.delete(cb);\n      },\n    ),\n  };\n\n  return {\n    create,\n    remove,\n    onClicked,\n    emitClicked: (info, tab) => {\n      for (const cb of listeners) cb(info, tab);\n    },\n    createdItems,\n  };\n}\n\nfunction setupContextMenusMock(): ContextMenusMock {\n  const mock = createContextMenusMock();\n  (globalThis.chrome as unknown as { contextMenus: unknown }).contextMenus = {\n    create: mock.create,\n    remove: mock.remove,\n    onClicked: mock.onClicked,\n  };\n  // Clear lastError\n  (globalThis.chrome.runtime as { lastError?: { message: string } }).lastError = undefined;\n  return mock;\n}\n\n// ==================== ContextMenu Trigger Tests ====================\n\ndescribe('V3 ContextMenuTriggerHandler', () => {\n  let contextMenusMock: ContextMenusMock;\n\n  beforeEach(() => {\n    contextMenusMock = setupContextMenusMock();\n  });\n\n  describe('Menu item creation', () => {\n    it('creates menu item on install', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n        contexts: ['page', 'selection'],\n      };\n\n      await handler.install(trigger);\n\n      expect(contextMenusMock.create).toHaveBeenCalledWith(\n        {\n          id: 'rr_v3_t1',\n          title: 'Run My Flow',\n          contexts: ['page', 'selection'],\n        },\n        expect.any(Function),\n      );\n    });\n\n    it('uses default contexts when not specified', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n      };\n\n      await handler.install(trigger);\n\n      expect(contextMenusMock.create).toHaveBeenCalledWith(\n        expect.objectContaining({\n          contexts: ['page'],\n        }),\n        expect.any(Function),\n      );\n    });\n  });\n\n  describe('Click handling', () => {\n    it('fires on menu item click', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n      };\n\n      await handler.install(trigger);\n\n      contextMenusMock.emitClicked(\n        { menuItemId: 'rr_v3_t1', pageUrl: 'https://example.com/page' },\n        { id: 123, url: 'https://example.com/page' },\n      );\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: 123,\n        sourceUrl: 'https://example.com/page',\n      });\n    });\n\n    it('ignores click on non-matching menu item', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n      };\n\n      await handler.install(trigger);\n\n      contextMenusMock.emitClicked({ menuItemId: 'other_menu_item' });\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('uses tab url when pageUrl not available', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n      };\n\n      await handler.install(trigger);\n\n      contextMenusMock.emitClicked(\n        { menuItemId: 'rr_v3_t1' },\n        { id: 123, url: 'https://example.com' },\n      );\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: 123,\n        sourceUrl: 'https://example.com',\n      });\n    });\n  });\n\n  describe('Menu item removal', () => {\n    it('removes menu item on uninstall', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run My Flow',\n      };\n\n      await handler.install(trigger);\n      await handler.uninstall('t1');\n\n      expect(contextMenusMock.remove).toHaveBeenCalledWith('rr_v3_t1', expect.any(Function));\n    });\n\n    it('removes all menu items on uninstallAll', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Flow 1',\n      };\n\n      const t2: TriggerSpecByKind<'contextMenu'> = {\n        id: 't2' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        title: 'Flow 2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n      await handler.uninstallAll();\n\n      expect(contextMenusMock.remove).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('Listener lifecycle', () => {\n    it('registers listener on first install', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Run',\n      };\n\n      await handler.install(trigger);\n\n      expect(contextMenusMock.onClicked.addListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('removes listener when all triggers uninstalled', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Flow 1',\n      };\n\n      const t2: TriggerSpecByKind<'contextMenu'> = {\n        id: 't2' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        title: 'Flow 2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      await handler.uninstall('t1');\n      expect(contextMenusMock.onClicked.removeListener).not.toHaveBeenCalled();\n\n      await handler.uninstall('t2');\n      expect(contextMenusMock.onClicked.removeListener).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns installed trigger IDs', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createContextMenuTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'contextMenu'> = {\n        id: 't1' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        title: 'Flow 1',\n      };\n\n      const t2: TriggerSpecByKind<'contextMenu'> = {\n        id: 't2' as never,\n        kind: 'contextMenu',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        title: 'Flow 2',\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n\n      await handler.uninstall('t1');\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/cron-trigger.test.ts",
    "content": "/**\n * @fileoverview Cron Trigger Handler 测试 (P4-07)\n * @description\n * Tests for:\n * - Alarm scheduling on install\n * - Firing and rescheduling on alarm\n * - Timezone validation\n * - Listener lifecycle\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createCronTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/cron-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface AlarmsMock {\n  create: ReturnType<typeof vi.fn>;\n  clear: ReturnType<typeof vi.fn>;\n  getAll: ReturnType<typeof vi.fn>;\n  onAlarm: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emit: (name: string) => void;\n  createdAlarms: Map<string, { when?: number }>;\n}\n\nfunction createAlarmsMock(): AlarmsMock {\n  const listeners = new Set<(alarm: { name: string }) => void>();\n  const createdAlarms = new Map<string, { when?: number }>();\n\n  const onAlarm = {\n    addListener: vi.fn((cb: (alarm: { name: string }) => void) => listeners.add(cb)),\n    removeListener: vi.fn((cb: (alarm: { name: string }) => void) => listeners.delete(cb)),\n  };\n\n  const create = vi.fn((name: string, info: { when?: number }) => {\n    createdAlarms.set(name, info);\n    return undefined;\n  });\n\n  const clear = vi.fn((name: string) => {\n    createdAlarms.delete(name);\n    return true;\n  });\n\n  const getAll = vi.fn(async () =>\n    Array.from(createdAlarms.entries()).map(([name, info]) => ({\n      name,\n      scheduledTime: info.when ?? 0,\n    })),\n  );\n\n  return {\n    create,\n    clear,\n    getAll,\n    onAlarm,\n    emit: (name) => {\n      for (const cb of listeners) cb({ name });\n    },\n    createdAlarms,\n  };\n}\n\n// ==================== Cron Trigger Tests ====================\n\ndescribe('V3 CronTriggerHandler', () => {\n  let alarms: AlarmsMock;\n\n  beforeEach(() => {\n    alarms = createAlarmsMock();\n    (globalThis.chrome as unknown as { alarms: unknown }).alarms = {\n      create: alarms.create,\n      clear: alarms.clear,\n      getAll: alarms.getAll,\n      onAlarm: alarms.onAlarm,\n    };\n  });\n\n  describe('Installation and scheduling', () => {\n    it('schedules alarm on install', async () => {\n      const nowMs = 1_700_000_000_000;\n      const now = vi.fn(() => nowMs);\n      const computeNext = vi.fn(async ({ fromMs }: { fromMs: number }) => fromMs + 60_000);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now,\n        computeNextFireAtMs: computeNext,\n      })(fireCallback);\n\n      const trigger: TriggerSpecByKind<'cron'> = {\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *',\n        timezone: 'UTC',\n      };\n\n      await handler.install(trigger);\n\n      expect(alarms.onAlarm.addListener).toHaveBeenCalledTimes(1);\n      expect(alarms.create).toHaveBeenCalledWith('rr_v3_cron_t1', { when: nowMs + 60_000 });\n      expect(computeNext).toHaveBeenCalledWith({\n        cron: '0 9 * * *',\n        timezone: 'UTC',\n        fromMs: nowMs,\n      });\n    });\n\n    it('passes timezone to computeNextFireAtMs', async () => {\n      const computeNext = vi.fn(async ({ fromMs }: { fromMs: number }) => fromMs + 60_000);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: computeNext,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *',\n        timezone: 'Asia/Shanghai',\n      });\n\n      expect(computeNext).toHaveBeenCalledWith(\n        expect.objectContaining({\n          timezone: 'Asia/Shanghai',\n        }),\n      );\n    });\n  });\n\n  describe('Alarm firing', () => {\n    it('fires callback on alarm and reschedules', async () => {\n      const nowMs = 1_700_000_000_000;\n      const now = vi.fn(() => nowMs);\n      const computeNext = vi.fn(async ({ fromMs }: { fromMs: number }) => fromMs + 60_000);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now,\n        computeNextFireAtMs: computeNext,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *',\n      });\n\n      alarms.emit('rr_v3_cron_t1');\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: undefined,\n        sourceUrl: undefined,\n      });\n\n      // Should reschedule\n      expect(alarms.create).toHaveBeenCalledTimes(2);\n    });\n\n    it('ignores unrelated alarms', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      alarms.emit('other_alarm');\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('ignores alarm for uninstalled trigger', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      await handler.uninstall('t1');\n\n      alarms.emit('rr_v3_cron_t1');\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Uninstallation', () => {\n    it('clears alarm on uninstall', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      await handler.uninstall('t1');\n\n      expect(alarms.clear).toHaveBeenCalledWith('rr_v3_cron_t1');\n    });\n\n    it('stops listening when all triggers uninstalled', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      await handler.uninstall('t1');\n\n      expect(alarms.onAlarm.removeListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('uninstallAll clears all cron alarms', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      await handler.install({\n        id: 't2' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        cron: '0 * * * *',\n      });\n\n      await handler.uninstallAll();\n\n      expect(alarms.clear).toHaveBeenCalledWith('rr_v3_cron_t1');\n      expect(alarms.clear).toHaveBeenCalledWith('rr_v3_cron_t2');\n      expect(alarms.onAlarm.removeListener).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('Timezone computation', () => {\n    it('computes different next fire times for different timezones', async () => {\n      // Use default computeNextFireAtMs (built-in parser with timezone support)\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        // Don't override computeNextFireAtMs to test actual implementation\n      })(fireCallback);\n\n      // Install with UTC timezone\n      await handler.install({\n        id: 'utc' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *', // 9:00 AM every day\n        timezone: 'UTC',\n      });\n\n      const utcAlarm = alarms.createdAlarms.get('rr_v3_cron_utc');\n      expect(utcAlarm?.when).toBeDefined();\n      const utcFireTime = utcAlarm!.when!;\n\n      // Uninstall and reinstall with different timezone\n      await handler.uninstall('utc');\n\n      await handler.install({\n        id: 'shanghai' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *', // 9:00 AM every day (in Asia/Shanghai)\n        timezone: 'Asia/Shanghai',\n      });\n\n      const shanghaiAlarm = alarms.createdAlarms.get('rr_v3_cron_shanghai');\n      expect(shanghaiAlarm?.when).toBeDefined();\n      const shanghaiFireTime = shanghaiAlarm!.when!;\n\n      // Asia/Shanghai is UTC+8, so 9:00 AM Shanghai = 1:00 AM UTC\n      // The fire times should differ by 8 hours (28800000 ms)\n      const diff = Math.abs(utcFireTime - shanghaiFireTime);\n\n      // Allow for some variance due to DST and date boundaries\n      // The key assertion is that they're NOT equal\n      expect(utcFireTime).not.toBe(shanghaiFireTime);\n      // Should be close to 8 hours difference (within 1 day variance for date boundary cases)\n      expect(diff).toBeLessThanOrEqual(24 * 60 * 60 * 1000); // max 1 day difference\n    });\n\n    it('computes correctly at fixed point in time', async () => {\n      // Fix time to a known point: 2024-01-15 00:00:00 UTC (a Monday)\n      const fixedNow = Date.UTC(2024, 0, 15, 0, 0, 0);\n      const now = vi.fn(() => fixedNow);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now,\n        // Use default computeNextFireAtMs\n      })(fireCallback);\n\n      // Cron: 0 9 * * * = 9:00 AM every day in UTC\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '0 9 * * *',\n        timezone: 'UTC',\n      });\n\n      const alarm = alarms.createdAlarms.get('rr_v3_cron_t1');\n      expect(alarm?.when).toBeDefined();\n\n      // Expected: 2024-01-15 09:00:00 UTC\n      const expected = Date.UTC(2024, 0, 15, 9, 0, 0);\n      expect(alarm!.when).toBe(expected);\n    });\n  });\n\n  describe('Validation', () => {\n    it('rejects invalid timezone', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await expect(\n        handler.install({\n          id: 't1' as never,\n          kind: 'cron',\n          enabled: true,\n          flowId: 'flow-1' as never,\n          cron: '0 9 * * *',\n          timezone: 'Invalid/Zone',\n        }),\n      ).rejects.toThrow('Invalid timezone');\n    });\n\n    it('rejects empty cron expression', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await expect(\n        handler.install({\n          id: 't1' as never,\n          kind: 'cron',\n          enabled: true,\n          flowId: 'flow-1' as never,\n          cron: '   ',\n        }),\n      ).rejects.toThrow('cron must be a non-empty string');\n    });\n\n    it('rejects invalid cron step (*/0 infinite loop prevention)', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        // Use default computeNextFireAtMs to test built-in parser\n      })(fireCallback);\n\n      await expect(\n        handler.install({\n          id: 't1' as never,\n          kind: 'cron',\n          enabled: true,\n          flowId: 'flow-1' as never,\n          cron: '*/0 * * * *', // Invalid: step of 0\n        }),\n      ).rejects.toThrow('step must be >= 1');\n    });\n\n    it('rejects negative step values', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n      })(fireCallback);\n\n      await expect(\n        handler.install({\n          id: 't1' as never,\n          kind: 'cron',\n          enabled: true,\n          flowId: 'flow-1' as never,\n          cron: '*/-5 * * * *', // Invalid: negative step\n        }),\n      ).rejects.toThrow('step must be >= 1');\n    });\n\n    it('rejects cron with wrong number of fields', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n      })(fireCallback);\n\n      await expect(\n        handler.install({\n          id: 't1' as never,\n          kind: 'cron',\n          enabled: true,\n          flowId: 'flow-1' as never,\n          cron: '0 9 * *', // Only 4 fields\n        }),\n      ).rejects.toThrow('expected 5 fields');\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns installed trigger IDs', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createCronTriggerHandlerFactory({\n        logger: createSilentLogger(),\n        now: () => 0,\n        computeNextFireAtMs: () => 60_000,\n      })(fireCallback);\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        cron: '*/5 * * * *',\n      });\n\n      await handler.install({\n        id: 't2' as never,\n        kind: 'cron',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        cron: '0 * * * *',\n      });\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n\n      await handler.uninstall('t1');\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/debugger.contract.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 Debugger Contracts\n * @description Verifies DebugController behavior via command handling + state changes\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { z } from 'zod';\n\nimport type {\n  EdgeV3,\n  FlowV3,\n  NodeV3,\n  RunEvent,\n  RunRecordV3,\n  NodeDefinition,\n  NodeExecutionResult,\n  DebuggerState,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport {\n  EDGE_LABELS,\n  FLOW_SCHEMA_VERSION,\n  RUN_SCHEMA_VERSION,\n  InMemoryEventsBus,\n  PluginRegistry,\n  RR_ERROR_CODES,\n  createNotImplementedStoragePort,\n  createRRError,\n  createRunRunnerFactory,\n  resetBreakpointRegistry,\n  DebugController,\n  createRunnerRegistry,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport type {\n  RunId,\n  PersistentVarRecord,\n  PersistentVarsStore,\n  RunsStore,\n  FlowsStore,\n} from '@/entrypoints/background/record-replay-v3';\n\n// ==================== Test Helpers ====================\n\ntype TestNodeConfig = {\n  action: 'succeed' | 'fail' | 'slow';\n  delayMs?: number;\n};\n\nfunction createTestNodeDefinition(\n  callsByNodeId: Map<string, number>,\n  resolvers: Map<string, () => void>,\n): NodeDefinition<'test', TestNodeConfig> {\n  return {\n    kind: 'test',\n    schema: z\n      .object({\n        action: z.enum(['succeed', 'fail', 'slow']),\n        delayMs: z.number().optional(),\n      })\n      .passthrough(),\n    execute: async (ctx, node): Promise<NodeExecutionResult> => {\n      const prev = callsByNodeId.get(ctx.nodeId) ?? 0;\n      callsByNodeId.set(ctx.nodeId, prev + 1);\n\n      const cfg = node.config as unknown as TestNodeConfig;\n\n      if (cfg.action === 'slow') {\n        // Wait for external resolution\n        await new Promise<void>((resolve) => {\n          resolvers.set(ctx.nodeId, resolve);\n        });\n      }\n\n      if (cfg.action === 'fail') {\n        return {\n          status: 'failed',\n          error: createRRError(RR_ERROR_CODES.TOOL_ERROR, `test failure (${ctx.nodeId})`),\n        };\n      }\n\n      return { status: 'succeeded' };\n    },\n  };\n}\n\nfunction createFlow(entryNodeId: string, nodes: NodeV3[], edges: EdgeV3[]): FlowV3 {\n  const iso = new Date(0).toISOString();\n  return {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id: 'flow-debug',\n    name: 'debug contract flow',\n    createdAt: iso,\n    updatedAt: iso,\n    entryNodeId,\n    nodes,\n    edges,\n  };\n}\n\nfunction createInMemoryRunsStore(): { store: RunsStore; byId: Map<RunId, RunRecordV3> } {\n  const byId = new Map<RunId, RunRecordV3>();\n  const store: RunsStore = {\n    list: async () => Array.from(byId.values()),\n    get: async (id) => byId.get(id) ?? null,\n    save: async (record) => {\n      byId.set(record.id, record);\n    },\n    patch: async (id, patch) => {\n      const existing = byId.get(id);\n      if (!existing) {\n        throw createRRError(RR_ERROR_CODES.INTERNAL, `Run \"${id}\" not found`);\n      }\n      byId.set(id, {\n        ...existing,\n        ...patch,\n        id: existing.id,\n        schemaVersion: existing.schemaVersion,\n        updatedAt: Date.now(),\n      });\n    },\n  };\n  return { store, byId };\n}\n\nfunction createInMemoryFlowsStore(): { store: FlowsStore; byId: Map<string, FlowV3> } {\n  const byId = new Map<string, FlowV3>();\n  const store: FlowsStore = {\n    list: async () => Array.from(byId.values()),\n    get: async (id) => byId.get(id) ?? null,\n    save: async (flow) => {\n      byId.set(flow.id, flow);\n    },\n    delete: async (id) => {\n      byId.delete(id);\n    },\n  };\n  return { store, byId };\n}\n\nfunction createInMemoryPersistentVarsStore(): PersistentVarsStore {\n  const byKey = new Map<string, PersistentVarRecord>();\n  return {\n    get: async (key) => byKey.get(key as string) as PersistentVarRecord | undefined,\n    set: async (key, value) => {\n      const prev = byKey.get(key as string);\n      const record: PersistentVarRecord = {\n        key,\n        value,\n        updatedAt: Date.now(),\n        version: (prev?.version ?? 0) + 1,\n      };\n      byKey.set(key as string, record);\n      return record;\n    },\n    delete: async (key) => {\n      byKey.delete(key as string);\n    },\n    list: async (prefix) => {\n      const all = Array.from(byKey.values());\n      if (!prefix) return all;\n      return all.filter((r) => r.key.startsWith(prefix));\n    },\n  };\n}\n\n// ==================== Tests ====================\n\ndescribe('V3 Debugger contracts', () => {\n  beforeEach(() => {\n    resetBreakpointRegistry();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n  });\n\n  describe('attach/detach', () => {\n    it('attach returns state with attached status', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n      const { store: flows, byId: flowsById } = createInMemoryFlowsStore();\n\n      const flow = createFlow('A', [{ id: 'A', kind: 'test', config: { action: 'succeed' } }], []);\n      flowsById.set(flow.id, flow);\n\n      // Create a run record\n      const runId = 'run-attach';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: flow.id,\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n      storage.flows = flows;\n      storage.persistentVars = createInMemoryPersistentVarsStore();\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      const response = await controller.handle({ type: 'debug.attach', runId });\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.status).toBe('attached');\n        expect(response.state.runId).toBe(runId);\n      }\n\n      controller.stop();\n    });\n\n    it('detach returns state with detached status', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-detach';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      // First attach\n      await controller.handle({ type: 'debug.attach', runId });\n\n      // Then detach\n      const response = await controller.handle({ type: 'debug.detach', runId });\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.status).toBe('detached');\n      }\n\n      controller.stop();\n    });\n  });\n\n  describe('breakpoints', () => {\n    it('setBreakpoints updates breakpoint list', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-bp';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      await controller.handle({ type: 'debug.attach', runId });\n\n      const response = await controller.handle({\n        type: 'debug.setBreakpoints',\n        runId,\n        nodeIds: ['A', 'B', 'C'],\n      });\n\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.breakpoints.map((bp) => bp.nodeId)).toEqual(['A', 'B', 'C']);\n      }\n\n      controller.stop();\n    });\n\n    it('addBreakpoint adds to existing list', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-bp-add';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      await controller.handle({ type: 'debug.setBreakpoints', runId, nodeIds: ['A'] });\n      const response = await controller.handle({ type: 'debug.addBreakpoint', runId, nodeId: 'B' });\n\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.breakpoints.map((bp) => bp.nodeId)).toContain('A');\n        expect(response.state.breakpoints.map((bp) => bp.nodeId)).toContain('B');\n      }\n\n      controller.stop();\n    });\n\n    it('removeBreakpoint removes from list', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-bp-remove';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      await controller.handle({ type: 'debug.setBreakpoints', runId, nodeIds: ['A', 'B'] });\n      const response = await controller.handle({\n        type: 'debug.removeBreakpoint',\n        runId,\n        nodeId: 'A',\n      });\n\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.breakpoints.map((bp) => bp.nodeId)).toEqual(['B']);\n      }\n\n      controller.stop();\n    });\n  });\n\n  describe('getState', () => {\n    it('returns current debug state', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-getstate';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'paused',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        currentNodeId: 'A',\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      const response = await controller.handle({ type: 'debug.getState', runId });\n\n      expect(response.ok).toBe(true);\n      if (response.ok && response.state) {\n        expect(response.state.runId).toBe(runId);\n        expect(response.state.execution).toBe('paused');\n        expect(response.state.currentNodeId).toBe('A');\n      }\n\n      controller.stop();\n    });\n  });\n\n  describe('variables', () => {\n    it('getVar returns variable value from active runner', async () => {\n      const calls = new Map<string, number>();\n      const resolvers = new Map<string, () => void>();\n      const plugins = new PluginRegistry();\n      plugins.registerNode(createTestNodeDefinition(calls, resolvers));\n\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n      const { store: flows, byId: flowsById } = createInMemoryFlowsStore();\n\n      const flow = createFlow('A', [{ id: 'A', kind: 'test', config: { action: 'slow' } }], []);\n      flowsById.set(flow.id, flow);\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n      storage.flows = flows;\n      storage.persistentVars = createInMemoryPersistentVarsStore();\n\n      const runners = createRunnerRegistry();\n      const factory = createRunRunnerFactory({ storage, events: bus, plugins });\n\n      const runId = 'run-getvar';\n      const runner = factory.create(runId, { flow, tabId: 1, args: { myVar: 'hello' } });\n      runners.register(runId, runner);\n\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      // Start the runner (it will wait on node A)\n      const startPromise = runner.start();\n\n      // Wait a bit for the runner to start\n      await new Promise((r) => setTimeout(r, 10));\n\n      // Get variable\n      const response = await controller.handle({ type: 'debug.getVar', runId, name: 'myVar' });\n\n      expect(response.ok).toBe(true);\n      if (response.ok) {\n        expect(response.value).toBe('hello');\n      }\n\n      // Clean up - resolve the slow node\n      resolvers.get('A')?.();\n      await startPromise;\n\n      controller.stop();\n    });\n\n    it('setVar updates variable in active runner', async () => {\n      const calls = new Map<string, number>();\n      const resolvers = new Map<string, () => void>();\n      const plugins = new PluginRegistry();\n      plugins.registerNode(createTestNodeDefinition(calls, resolvers));\n\n      const bus = new InMemoryEventsBus();\n      const { store: runs } = createInMemoryRunsStore();\n      const { store: flows, byId: flowsById } = createInMemoryFlowsStore();\n\n      const flow = createFlow('A', [{ id: 'A', kind: 'test', config: { action: 'slow' } }], []);\n      flowsById.set(flow.id, flow);\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n      storage.flows = flows;\n      storage.persistentVars = createInMemoryPersistentVarsStore();\n\n      const runners = createRunnerRegistry();\n      const factory = createRunRunnerFactory({ storage, events: bus, plugins });\n\n      const runId = 'run-setvar';\n      const runner = factory.create(runId, { flow, tabId: 1 });\n      runners.register(runId, runner);\n\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      // Start the runner\n      const startPromise = runner.start();\n      await new Promise((r) => setTimeout(r, 10));\n\n      // Set variable\n      const setResponse = await controller.handle({\n        type: 'debug.setVar',\n        runId,\n        name: 'newVar',\n        value: 42,\n      });\n      expect(setResponse.ok).toBe(true);\n\n      // Get variable back\n      const getResponse = await controller.handle({ type: 'debug.getVar', runId, name: 'newVar' });\n      expect(getResponse.ok).toBe(true);\n      if (getResponse.ok) {\n        expect(getResponse.value).toBe(42);\n      }\n\n      // Clean up\n      resolvers.get('A')?.();\n      await startPromise;\n\n      controller.stop();\n    });\n  });\n\n  describe('state subscription', () => {\n    it('subscribe receives state changes', async () => {\n      const bus = new InMemoryEventsBus();\n      const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n      const runId = 'run-subscribe';\n      runsById.set(runId, {\n        schemaVersion: RUN_SCHEMA_VERSION,\n        id: runId,\n        flowId: 'flow-1',\n        status: 'running',\n        createdAt: Date.now(),\n        updatedAt: Date.now(),\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 1,\n      });\n\n      const storage = createNotImplementedStoragePort();\n      storage.runs = runs;\n\n      const runners = createRunnerRegistry();\n      const controller = new DebugController({ storage, events: bus, runners });\n      controller.start();\n\n      const receivedStates: DebuggerState[] = [];\n      controller.subscribe((state) => receivedStates.push(state), { runId });\n\n      // Attach to trigger state notification\n      await controller.handle({ type: 'debug.attach', runId });\n\n      expect(receivedStates.length).toBeGreaterThan(0);\n      expect(receivedStates[0].runId).toBe(runId);\n      expect(receivedStates[0].status).toBe('attached');\n\n      controller.stop();\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/dom-trigger.test.ts",
    "content": "/**\n * @fileoverview DOM Trigger Handler 测试 (P4-06)\n * @description\n * Tests for:\n * - Syncing triggers to tabs (inject + set_dom_triggers)\n * - Handling dom_trigger_fired messages\n * - Re-syncing on navigation completion\n * - Listener lifecycle\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { CONTENT_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types';\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createDomTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/dom-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface RuntimeOnMessageMock {\n  onMessage: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emit: (message: unknown, sender?: Partial<chrome.runtime.MessageSender>) => void;\n}\n\nfunction createRuntimeOnMessageMock(): RuntimeOnMessageMock {\n  const listeners = new Set<\n    (\n      message: unknown,\n      sender: chrome.runtime.MessageSender,\n      sendResponse: (response?: unknown) => void,\n    ) => boolean | void\n  >();\n\n  const onMessage = {\n    addListener: vi.fn((cb) => {\n      listeners.add(cb);\n    }),\n    removeListener: vi.fn((cb) => {\n      listeners.delete(cb);\n    }),\n  };\n\n  return {\n    onMessage,\n    emit: (message, sender) => {\n      for (const cb of listeners) {\n        cb(message, sender as chrome.runtime.MessageSender, vi.fn());\n      }\n    },\n  };\n}\n\ninterface WebNavigationMock {\n  onCompleted: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emitCompleted: (details: { tabId: number; frameId: number; url: string }) => void;\n}\n\nfunction createWebNavigationMock(): WebNavigationMock {\n  const listeners = new Set<(details: unknown) => void>();\n\n  const onCompleted = {\n    addListener: vi.fn((cb: (details: unknown) => void) => {\n      listeners.add(cb);\n    }),\n    removeListener: vi.fn((cb: (details: unknown) => void) => {\n      listeners.delete(cb);\n    }),\n  };\n\n  return {\n    onCompleted,\n    emitCompleted: (details) => {\n      for (const cb of listeners) cb(details);\n    },\n  };\n}\n\n// ==================== DOM Trigger Tests ====================\n\ndescribe('V3 DomTriggerHandler', () => {\n  let runtimeMock: RuntimeOnMessageMock;\n  let webNav: WebNavigationMock;\n\n  beforeEach(() => {\n    runtimeMock = createRuntimeOnMessageMock();\n    webNav = createWebNavigationMock();\n\n    (globalThis.chrome as unknown as { runtime: unknown }).runtime = {\n      ...(globalThis.chrome as unknown as { runtime: object }).runtime,\n      onMessage: runtimeMock.onMessage,\n    };\n\n    (globalThis.chrome as unknown as { webNavigation: unknown }).webNavigation = {\n      onCompleted: webNav.onCompleted,\n    };\n\n    (globalThis.chrome as unknown as { scripting: unknown }).scripting = {\n      executeScript: vi.fn().mockResolvedValue([]),\n    };\n\n    (globalThis.chrome as unknown as { tabs: unknown }).tabs = {\n      query: vi.fn().mockResolvedValue([]),\n      sendMessage: vi.fn().mockResolvedValue({}),\n    };\n  });\n\n  describe('Installation and sync', () => {\n    it('injects dom-observer and pushes triggers on install', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([\n        { id: 1, url: 'https://example.com' },\n        { id: 2, url: 'chrome://extensions' }, // Should be skipped\n      ]);\n\n      (globalThis.chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockImplementation(\n        async (_tabId: number, msg: { action?: string; triggers?: unknown[] }) => {\n          if (msg.action === CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING) {\n            throw new Error('no observer'); // Simulate not injected\n          }\n          if (msg.action === TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS) {\n            return { success: true, count: Array.isArray(msg.triggers) ? msg.triggers.length : 0 };\n          }\n          return undefined;\n        },\n      );\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'dom'> = {\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#submit-button',\n      };\n\n      await handler.install(trigger);\n\n      // Listeners should be registered\n      expect(runtimeMock.onMessage.addListener).toHaveBeenCalledTimes(1);\n      expect(webNav.onCompleted.addListener).toHaveBeenCalledTimes(1);\n\n      // Should inject script to injectable tab only\n      expect(globalThis.chrome.scripting.executeScript).toHaveBeenCalledWith(\n        expect.objectContaining({\n          target: { tabId: 1 },\n          files: ['inject-scripts/dom-observer.js'],\n          world: 'ISOLATED',\n        }),\n      );\n\n      // Should not inject to chrome:// URL\n      const executeScriptCalls = (\n        globalThis.chrome.scripting.executeScript as ReturnType<typeof vi.fn>\n      ).mock.calls;\n      expect(executeScriptCalls.every((c) => c[0].target.tabId !== 2)).toBe(true);\n\n      // Should send triggers\n      const sendCalls = (globalThis.chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mock.calls;\n      const setCalls = sendCalls.filter(\n        (c) => c[1]?.action === TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS,\n      );\n\n      expect(setCalls.length).toBeGreaterThan(0);\n      expect(setCalls[0][1]).toEqual({\n        action: TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS,\n        triggers: [\n          {\n            id: 't1',\n            selector: '#submit-button',\n            appear: true,\n            once: true,\n            debounceMs: 800,\n          },\n        ],\n      });\n    });\n\n    it('uses custom debounceMs when specified', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([\n        { id: 1, url: 'https://example.com' },\n      ]);\n\n      (globalThis.chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockImplementation(\n        async (_tabId: number, msg: { action?: string }) => {\n          if (msg.action === CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING) {\n            return { status: 'pong' }; // Already injected\n          }\n          return { success: true };\n        },\n      );\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'dom'> = {\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#btn',\n        debounceMs: 2000,\n        once: false,\n        appear: false,\n      };\n\n      await handler.install(trigger);\n\n      const sendCalls = (globalThis.chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mock.calls;\n      const setCalls = sendCalls.filter(\n        (c) => c[1]?.action === TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS,\n      );\n\n      expect(setCalls[0][1].triggers[0]).toMatchObject({\n        debounceMs: 2000,\n        once: false,\n        appear: false,\n      });\n    });\n  });\n\n  describe('Message handling', () => {\n    it('fires when receiving dom_trigger_fired for installed trigger', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'dom'> = {\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      };\n\n      await handler.install(trigger);\n\n      runtimeMock.emit(\n        {\n          action: TOOL_MESSAGE_TYPES.DOM_TRIGGER_FIRED,\n          triggerId: 't1',\n          url: 'https://example.com/page',\n        },\n        { tab: { id: 123, url: 'https://example.com/page' } as chrome.tabs.Tab },\n      );\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: 123,\n        sourceUrl: 'https://example.com/page',\n      });\n    });\n\n    it('ignores dom_trigger_fired for unknown trigger', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'dom'> = {\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      };\n\n      await handler.install(trigger);\n\n      runtimeMock.emit(\n        {\n          action: TOOL_MESSAGE_TYPES.DOM_TRIGGER_FIRED,\n          triggerId: 'unknown',\n          url: 'https://example.com/page',\n        },\n        { tab: { id: 123 } as chrome.tabs.Tab },\n      );\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('ignores non-dom_trigger_fired messages', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n\n      runtimeMock.emit({ action: 'some_other_action', data: 'test' }, {});\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Navigation handling', () => {\n    it('re-syncs on main-frame navigation completion', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n      (globalThis.chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).mockImplementation(\n        async (_tabId: number, msg: { action?: string }) => {\n          if (msg.action === CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING) {\n            throw new Error('no observer');\n          }\n          return { ok: true };\n        },\n      );\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n\n      // Clear previous calls\n      (globalThis.chrome.scripting.executeScript as ReturnType<typeof vi.fn>).mockClear();\n\n      // Emit navigation completed\n      webNav.emitCompleted({ tabId: 5, frameId: 0, url: 'https://example.com' });\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(globalThis.chrome.scripting.executeScript).toHaveBeenCalledWith(\n        expect.objectContaining({\n          target: { tabId: 5 },\n          files: ['inject-scripts/dom-observer.js'],\n          world: 'ISOLATED',\n        }),\n      );\n    });\n\n    it('ignores subframe navigation', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n\n      (globalThis.chrome.scripting.executeScript as ReturnType<typeof vi.fn>).mockClear();\n\n      // Emit subframe navigation\n      webNav.emitCompleted({ tabId: 5, frameId: 1, url: 'https://example.com' });\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(globalThis.chrome.scripting.executeScript).not.toHaveBeenCalled();\n    });\n\n    it('ignores non-injectable URLs on navigation', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n\n      (globalThis.chrome.scripting.executeScript as ReturnType<typeof vi.fn>).mockClear();\n\n      // Emit navigation to chrome:// URL\n      webNav.emitCompleted({ tabId: 5, frameId: 0, url: 'chrome://extensions' });\n      await new Promise((r) => setTimeout(r, 0));\n\n      expect(globalThis.chrome.scripting.executeScript).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Lifecycle', () => {\n    it('stops listening when last trigger uninstalled', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n\n      await handler.uninstall('t1');\n\n      expect(runtimeMock.onMessage.removeListener).toHaveBeenCalledTimes(1);\n      expect(webNav.onCompleted.removeListener).toHaveBeenCalledTimes(1);\n      expect(handler.getInstalledIds()).toEqual([]);\n    });\n\n    it('uninstallAll clears all and stops listening', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n      await handler.install({\n        id: 't2' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        selector: '#y',\n      });\n\n      await handler.uninstallAll();\n\n      expect(runtimeMock.onMessage.removeListener).toHaveBeenCalledTimes(1);\n      expect(webNav.onCompleted.removeListener).toHaveBeenCalledTimes(1);\n      expect(handler.getInstalledIds()).toEqual([]);\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns installed trigger IDs', async () => {\n      (globalThis.chrome.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createDomTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        selector: '#x',\n      });\n      await handler.install({\n        id: 't2' as never,\n        kind: 'dom',\n        enabled: true,\n        flowId: 'flow-2' as never,\n        selector: '#y',\n      });\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n\n      await handler.uninstall('t1');\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/e2e.integration.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 service-level E2E 集成测试\n * @description\n * 验证完整的 V3 流程：RPC → enqueue → schedule → run → complete\n *\n * 测试使用：\n * - 真实 IndexedDB 存储（fake-indexeddb）\n * - service-level RPC（直接调用内部 handler，避免 Port mock）\n */\n\nimport { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport type { FlowV3, RunEvent, RunRecordV3 } from '@/entrypoints/background/record-replay-v3';\nimport {\n  FLOW_SCHEMA_VERSION,\n  RUN_SCHEMA_VERSION,\n  closeRrV3Db,\n  deleteRrV3Db,\n  resetBreakpointRegistry,\n  recoverFromCrash,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport { createV3E2EHarness, type V3E2EHarness, type RpcClient } from './v3-e2e-harness';\n\n// ==================== Test Fixtures ====================\n\n/**\n * 创建测试用 Flow\n */\nfunction createTestFlow(\n  id: string,\n  nodeConfig: { action: 'succeed' | 'fail' } = { action: 'succeed' },\n): FlowV3 {\n  const iso = new Date(0).toISOString();\n  return {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id,\n    name: `E2E Flow ${id}`,\n    createdAt: iso,\n    updatedAt: iso,\n    entryNodeId: 'node-1',\n    nodes: [{ id: 'node-1', kind: 'test', config: nodeConfig }],\n    edges: [],\n  };\n}\n\n/**\n * 创建测试用 RunRecord\n */\nfunction createRunRecord(\n  runId: string,\n  flowId: string,\n  status: RunRecordV3['status'],\n): RunRecordV3 {\n  const t0 = Date.now();\n  return {\n    schemaVersion: RUN_SCHEMA_VERSION,\n    id: runId,\n    flowId,\n    status,\n    createdAt: t0,\n    updatedAt: t0,\n    startedAt: status === 'running' ? t0 : undefined,\n    attempt: 0,\n    maxAttempts: 1,\n    nextSeq: 0,\n  };\n}\n\n/**\n * 提取事件类型列表\n */\nfunction eventTypes(events: RunEvent[], runId: string): string[] {\n  return events.filter((e) => e.runId === runId).map((e) => e.type);\n}\n\n// ==================== E2E Tests ====================\n\ndescribe('V3 service-level E2E', () => {\n  let h: V3E2EHarness;\n  let client: RpcClient;\n\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n    resetBreakpointRegistry();\n\n    h = createV3E2EHarness();\n    client = h.createClient();\n  });\n\n  afterEach(async () => {\n    await h.dispose();\n  });\n\n  describe('Happy path', () => {\n    it('enqueueRun → schedule → runner → succeeded', async () => {\n      // 准备 Flow\n      const flow = createTestFlow('flow-happy');\n      await h.storage.flows.save(flow);\n\n      // Enqueue run\n      const result = await client.call<{ runId: string; position: number }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n      expect(result.runId).toBeDefined();\n      expect(result.position).toBeGreaterThanOrEqual(1);\n\n      // 等待完成\n      const run = await h.waitForTerminal(result.runId);\n      expect(run.status).toBe('succeeded');\n\n      // 等待队列项被移除\n      await h.waitForQueueItemGone(result.runId);\n\n      // 验证事件序列\n      const events = await h.listEvents(result.runId);\n      const types = eventTypes(events, result.runId);\n\n      expect(types).toContain('run.queued');\n      expect(types).toContain('run.started');\n      expect(types).toContain('node.queued');\n      expect(types).toContain('node.started');\n      expect(types).toContain('node.succeeded');\n      expect(types).toContain('run.succeeded');\n\n      // 验证事件顺序\n      expect(types.indexOf('run.queued')).toBeLessThan(types.indexOf('run.started'));\n      expect(types.indexOf('run.started')).toBeLessThan(types.indexOf('run.succeeded'));\n    });\n\n    it('failed node leads to run.failed', async () => {\n      const flow = createTestFlow('flow-fail', { action: 'fail' });\n      await h.storage.flows.save(flow);\n\n      const result = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n\n      const run = await h.waitForTerminal(result.runId);\n      expect(run.status).toBe('failed');\n      expect(run.error).toBeDefined();\n\n      await h.waitForQueueItemGone(result.runId);\n\n      const events = await h.listEvents(result.runId);\n      const types = eventTypes(events, result.runId);\n\n      expect(types).toContain('run.failed');\n      expect(types).toContain('node.failed');\n    });\n  });\n\n  describe('Event streaming', () => {\n    it('subscribe → receive rr_v3.event messages', async () => {\n      const flow = createTestFlow('flow-stream');\n      await h.storage.flows.save(flow);\n\n      // 订阅所有 Run\n      await client.call('rr_v3.subscribe');\n\n      // 入队\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n\n      await h.waitForTerminal(runId);\n      await h.waitForQueueItemGone(runId);\n\n      // 验证流式推送的事件\n      const streamed = client.getStreamedEvents().filter((e) => e.runId === runId);\n      const streamedTypes = streamed.map((e) => e.type);\n\n      expect(streamedTypes).toContain('run.queued');\n      expect(streamedTypes).toContain('run.started');\n      expect(streamedTypes).toContain('run.succeeded');\n    });\n\n    it('subscribe with runId filter only receives events for that run', async () => {\n      const flow1 = createTestFlow('flow-1');\n      const flow2 = createTestFlow('flow-2');\n      await h.storage.flows.save(flow1);\n      await h.storage.flows.save(flow2);\n\n      // 先入队 run1\n      const { runId: runId1 } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow1.id,\n      });\n      await h.waitForTerminal(runId1);\n\n      // 订阅只接收 runId1 的事件（但 runId1 已完成）\n      await client.call('rr_v3.subscribe', { runId: runId1 });\n      client.clearMessages();\n\n      // 入队 run2\n      const { runId: runId2 } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow2.id,\n      });\n      await h.waitForTerminal(runId2);\n\n      // 应该不收到 run2 的事件\n      const streamedForRun2 = client.getStreamedEvents().filter((e) => e.runId === runId2);\n      expect(streamedForRun2).toHaveLength(0);\n    });\n  });\n\n  describe('Control plane', () => {\n    it('pause/resume: pauseRun marks queue paused, resumeRun completes succeeded', async () => {\n      const flow = createTestFlow('flow-control');\n      await h.storage.flows.save(flow);\n\n      // 入队时启用 pauseOnStart\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n        debug: { pauseOnStart: true },\n      });\n\n      // 等待 run.paused 事件\n      await h.waitForEvent(runId, (e) => e.type === 'run.paused');\n\n      // 暂停 queue item\n      await client.call('rr_v3.pauseRun', { runId });\n      const pausedItem = await h.storage.queue.get(runId);\n      expect(pausedItem?.status).toBe('paused');\n\n      // 恢复\n      await client.call('rr_v3.resumeRun', { runId });\n\n      // 等待完成\n      const run = await h.waitForTerminal(runId);\n      expect(run.status).toBe('succeeded');\n      await h.waitForQueueItemGone(runId);\n    });\n\n    it('cancel: cancelRun transitions run to canceled', async () => {\n      const flow = createTestFlow('flow-cancel');\n      await h.storage.flows.save(flow);\n\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n        debug: { pauseOnStart: true },\n      });\n\n      await h.waitForEvent(runId, (e) => e.type === 'run.paused');\n\n      // 先暂停 queue item\n      await client.call('rr_v3.pauseRun', { runId });\n\n      // 取消\n      await client.call('rr_v3.cancelRun', { runId, reason: 'E2E cancel test' });\n\n      const run = await h.waitForTerminal(runId);\n      expect(run.status).toBe('canceled');\n      await h.waitForQueueItemGone(runId);\n    });\n\n    it('cancel queued run removes it from queue', async () => {\n      // 创建一个新的 harness，不自动启动 scheduler\n      await h.dispose();\n      h = createV3E2EHarness({ autoStartScheduler: false });\n      client = h.createClient();\n\n      const flow = createTestFlow('flow-cancel-queued');\n      await h.storage.flows.save(flow);\n\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n\n      // 队列中应该有这个 item\n      let item = await h.storage.queue.get(runId);\n      expect(item?.status).toBe('queued');\n\n      // 取消\n      await client.call('rr_v3.cancelRun', { runId });\n\n      // Queue item should be removed (queue.get returns null when not found)\n      item = await h.storage.queue.get(runId);\n      expect(item).toBeNull();\n\n      // Run 状态应该是 canceled\n      const run = await h.storage.runs.get(runId);\n      expect(run?.status).toBe('canceled');\n    });\n  });\n\n  describe('Recovery', () => {\n    it('orphan running lease is requeued and run can complete', async () => {\n      // 停止当前 harness，创建新的不启动 scheduler\n      await h.dispose();\n      h = createV3E2EHarness({ autoStartScheduler: false, ownerId: 'owner-new' });\n      client = h.createClient();\n\n      const flow = createTestFlow('flow-recovery');\n      await h.storage.flows.save(flow);\n\n      const runId = 'run-orphan';\n      await h.storage.runs.save(createRunRecord(runId, flow.id, 'running'));\n\n      // 创建 orphan 队列项（旧 owner 持有）\n      await h.storage.queue.enqueue({ id: runId, flowId: flow.id, priority: 0 });\n      await h.storage.queue.markRunning(runId, 'owner-old', Date.now());\n\n      // 执行恢复\n      const recovery = await recoverFromCrash({\n        storage: h.storage,\n        events: h.events,\n        ownerId: h.ownerId,\n        now: () => Date.now(),\n        logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },\n      });\n\n      expect(recovery.requeuedRunning).toContain(runId);\n\n      // 队列项应该回到 queued 状态\n      const queueItemAfter = await h.storage.queue.get(runId);\n      expect(queueItemAfter?.status).toBe('queued');\n      expect(queueItemAfter?.lease).toBeUndefined();\n\n      // 应该有 run.recovered 事件\n      const events = await h.listEvents(runId);\n      expect(events.some((e) => e.type === 'run.recovered')).toBe(true);\n\n      // 启动 scheduler，Run 应该能继续执行\n      h.scheduler.start();\n\n      const run = await h.waitForTerminal(runId);\n      expect(run.status).toBe('succeeded');\n      await h.waitForQueueItemGone(runId);\n    });\n\n    it('adopts orphan paused items', async () => {\n      await h.dispose();\n      h = createV3E2EHarness({ autoStartScheduler: false, ownerId: 'owner-new' });\n      client = h.createClient();\n\n      const flow = createTestFlow('flow-adopt');\n      await h.storage.flows.save(flow);\n\n      const runId = 'run-paused-orphan';\n      await h.storage.runs.save(createRunRecord(runId, flow.id, 'paused'));\n\n      await h.storage.queue.enqueue({ id: runId, flowId: flow.id, priority: 0 });\n      await h.storage.queue.markPaused(runId, 'owner-old', Date.now());\n\n      const recovery = await recoverFromCrash({\n        storage: h.storage,\n        events: h.events,\n        ownerId: h.ownerId,\n        now: () => Date.now(),\n        logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },\n      });\n\n      expect(recovery.adoptedPaused).toContain(runId);\n\n      // 队列项应该仍是 paused，但 owner 换成新的\n      const queueItem = await h.storage.queue.get(runId);\n      expect(queueItem?.status).toBe('paused');\n      expect(queueItem?.lease?.ownerId).toBe(h.ownerId);\n    });\n\n    it('cleans terminal runs left in queue', async () => {\n      await h.dispose();\n      h = createV3E2EHarness({ autoStartScheduler: false, ownerId: 'owner-new' });\n      client = h.createClient();\n\n      const flow = createTestFlow('flow-clean');\n      await h.storage.flows.save(flow);\n\n      const runId = 'run-completed-orphan';\n      await h.storage.runs.save(createRunRecord(runId, flow.id, 'succeeded'));\n\n      // 模拟崩溃场景：Run 完成但队列项未清理\n      await h.storage.queue.enqueue({ id: runId, flowId: flow.id, priority: 0 });\n      await h.storage.queue.markRunning(runId, 'owner-old', Date.now());\n\n      const recovery = await recoverFromCrash({\n        storage: h.storage,\n        events: h.events,\n        ownerId: h.ownerId,\n        now: () => Date.now(),\n        logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },\n      });\n\n      expect(recovery.cleanedTerminal).toContain(runId);\n\n      // 队列应该为空\n      const remaining = await h.storage.queue.list();\n      expect(remaining).toHaveLength(0);\n    });\n  });\n\n  describe('Query APIs', () => {\n    it('getRun returns run record', async () => {\n      const flow = createTestFlow('flow-get');\n      await h.storage.flows.save(flow);\n\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n\n      await h.waitForTerminal(runId);\n\n      const run = await client.call<RunRecordV3 | null>('rr_v3.getRun', { runId });\n      expect(run).not.toBeNull();\n      expect(run?.id).toBe(runId);\n      expect(run?.status).toBe('succeeded');\n    });\n\n    it('listRuns returns all runs', async () => {\n      const flow = createTestFlow('flow-list');\n      await h.storage.flows.save(flow);\n\n      const { runId: runId1 } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n      await h.waitForTerminal(runId1);\n\n      const { runId: runId2 } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n      await h.waitForTerminal(runId2);\n\n      const runs = await client.call<RunRecordV3[]>('rr_v3.listRuns');\n      expect(runs.length).toBeGreaterThanOrEqual(2);\n      expect(runs.some((r) => r.id === runId1)).toBe(true);\n      expect(runs.some((r) => r.id === runId2)).toBe(true);\n    });\n\n    it('getEvents returns run events', async () => {\n      const flow = createTestFlow('flow-events');\n      await h.storage.flows.save(flow);\n\n      const { runId } = await client.call<{ runId: string }>('rr_v3.enqueueRun', {\n        flowId: flow.id,\n      });\n      await h.waitForTerminal(runId);\n\n      const events = await client.call<RunEvent[]>('rr_v3.getEvents', { runId });\n      expect(events.length).toBeGreaterThan(0);\n      expect(events.some((e) => e.type === 'run.queued')).toBe(true);\n      expect(events.some((e) => e.type === 'run.succeeded')).toBe(true);\n    });\n\n    it('listQueue returns queue items', async () => {\n      await h.dispose();\n      h = createV3E2EHarness({ autoStartScheduler: false });\n      client = h.createClient();\n\n      const flow = createTestFlow('flow-queue');\n      await h.storage.flows.save(flow);\n\n      await client.call('rr_v3.enqueueRun', { flowId: flow.id });\n      await client.call('rr_v3.enqueueRun', { flowId: flow.id });\n\n      const queue = await client.call<unknown[]>('rr_v3.listQueue');\n      expect(queue.length).toBeGreaterThanOrEqual(2);\n    });\n  });\n\n  describe('Error handling', () => {\n    it('enqueueRun with non-existent flow throws error', async () => {\n      await expect(\n        client.call('rr_v3.enqueueRun', { flowId: 'non-existent-flow' }),\n      ).rejects.toThrow();\n    });\n\n    it('getRun with non-existent runId returns null', async () => {\n      const run = await client.call<RunRecordV3 | null>('rr_v3.getRun', {\n        runId: 'non-existent-run',\n      });\n      expect(run).toBeNull();\n    });\n\n    it('pauseRun with invalid runId throws error', async () => {\n      await expect(client.call('rr_v3.pauseRun', { runId: 'invalid-run' })).rejects.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/events.contract.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 Events Contracts\n * @description\n * Verifies the persistence + transport contracts for:\n * - EventsStore (IndexedDB-backed): atomic seq allocation via RunRecordV3.nextSeq\n * - StorageBackedEventsBus: persistence-before-broadcast semantics\n *\n * Note: These tests assume `RunRecordV3.nextSeq` is initialized to 1 (1-based seq).\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest';\n\nimport type {\n  RunEvent,\n  RunEventInput,\n  RunRecordV3,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport {\n  RUN_SCHEMA_VERSION,\n  RR_ERROR_CODES,\n  StorageBackedEventsBus,\n  createEventsStore,\n  createRunsStore,\n  closeRrV3Db,\n  deleteRrV3Db,\n  RR_V3_STORES,\n  withTransaction,\n} from '@/entrypoints/background/record-replay-v3';\n\n/**\n * Create a valid RunRecordV3 for testing\n */\nfunction createRunRecord(runId: string, overrides: Partial<RunRecordV3> = {}): RunRecordV3 {\n  const now = Date.now();\n  return {\n    schemaVersion: RUN_SCHEMA_VERSION,\n    id: runId,\n    flowId: 'flow-1',\n    status: 'running',\n    createdAt: now,\n    updatedAt: now,\n    attempt: 0,\n    maxAttempts: 1,\n    nextSeq: 1,\n    ...overrides,\n  };\n}\n\n/**\n * Create a valid RunEventInput for testing\n */\nfunction createEventInput(runId: string, overrides: Partial<RunEventInput> = {}): RunEventInput {\n  return {\n    runId,\n    type: 'run.resumed',\n    ...overrides,\n  } as RunEventInput;\n}\n\n/**\n * Directly insert an event into the events store (bypasses append logic)\n * Used for testing list() with out-of-order data\n */\nasync function putEventRaw(event: RunEvent): Promise<void> {\n  await withTransaction(RR_V3_STORES.EVENTS, 'readwrite', async (stores) => {\n    const store = stores[RR_V3_STORES.EVENTS];\n    await new Promise<void>((resolve, reject) => {\n      const request = store.add(event);\n      request.onsuccess = () => resolve();\n      request.onerror = () => reject(request.error);\n    });\n  });\n}\n\ndescribe('V3 Events contracts', () => {\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n  });\n\n  describe('EventsStore', () => {\n    it('seq is monotonic and contiguous for a run', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n\n      const e1 = await events.append(createEventInput('run-1'));\n      const e2 = await events.append(createEventInput('run-1'));\n      const e3 = await events.append(createEventInput('run-1'));\n\n      expect([e1.seq, e2.seq, e3.seq]).toEqual([1, 2, 3]);\n    });\n\n    it('append is atomic: event.seq matches pre-append nextSeq and nextSeq increments on success', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 10 }));\n\n      const appended = await events.append(createEventInput('run-1'));\n      expect(appended.seq).toBe(10);\n\n      const runAfter = await runs.get('run-1');\n      expect(runAfter).not.toBeNull();\n      expect(runAfter!.nextSeq).toBe(appended.seq + 1);\n\n      const list = await events.list('run-1');\n      expect(list.map((e) => e.seq)).toContain(10);\n    });\n\n    it('throws RRError when appending to a missing run', async () => {\n      const events = createEventsStore();\n\n      await expect(events.append(createEventInput('missing-run'))).rejects.toMatchObject({\n        code: RR_ERROR_CODES.INTERNAL,\n      });\n    });\n\n    it('list returns events ordered by seq ascending (even if inserted out-of-order)', async () => {\n      const events = createEventsStore();\n      const runId = 'run-1';\n      const now = Date.now();\n\n      // Insert events out of order to verify sorting\n      await putEventRaw({ runId, type: 'run.resumed', seq: 5, ts: now } as RunEvent);\n      await putEventRaw({ runId, type: 'run.resumed', seq: 2, ts: now } as RunEvent);\n      await putEventRaw({ runId, type: 'run.resumed', seq: 9, ts: now } as RunEvent);\n\n      const list = await events.list(runId);\n      expect(list.map((e) => e.seq)).toEqual([2, 5, 9]);\n    });\n\n    it('list supports fromSeq (inclusive)', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n      for (let i = 0; i < 5; i++) {\n        await events.append(createEventInput('run-1'));\n      }\n\n      const list = await events.list('run-1', { fromSeq: 3 });\n      expect(list.map((e) => e.seq)).toEqual([3, 4, 5]);\n    });\n\n    it('list supports limit', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n      for (let i = 0; i < 5; i++) {\n        await events.append(createEventInput('run-1'));\n      }\n\n      const list = await events.list('run-1', { limit: 2 });\n      expect(list.map((e) => e.seq)).toEqual([1, 2]);\n\n      const listFrom = await events.list('run-1', { fromSeq: 2, limit: 2 });\n      expect(listFrom.map((e) => e.seq)).toEqual([2, 3]);\n\n      const empty = await events.list('run-1', { limit: 0 });\n      expect(empty).toEqual([]);\n    });\n\n    it('seq allocation remains correct under concurrent appends', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n\n      // Fire multiple appends concurrently\n      const appended = await Promise.all(\n        Array.from({ length: 20 }, () => events.append(createEventInput('run-1'))),\n      );\n\n      const seqs = appended.map((e) => e.seq).sort((a, b) => a - b);\n      expect(seqs).toEqual(Array.from({ length: 20 }, (_, i) => i + 1));\n\n      const runAfter = await runs.get('run-1');\n      expect(runAfter!.nextSeq).toBe(21);\n    });\n\n    it('list does not mix events from different runs', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n      await runs.save(createRunRecord('run-2', { nextSeq: 1 }));\n\n      await events.append(createEventInput('run-1'));\n      await events.append(createEventInput('run-2'));\n      await events.append(createEventInput('run-1'));\n\n      const run1Events = await events.list('run-1');\n      const run2Events = await events.list('run-2');\n\n      expect(run1Events.every((e) => e.runId === 'run-1')).toBe(true);\n      expect(run2Events.every((e) => e.runId === 'run-2')).toBe(true);\n      expect(run1Events.map((e) => e.seq)).toEqual([1, 2]);\n      expect(run2Events.map((e) => e.seq)).toEqual([1]);\n    });\n\n    it('throws INVARIANT_VIOLATION when nextSeq is invalid', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n\n      // Test with negative nextSeq\n      await runs.save(createRunRecord('run-neg', { nextSeq: -1 }));\n      await expect(events.append(createEventInput('run-neg'))).rejects.toMatchObject({\n        code: RR_ERROR_CODES.INVARIANT_VIOLATION,\n      });\n\n      // Test with non-integer nextSeq (NaN)\n      await runs.save(createRunRecord('run-nan', { nextSeq: NaN }));\n      await expect(events.append(createEventInput('run-nan'))).rejects.toMatchObject({\n        code: RR_ERROR_CODES.INVARIANT_VIOLATION,\n      });\n    });\n  });\n\n  describe('StorageBackedEventsBus', () => {\n    it('broadcasts after commit: when listener runs, data is already durable', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n\n      const bus = new StorageBackedEventsBus(events);\n\n      const received: RunEvent[] = [];\n      let seenRunNextSeq: number | null = null;\n      let seenListSeqs: number[] | null = null;\n\n      const listenerDone = new Promise<void>((resolve, reject) => {\n        bus.subscribe((event) => {\n          received.push(event);\n          void Promise.all([runs.get(event.runId), events.list(event.runId)])\n            .then(([run, list]) => {\n              seenRunNextSeq = run?.nextSeq ?? null;\n              seenListSeqs = list.map((e) => e.seq);\n              resolve();\n            })\n            .catch(reject);\n        });\n      });\n\n      const appended = await bus.append(createEventInput('run-1'));\n\n      // Contract: by the time append resolves, the event is already broadcast\n      expect(received).toHaveLength(1);\n      expect(received[0]).toMatchObject({ runId: 'run-1', seq: appended.seq });\n\n      await listenerDone;\n      expect(seenRunNextSeq).toBe(appended.seq + 1);\n      expect(seenListSeqs).toContain(appended.seq);\n    });\n\n    it('applies runId filter for subscriptions', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n      await runs.save(createRunRecord('run-2', { nextSeq: 1 }));\n\n      const bus = new StorageBackedEventsBus(events);\n\n      const all: RunEvent[] = [];\n      const onlyRun1: RunEvent[] = [];\n      const onlyRun2: RunEvent[] = [];\n\n      bus.subscribe((e) => all.push(e));\n      bus.subscribe((e) => onlyRun1.push(e), { runId: 'run-1' });\n      bus.subscribe((e) => onlyRun2.push(e), { runId: 'run-2' });\n\n      await bus.append(createEventInput('run-1'));\n      await bus.append(createEventInput('run-2'));\n\n      expect(all.map((e) => e.runId)).toEqual(['run-1', 'run-2']);\n      expect(onlyRun1.map((e) => e.runId)).toEqual(['run-1']);\n      expect(onlyRun2.map((e) => e.runId)).toEqual(['run-2']);\n    });\n\n    it('unsubscribe stops further broadcasts', async () => {\n      const runs = createRunsStore();\n      const events = createEventsStore();\n      await runs.save(createRunRecord('run-1', { nextSeq: 1 }));\n\n      const bus = new StorageBackedEventsBus(events);\n      const received: RunEvent[] = [];\n      const unsub = bus.subscribe((e) => received.push(e));\n\n      await bus.append(createEventInput('run-1'));\n      expect(received).toHaveLength(1);\n\n      unsub();\n      await bus.append(createEventInput('run-1'));\n\n      // Should not receive second event after unsubscribe\n      expect(received).toHaveLength(1);\n    });\n  });\n\n  describe('Crash recovery', () => {\n    it('continues seq after a simulated restart', async () => {\n      const runs1 = createRunsStore();\n      const events1 = createEventsStore();\n\n      await runs1.save(createRunRecord('run-1', { nextSeq: 1 }));\n\n      await events1.append(createEventInput('run-1'));\n      await events1.append(createEventInput('run-1'));\n      await events1.append(createEventInput('run-1'));\n\n      // Simulate a service worker restart (drop cached IDB connection)\n      closeRrV3Db();\n\n      const runs2 = createRunsStore();\n      const events2 = createEventsStore();\n\n      const e4 = await events2.append(createEventInput('run-1'));\n      expect(e4.seq).toBe(4);\n\n      const list = await events2.list('run-1');\n      expect(list.map((e) => e.seq)).toEqual([1, 2, 3, 4]);\n\n      const run = await runs2.get('run-1');\n      expect(run!.nextSeq).toBe(5);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/interval-trigger.test.ts",
    "content": "/**\n * @fileoverview Interval Trigger Handler Tests\n * @description 测试 interval 触发器的安装、卸载和触发行为\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createIntervalTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/interval-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createMockLogger() {\n  return {\n    debug: vi.fn(),\n    info: vi.fn(),\n    warn: vi.fn(),\n    error: vi.fn(),\n  };\n}\n\nfunction createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {\n  const calls: Array<{ triggerId: string }> = [];\n  return {\n    calls,\n    onFire: vi.fn(async (triggerId) => {\n      calls.push({ triggerId });\n    }),\n  };\n}\n\nfunction createIntervalTriggerSpec(\n  overrides: Partial<TriggerSpecByKind<'interval'>> = {},\n): TriggerSpecByKind<'interval'> {\n  return {\n    id: 'interval-trigger-1' as TriggerId,\n    kind: 'interval',\n    flowId: 'flow-1' as FlowId,\n    enabled: true,\n    periodMinutes: 5,\n    ...overrides,\n  };\n}\n\n// ==================== Mock chrome.alarms ====================\n\nlet alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];\nlet createdAlarms: Map<string, { periodInMinutes?: number; delayInMinutes?: number }> = new Map();\n\nfunction setupMockChromeAlarms() {\n  alarmListeners = [];\n  createdAlarms = new Map();\n\n  const alarms = {\n    create: vi.fn((name: string, info: { periodInMinutes?: number; delayInMinutes?: number }) => {\n      createdAlarms.set(name, info);\n      return Promise.resolve();\n    }),\n    clear: vi.fn((name: string) => {\n      createdAlarms.delete(name);\n      return Promise.resolve(true);\n    }),\n    getAll: vi.fn(() => {\n      return Promise.resolve(\n        Array.from(createdAlarms.entries()).map(([name]) => ({ name, scheduledTime: 0 })),\n      );\n    }),\n    onAlarm: {\n      addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {\n        alarmListeners.push(listener);\n      }),\n      removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {\n        alarmListeners = alarmListeners.filter((l) => l !== listener);\n      }),\n    },\n  };\n\n  (globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };\n\n  return alarms;\n}\n\nfunction simulateAlarmFire(name: string) {\n  for (const listener of alarmListeners) {\n    listener({ name, scheduledTime: Date.now() });\n  }\n}\n\n// ==================== Tests ====================\n\ndescribe('IntervalTriggerHandler', () => {\n  let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;\n  let mockLogger: ReturnType<typeof createMockLogger>;\n  let fireCallback: ReturnType<typeof createMockFireCallback>;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockAlarms = setupMockChromeAlarms();\n    mockLogger = createMockLogger();\n    fireCallback = createMockFireCallback();\n  });\n\n  describe('install', () => {\n    it('creates repeating alarm with correct periodInMinutes', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec({ periodMinutes: 10 });\n\n      await handler.install(trigger);\n\n      expect(mockAlarms.create).toHaveBeenCalledWith(\n        'rr_v3_interval_interval-trigger-1',\n        expect.objectContaining({\n          periodInMinutes: 10,\n          delayInMinutes: 10,\n        }),\n      );\n    });\n\n    it('adds alarm listener on first install', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n\n      expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();\n\n      await handler.install(createIntervalTriggerSpec());\n\n      expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('registers trigger ID', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec();\n\n      await handler.install(trigger);\n\n      expect(handler.getInstalledIds()).toContain(trigger.id);\n    });\n\n    it('throws error for invalid periodMinutes', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n\n      await expect(\n        handler.install(createIntervalTriggerSpec({ periodMinutes: 0 })),\n      ).rejects.toThrow('periodMinutes must be >= 1');\n\n      await expect(\n        handler.install(createIntervalTriggerSpec({ periodMinutes: -5 })),\n      ).rejects.toThrow('periodMinutes must be >= 1');\n\n      await expect(\n        handler.install(createIntervalTriggerSpec({ periodMinutes: NaN as number })),\n      ).rejects.toThrow('periodMinutes must be a finite number');\n    });\n  });\n\n  describe('uninstall', () => {\n    it('clears alarm and removes trigger from installed list', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec();\n\n      await handler.install(trigger);\n      expect(handler.getInstalledIds()).toContain(trigger.id);\n\n      await handler.uninstall(trigger.id);\n\n      expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_interval_interval-trigger-1');\n      expect(handler.getInstalledIds()).not.toContain(trigger.id);\n    });\n\n    it('removes alarm listener when last trigger is uninstalled', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec();\n\n      await handler.install(trigger);\n      await handler.uninstall(trigger.id);\n\n      expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();\n    });\n  });\n\n  describe('uninstallAll', () => {\n    it('clears all interval alarms', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n\n      await handler.install(createIntervalTriggerSpec({ id: 'trigger-1' as TriggerId }));\n      await handler.install(createIntervalTriggerSpec({ id: 'trigger-2' as TriggerId }));\n\n      await handler.uninstallAll();\n\n      expect(handler.getInstalledIds()).toHaveLength(0);\n      expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();\n    });\n  });\n\n  describe('alarm handling', () => {\n    it('fires callback when alarm triggers', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec();\n\n      await handler.install(trigger);\n\n      // Simulate alarm fire\n      simulateAlarmFire('rr_v3_interval_interval-trigger-1');\n\n      // Wait for async callback\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith(\n        trigger.id,\n        expect.objectContaining({\n          sourceTabId: undefined,\n          sourceUrl: undefined,\n        }),\n      );\n    });\n\n    it('ignores alarms from other handlers', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      await handler.install(createIntervalTriggerSpec());\n\n      // Simulate alarm from different handler\n      simulateAlarmFire('rr_v3_cron_some-other-trigger');\n\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('ignores alarms for uninstalled triggers', async () => {\n      const handler = createIntervalTriggerHandler(fireCallback, { logger: mockLogger });\n      const trigger = createIntervalTriggerSpec();\n\n      await handler.install(trigger);\n      await handler.uninstall(trigger.id);\n\n      simulateAlarmFire('rr_v3_interval_interval-trigger-1');\n\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/manual-trigger.test.ts",
    "content": "/**\n * @fileoverview Manual Trigger Handler 测试 (P4-08)\n * @description\n * Tests for:\n * - Basic install/uninstall operations\n * - getInstalledIds tracking\n */\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createManualTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/manual-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\n// ==================== Manual Trigger Tests ====================\n\ndescribe('V3 ManualTriggerHandler', () => {\n  describe('Installation', () => {\n    it('installs trigger', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'manual'> = {\n        id: 't1' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1' as never,\n      };\n\n      await handler.install(trigger);\n\n      expect(handler.getInstalledIds()).toEqual(['t1']);\n    });\n\n    it('installs multiple triggers', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1' as never,\n      });\n\n      await handler.install({\n        id: 't2' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-2' as never,\n      });\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n    });\n  });\n\n  describe('Uninstallation', () => {\n    it('uninstalls trigger', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1' as never,\n      });\n\n      await handler.uninstall('t1');\n\n      expect(handler.getInstalledIds()).toEqual([]);\n    });\n\n    it('uninstallAll clears all triggers', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1' as never,\n      });\n\n      await handler.install({\n        id: 't2' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-2' as never,\n      });\n\n      await handler.uninstallAll();\n\n      expect(handler.getInstalledIds()).toEqual([]);\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns empty array when no triggers installed', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      expect(handler.getInstalledIds()).toEqual([]);\n    });\n\n    it('tracks partial uninstall', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createManualTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      await handler.install({\n        id: 't1' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1' as never,\n      });\n\n      await handler.install({\n        id: 't2' as never,\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-2' as never,\n      });\n\n      await handler.uninstall('t1');\n\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/once-trigger.test.ts",
    "content": "/**\n * @fileoverview Once Trigger Handler Tests\n * @description 测试 once 触发器的安装、卸载、触发和自动禁用行为\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport type { TriggerId, FlowId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createOnceTriggerHandler } from '@/entrypoints/background/record-replay-v3/engine/triggers/once-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createMockLogger() {\n  return {\n    debug: vi.fn(),\n    info: vi.fn(),\n    warn: vi.fn(),\n    error: vi.fn(),\n  };\n}\n\nfunction createMockFireCallback(): TriggerFireCallback & { calls: Array<{ triggerId: string }> } {\n  const calls: Array<{ triggerId: string }> = [];\n  return {\n    calls,\n    onFire: vi.fn(async (triggerId) => {\n      calls.push({ triggerId });\n    }),\n  };\n}\n\nfunction createOnceTriggerSpec(\n  overrides: Partial<TriggerSpecByKind<'once'>> = {},\n): TriggerSpecByKind<'once'> {\n  return {\n    id: 'once-trigger-1' as TriggerId,\n    kind: 'once',\n    flowId: 'flow-1' as FlowId,\n    enabled: true,\n    whenMs: Date.now() + 60000, // 1 minute from now\n    ...overrides,\n  };\n}\n\n// ==================== Mock chrome.alarms ====================\n\nlet alarmListeners: Array<(alarm: chrome.alarms.Alarm) => void> = [];\nlet createdAlarms: Map<string, { when?: number }> = new Map();\n\nfunction setupMockChromeAlarms() {\n  alarmListeners = [];\n  createdAlarms = new Map();\n\n  const alarms = {\n    create: vi.fn((name: string, info: { when?: number }) => {\n      createdAlarms.set(name, info);\n      return Promise.resolve();\n    }),\n    clear: vi.fn((name: string) => {\n      createdAlarms.delete(name);\n      return Promise.resolve(true);\n    }),\n    getAll: vi.fn(() => {\n      return Promise.resolve(\n        Array.from(createdAlarms.entries()).map(([name, info]) => ({\n          name,\n          scheduledTime: info.when ?? 0,\n        })),\n      );\n    }),\n    onAlarm: {\n      addListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {\n        alarmListeners.push(listener);\n      }),\n      removeListener: vi.fn((listener: (alarm: chrome.alarms.Alarm) => void) => {\n        alarmListeners = alarmListeners.filter((l) => l !== listener);\n      }),\n    },\n  };\n\n  (globalThis as unknown as { chrome: { alarms: typeof alarms } }).chrome = { alarms };\n\n  return alarms;\n}\n\nfunction simulateAlarmFire(name: string) {\n  for (const listener of alarmListeners) {\n    listener({ name, scheduledTime: Date.now() });\n  }\n}\n\n// ==================== Tests ====================\n\ndescribe('OnceTriggerHandler', () => {\n  let mockAlarms: ReturnType<typeof setupMockChromeAlarms>;\n  let mockLogger: ReturnType<typeof createMockLogger>;\n  let fireCallback: ReturnType<typeof createMockFireCallback>;\n  let disabledTriggers: Set<TriggerId>;\n  let mockDisableTrigger: (triggerId: TriggerId) => Promise<void>;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockAlarms = setupMockChromeAlarms();\n    mockLogger = createMockLogger();\n    fireCallback = createMockFireCallback();\n    disabledTriggers = new Set();\n    mockDisableTrigger = vi.fn(async (triggerId: TriggerId) => {\n      disabledTriggers.add(triggerId);\n    });\n  });\n\n  describe('install', () => {\n    it('creates one-shot alarm with correct when timestamp', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const futureTime = Date.now() + 300000; // 5 minutes\n      const trigger = createOnceTriggerSpec({ whenMs: futureTime });\n\n      await handler.install(trigger);\n\n      expect(mockAlarms.create).toHaveBeenCalledWith(\n        'rr_v3_once_once-trigger-1',\n        expect.objectContaining({ when: futureTime }),\n      );\n    });\n\n    it('adds alarm listener on first install', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n\n      expect(mockAlarms.onAlarm.addListener).not.toHaveBeenCalled();\n\n      await handler.install(createOnceTriggerSpec());\n\n      expect(mockAlarms.onAlarm.addListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('registers trigger ID', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n\n      expect(handler.getInstalledIds()).toContain(trigger.id);\n    });\n\n    it('throws error for invalid whenMs', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n\n      await expect(\n        handler.install(createOnceTriggerSpec({ whenMs: NaN as number })),\n      ).rejects.toThrow('whenMs must be a finite number');\n\n      await expect(\n        handler.install(createOnceTriggerSpec({ whenMs: Infinity as number })),\n      ).rejects.toThrow('whenMs must be a finite number');\n    });\n\n    it('floors whenMs to integer', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec({ whenMs: 1234567890123.999 });\n\n      await handler.install(trigger);\n\n      expect(mockAlarms.create).toHaveBeenCalledWith(\n        expect.any(String),\n        expect.objectContaining({ when: 1234567890123 }),\n      );\n    });\n  });\n\n  describe('uninstall', () => {\n    it('clears alarm and removes trigger from installed list', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n      expect(handler.getInstalledIds()).toContain(trigger.id);\n\n      await handler.uninstall(trigger.id);\n\n      expect(mockAlarms.clear).toHaveBeenCalledWith('rr_v3_once_once-trigger-1');\n      expect(handler.getInstalledIds()).not.toContain(trigger.id);\n    });\n\n    it('removes alarm listener when last trigger is uninstalled', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n      await handler.uninstall(trigger.id);\n\n      expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();\n    });\n  });\n\n  describe('uninstallAll', () => {\n    it('clears all once alarms', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n\n      await handler.install(createOnceTriggerSpec({ id: 'trigger-1' as TriggerId }));\n      await handler.install(createOnceTriggerSpec({ id: 'trigger-2' as TriggerId }));\n\n      await handler.uninstallAll();\n\n      expect(handler.getInstalledIds()).toHaveLength(0);\n      expect(mockAlarms.onAlarm.removeListener).toHaveBeenCalled();\n    });\n  });\n\n  describe('alarm handling', () => {\n    it('fires callback when alarm triggers', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n\n      // Simulate alarm fire\n      simulateAlarmFire('rr_v3_once_once-trigger-1');\n\n      // Wait for async callback\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).toHaveBeenCalledWith(\n        trigger.id,\n        expect.objectContaining({\n          sourceTabId: undefined,\n          sourceUrl: undefined,\n        }),\n      );\n    });\n\n    it('disables trigger after firing', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n      simulateAlarmFire('rr_v3_once_once-trigger-1');\n\n      // Wait for async callback\n      await new Promise((resolve) => setTimeout(resolve, 50));\n\n      expect(mockDisableTrigger).toHaveBeenCalledWith(trigger.id);\n      expect(disabledTriggers.has(trigger.id)).toBe(true);\n    });\n\n    it('uninstalls trigger after firing', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n      expect(handler.getInstalledIds()).toContain(trigger.id);\n\n      simulateAlarmFire('rr_v3_once_once-trigger-1');\n\n      // Wait for async cleanup\n      await new Promise((resolve) => setTimeout(resolve, 50));\n\n      expect(handler.getInstalledIds()).not.toContain(trigger.id);\n    });\n\n    it('ignores alarms from other handlers', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      await handler.install(createOnceTriggerSpec());\n\n      // Simulate alarm from different handler\n      simulateAlarmFire('rr_v3_interval_some-other-trigger');\n\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('ignores alarms for uninstalled triggers', async () => {\n      const handler = createOnceTriggerHandler(fireCallback, {\n        logger: mockLogger,\n        disableTrigger: mockDisableTrigger,\n      });\n      const trigger = createOnceTriggerSpec();\n\n      await handler.install(trigger);\n      await handler.uninstall(trigger.id);\n\n      simulateAlarmFire('rr_v3_once_once-trigger-1');\n\n      await new Promise((resolve) => setTimeout(resolve, 10));\n\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/queue.contract.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 Queue Contracts\n * @description\n * Verifies the persistence + atomic claim contracts for RunQueue:\n * - Basic CRUD operations (enqueue, get, list)\n * - Atomic claimNext with priority DESC + createdAt ASC (FIFO) ordering\n * - Lease management (markRunning, markPaused, markDone)\n * - Concurrent claim behavior\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest';\n\nimport {\n  DEFAULT_QUEUE_CONFIG,\n  type RunQueueItem,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\n\nimport {\n  createQueueStore,\n  closeRrV3Db,\n  deleteRrV3Db,\n} from '@/entrypoints/background/record-replay-v3';\n\ndescribe('V3 Queue contracts', () => {\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n  });\n\n  describe('Basic CRUD', () => {\n    it('enqueue creates a queued item with correct defaults', async () => {\n      const queue = createQueueStore();\n\n      const item = await queue.enqueue({\n        id: 'run-1',\n        flowId: 'flow-1',\n        priority: 5,\n      });\n\n      expect(item).toMatchObject({\n        id: 'run-1',\n        flowId: 'flow-1',\n        priority: 5,\n        status: 'queued',\n        attempt: 0,\n      });\n      expect(item.createdAt).toBeGreaterThan(0);\n      expect(item.updatedAt).toBeGreaterThan(0);\n    });\n\n    it('get retrieves an enqueued item', async () => {\n      const queue = createQueueStore();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n\n      const retrieved = await queue.get('run-1');\n      expect(retrieved).not.toBeNull();\n      expect(retrieved!.id).toBe('run-1');\n    });\n\n    it('get returns null for non-existent item', async () => {\n      const queue = createQueueStore();\n\n      const retrieved = await queue.get('non-existent');\n      expect(retrieved).toBeNull();\n    });\n\n    it('list returns all items when no filter', async () => {\n      const queue = createQueueStore();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 2 });\n\n      const items = await queue.list();\n      expect(items).toHaveLength(2);\n    });\n\n    it('list filters by status', async () => {\n      const queue = createQueueStore();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 2 });\n      await queue.markRunning('run-1', 'owner-1', Date.now());\n\n      const queued = await queue.list('queued');\n      const running = await queue.list('running');\n\n      expect(queued).toHaveLength(1);\n      expect(queued[0].id).toBe('run-2');\n      expect(running).toHaveLength(1);\n      expect(running[0].id).toBe('run-1');\n    });\n  });\n\n  describe('Atomic claimNext', () => {\n    it('returns null when queue is empty', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      const claimed = await queue.claimNext('owner-1', now);\n      expect(claimed).toBeNull();\n    });\n\n    it('claims the highest priority item first', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      // Enqueue with different priorities (lower number = lower priority)\n      await queue.enqueue({ id: 'low', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'high', flowId: 'flow-1', priority: 10 });\n      await queue.enqueue({ id: 'medium', flowId: 'flow-1', priority: 5 });\n\n      const claimed = await queue.claimNext('owner-1', now);\n      expect(claimed).not.toBeNull();\n      expect(claimed!.id).toBe('high');\n      expect(claimed!.status).toBe('running');\n      expect(claimed!.priority).toBe(10);\n    });\n\n    it('claims FIFO within same priority (earlier createdAt first)', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      // Enqueue items with same priority\n      // Small delays ensure different createdAt timestamps\n      await queue.enqueue({ id: 'first', flowId: 'flow-1', priority: 5 });\n      await new Promise((r) => setTimeout(r, 5));\n      await queue.enqueue({ id: 'second', flowId: 'flow-1', priority: 5 });\n      await new Promise((r) => setTimeout(r, 5));\n      await queue.enqueue({ id: 'third', flowId: 'flow-1', priority: 5 });\n\n      // First claim should get 'first'\n      const claim1 = await queue.claimNext('owner-1', now);\n      expect(claim1!.id).toBe('first');\n\n      // Second claim should get 'second'\n      const claim2 = await queue.claimNext('owner-1', now);\n      expect(claim2!.id).toBe('second');\n\n      // Third claim should get 'third'\n      const claim3 = await queue.claimNext('owner-1', now);\n      expect(claim3!.id).toBe('third');\n\n      // Fourth claim should return null\n      const claim4 = await queue.claimNext('owner-1', now);\n      expect(claim4).toBeNull();\n    });\n\n    it('atomically updates item to running with lease', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n\n      const claimed = await queue.claimNext('owner-1', now);\n\n      expect(claimed).toMatchObject({\n        id: 'run-1',\n        status: 'running',\n        attempt: 1,\n        lease: {\n          ownerId: 'owner-1',\n        },\n      });\n      expect(claimed!.lease!.expiresAt).toBeGreaterThan(now);\n      expect(claimed!.updatedAt).toBeGreaterThanOrEqual(now);\n    });\n\n    it('persists the claimed item as running in the store', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      const claimed = await queue.claimNext('owner-1', now);\n      expect(claimed).not.toBeNull();\n\n      // Verify persistence via get()\n      const stored = await queue.get('run-1');\n      expect(stored).toMatchObject({\n        id: 'run-1',\n        status: 'running',\n        attempt: 1,\n        lease: { ownerId: 'owner-1' },\n      });\n    });\n\n    it('increments attempt on each claim', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n\n      // First claim\n      let claimed = await queue.claimNext('owner-1', now);\n      expect(claimed!.attempt).toBe(1);\n\n      // Re-queue by marking as queued (simulating retry)\n      await queue.markDone('run-1', now);\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n\n      // Second claim\n      claimed = await queue.claimNext('owner-2', now);\n      expect(claimed!.attempt).toBe(1); // New enqueue resets attempt\n    });\n\n    it('throws on invalid ownerId', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await expect(queue.claimNext('', now)).rejects.toThrow('ownerId is required');\n    });\n\n    it('throws on invalid now timestamp', async () => {\n      const queue = createQueueStore();\n\n      await expect(queue.claimNext('owner-1', NaN)).rejects.toThrow('Invalid now');\n      await expect(queue.claimNext('owner-1', Infinity)).rejects.toThrow('Invalid now');\n    });\n\n    it('concurrent claims do not return the same item', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      // Enqueue multiple items\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-3', flowId: 'flow-1', priority: 1 });\n\n      // Claim concurrently\n      const claims = await Promise.all([\n        queue.claimNext('owner-1', now),\n        queue.claimNext('owner-2', now),\n        queue.claimNext('owner-3', now),\n      ]);\n\n      // Filter out nulls\n      const claimed = claims.filter((c): c is RunQueueItem => c !== null);\n      expect(claimed).toHaveLength(3);\n\n      // All claimed items should have unique IDs\n      const ids = claimed.map((c) => c.id);\n      expect(new Set(ids).size).toBe(3);\n\n      // All should be running\n      expect(claimed.every((c) => c.status === 'running')).toBe(true);\n    });\n\n    it('skips non-queued items', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 10 });\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 5 });\n\n      // Mark the higher priority one as running\n      await queue.markRunning('run-1', 'owner-1', now);\n\n      // claimNext should skip run-1 and return run-2\n      const claimed = await queue.claimNext('owner-2', now);\n      expect(claimed!.id).toBe('run-2');\n    });\n  });\n\n  describe('Status transitions', () => {\n    it('markRunning updates status and creates lease', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.markRunning('run-1', 'owner-1', now);\n\n      const item = await queue.get('run-1');\n      expect(item!.status).toBe('running');\n      expect(item!.lease).toMatchObject({\n        ownerId: 'owner-1',\n      });\n      expect(item!.attempt).toBe(1);\n    });\n\n    it('markPaused updates status while keeping lease', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.markRunning('run-1', 'owner-1', now);\n      await queue.markPaused('run-1', 'owner-1', now + 1000);\n\n      const item = await queue.get('run-1');\n      expect(item!.status).toBe('paused');\n      expect(item!.lease!.ownerId).toBe('owner-1');\n    });\n\n    it('markDone removes item from queue', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.markDone('run-1', now);\n\n      const item = await queue.get('run-1');\n      expect(item).toBeNull();\n    });\n\n    it('cancel removes item from queue', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.cancel('run-1', now, 'User cancelled');\n\n      const item = await queue.get('run-1');\n      expect(item).toBeNull();\n    });\n\n    it('markRunning throws for non-existent item', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await expect(queue.markRunning('non-existent', 'owner-1', now)).rejects.toThrow(\n        'Queue item \"non-existent\" not found',\n      );\n    });\n\n    it('markPaused throws for non-existent item', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await expect(queue.markPaused('non-existent', 'owner-1', now)).rejects.toThrow(\n        'Queue item \"non-existent\" not found',\n      );\n    });\n  });\n\n  describe('Lease heartbeat', () => {\n    it('renews leases for running and paused items owned by ownerId', async () => {\n      const queue = createQueueStore();\n      const t0 = 1_700_000_000_000;\n      const t1 = t0 + 1_234;\n\n      await queue.enqueue({ id: 'run-running', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-paused', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-other', flowId: 'flow-1', priority: 1 });\n\n      await queue.markRunning('run-running', 'owner-1', t0);\n      await queue.markPaused('run-paused', 'owner-1', t0);\n      await queue.markRunning('run-other', 'owner-2', t0);\n\n      const otherBefore = await queue.get('run-other');\n      const otherExpiresAtBefore = otherBefore!.lease!.expiresAt;\n\n      await queue.heartbeat('owner-1', t1);\n\n      const running = await queue.get('run-running');\n      const paused = await queue.get('run-paused');\n      const otherAfter = await queue.get('run-other');\n\n      // Owner-1's items should have renewed leases\n      expect(running!.lease!.expiresAt).toBe(t1 + DEFAULT_QUEUE_CONFIG.leaseTtlMs);\n      expect(paused!.lease!.expiresAt).toBe(t1 + DEFAULT_QUEUE_CONFIG.leaseTtlMs);\n      // Owner-2's item should be unchanged\n      expect(otherAfter!.lease!.expiresAt).toBe(otherExpiresAtBefore);\n    });\n\n    it('is a no-op when the owner has no leased items', async () => {\n      const queue = createQueueStore();\n      await expect(queue.heartbeat('owner-1', 1_700_000_000_000)).resolves.toBeUndefined();\n    });\n\n    it('throws on invalid ownerId', async () => {\n      const queue = createQueueStore();\n      await expect(queue.heartbeat('', Date.now())).rejects.toThrow('ownerId is required');\n    });\n\n    it('throws on invalid now timestamp', async () => {\n      const queue = createQueueStore();\n      await expect(queue.heartbeat('owner-1', NaN)).rejects.toThrow('Invalid now');\n    });\n  });\n\n  describe('Lease reclamation', () => {\n    it('requeues an expired running item and clears the lease', async () => {\n      const queue = createQueueStore();\n      const t0 = 1_700_000_000_000;\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.markRunning('run-1', 'owner-1', t0);\n\n      const expiresAt = t0 + DEFAULT_QUEUE_CONFIG.leaseTtlMs;\n\n      // Not expired when expiresAt === now (expiry is strictly < now)\n      expect(await queue.reclaimExpiredLeases(expiresAt)).toEqual([]);\n\n      // Expired when expiresAt < now\n      expect(await queue.reclaimExpiredLeases(expiresAt + 1)).toEqual(['run-1']);\n\n      const item = await queue.get('run-1');\n      expect(item).toMatchObject({ id: 'run-1', status: 'queued', attempt: 1 });\n      expect(item!.lease).toBeUndefined();\n    });\n\n    it('requeues an expired paused item and keeps attempt count', async () => {\n      const queue = createQueueStore();\n      const t0 = 1_700_000_000_000;\n\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 1 });\n      // markPaused doesn't increment attempt (only markRunning/claimNext does)\n      await queue.markPaused('run-2', 'owner-1', t0);\n\n      const expiresAt = t0 + DEFAULT_QUEUE_CONFIG.leaseTtlMs;\n\n      expect(await queue.reclaimExpiredLeases(expiresAt + 1)).toEqual(['run-2']);\n\n      const item = await queue.get('run-2');\n      expect(item).toMatchObject({ id: 'run-2', status: 'queued', attempt: 0 });\n      expect(item!.lease).toBeUndefined();\n    });\n\n    it('reclaims multiple expired items in one call', async () => {\n      const queue = createQueueStore();\n      const t0 = 1_700_000_000_000;\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-2', flowId: 'flow-1', priority: 1 });\n      await queue.enqueue({ id: 'run-3', flowId: 'flow-1', priority: 1 });\n\n      await queue.markRunning('run-1', 'owner-1', t0);\n      await queue.markPaused('run-2', 'owner-1', t0);\n      // run-3 stays queued (no lease)\n\n      const expiresAt = t0 + DEFAULT_QUEUE_CONFIG.leaseTtlMs;\n      const reclaimed = await queue.reclaimExpiredLeases(expiresAt + 1);\n\n      expect(reclaimed.sort()).toEqual(['run-1', 'run-2']);\n\n      // All should be back to queued\n      const run1 = await queue.get('run-1');\n      const run2 = await queue.get('run-2');\n      const run3 = await queue.get('run-3');\n\n      expect(run1!.status).toBe('queued');\n      expect(run2!.status).toBe('queued');\n      expect(run3!.status).toBe('queued');\n    });\n\n    it('returns empty array when no items are expired', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n      await queue.markRunning('run-1', 'owner-1', now);\n\n      // Check before expiration\n      const reclaimed = await queue.reclaimExpiredLeases(now);\n      expect(reclaimed).toEqual([]);\n    });\n\n    it('throws on invalid now timestamp', async () => {\n      const queue = createQueueStore();\n      await expect(queue.reclaimExpiredLeases(NaN)).rejects.toThrow('Invalid now');\n    });\n\n    it('reclaimed item can be claimed again with incremented attempt', async () => {\n      const queue = createQueueStore();\n      const t0 = 1_700_000_000_000;\n\n      await queue.enqueue({ id: 'run-1', flowId: 'flow-1', priority: 1 });\n\n      // First claim: attempt becomes 1\n      const claim1 = await queue.claimNext('owner-1', t0);\n      expect(claim1!.attempt).toBe(1);\n\n      // Simulate lease expiration and reclaim\n      const expiresAt = t0 + DEFAULT_QUEUE_CONFIG.leaseTtlMs;\n      await queue.reclaimExpiredLeases(expiresAt + 1);\n\n      // Verify item is back to queued with attempt preserved\n      const afterReclaim = await queue.get('run-1');\n      expect(afterReclaim!.status).toBe('queued');\n      expect(afterReclaim!.attempt).toBe(1);\n\n      // Second claim: attempt becomes 2\n      const claim2 = await queue.claimNext('owner-2', expiresAt + 100);\n      expect(claim2!.id).toBe('run-1');\n      expect(claim2!.attempt).toBe(2);\n    });\n  });\n\n  describe('Priority ordering edge cases', () => {\n    it('handles negative priorities', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'neg', flowId: 'flow-1', priority: -5 });\n      await queue.enqueue({ id: 'zero', flowId: 'flow-1', priority: 0 });\n      await queue.enqueue({ id: 'pos', flowId: 'flow-1', priority: 5 });\n\n      const claim1 = await queue.claimNext('owner-1', now);\n      expect(claim1!.id).toBe('pos'); // Highest priority first\n\n      const claim2 = await queue.claimNext('owner-1', now);\n      expect(claim2!.id).toBe('zero');\n\n      const claim3 = await queue.claimNext('owner-1', now);\n      expect(claim3!.id).toBe('neg');\n    });\n\n    it('handles large priority values', async () => {\n      const queue = createQueueStore();\n      const now = Date.now();\n\n      await queue.enqueue({ id: 'max', flowId: 'flow-1', priority: Number.MAX_SAFE_INTEGER });\n      await queue.enqueue({ id: 'min', flowId: 'flow-1', priority: Number.MIN_SAFE_INTEGER });\n      await queue.enqueue({ id: 'mid', flowId: 'flow-1', priority: 0 });\n\n      const claim1 = await queue.claimNext('owner-1', now);\n      expect(claim1!.id).toBe('max');\n\n      const claim2 = await queue.claimNext('owner-1', now);\n      expect(claim2!.id).toBe('mid');\n\n      const claim3 = await queue.claimNext('owner-1', now);\n      expect(claim3!.id).toBe('min');\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/recovery.test.ts",
    "content": "/**\n * @fileoverview 崩溃恢复测试 (P3-06)\n * @description\n * Tests for:\n * - recoverOrphanLeases (queue-level)\n * - RecoveryCoordinator (orchestration)\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type {\n  RunRecordV3,\n  RunEvent,\n} from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';\nimport type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';\nimport type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport { DEFAULT_QUEUE_CONFIG } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport {\n  createQueueStore,\n  closeRrV3Db,\n  deleteRrV3Db,\n} from '@/entrypoints/background/record-replay-v3';\nimport { recoverFromCrash } from '@/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator';\n\n// ==================== Queue-level Tests ====================\n\ndescribe('recoverOrphanLeases', () => {\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n  });\n\n  it('requeues orphan running items and adopts orphan paused items', async () => {\n    const queue = createQueueStore();\n    const t0 = 1_700_000_000_000;\n    const t1 = t0 + 1234;\n\n    await queue.enqueue({ id: 'run-running' as any, flowId: 'flow-1' as any, priority: 1 });\n    await queue.enqueue({ id: 'run-paused' as any, flowId: 'flow-1' as any, priority: 1 });\n\n    await queue.markRunning('run-running' as any, 'old-owner', t0);\n    await queue.markPaused('run-paused' as any, 'old-owner', t0);\n\n    const recovered = await queue.recoverOrphanLeases('new-owner', t1);\n\n    expect(recovered).toEqual({\n      requeuedRunning: [{ runId: 'run-running', prevOwnerId: 'old-owner' }],\n      adoptedPaused: [{ runId: 'run-paused', prevOwnerId: 'old-owner' }],\n    });\n\n    const runningAfter = await queue.get('run-running' as any);\n    expect(runningAfter).toMatchObject({ id: 'run-running', status: 'queued', attempt: 1 });\n    expect(runningAfter!.lease).toBeUndefined();\n\n    const pausedAfter = await queue.get('run-paused' as any);\n    expect(pausedAfter).toMatchObject({\n      id: 'run-paused',\n      status: 'paused',\n      attempt: 0,\n      lease: { ownerId: 'new-owner' },\n    });\n    expect(pausedAfter!.lease!.expiresAt).toBe(t1 + DEFAULT_QUEUE_CONFIG.leaseTtlMs);\n  });\n\n  it('skips items already owned by the current ownerId', async () => {\n    const queue = createQueueStore();\n    const t0 = 1_700_000_000_000;\n\n    await queue.enqueue({ id: 'run-running' as any, flowId: 'flow-1' as any, priority: 1 });\n    await queue.enqueue({ id: 'run-paused' as any, flowId: 'flow-1' as any, priority: 1 });\n\n    await queue.markRunning('run-running' as any, 'owner-1', t0);\n    await queue.markPaused('run-paused' as any, 'owner-1', t0);\n\n    const recovered = await queue.recoverOrphanLeases('owner-1', t0 + 1);\n    expect(recovered).toEqual({ requeuedRunning: [], adoptedPaused: [] });\n\n    const runningAfter = await queue.get('run-running' as any);\n    expect(runningAfter).toMatchObject({\n      id: 'run-running',\n      status: 'running',\n      lease: { ownerId: 'owner-1' },\n    });\n\n    const pausedAfter = await queue.get('run-paused' as any);\n    expect(pausedAfter).toMatchObject({\n      id: 'run-paused',\n      status: 'paused',\n      lease: { ownerId: 'owner-1' },\n    });\n  });\n\n  it('handles items without lease (defensive)', async () => {\n    const queue = createQueueStore();\n    const t0 = 1_700_000_000_000;\n\n    // Enqueue and claim, but the item will have lease\n    await queue.enqueue({ id: 'run-1' as any, flowId: 'flow-1' as any });\n\n    // Directly mark as running (with lease)\n    await queue.markRunning('run-1' as any, 'old-owner', t0);\n\n    // Recover with new owner\n    const recovered = await queue.recoverOrphanLeases('new-owner', t0 + 1);\n    expect(recovered.requeuedRunning).toHaveLength(1);\n    expect(recovered.requeuedRunning[0].runId).toBe('run-1');\n  });\n\n  it('preserves attempt count during recovery', async () => {\n    const queue = createQueueStore();\n    const t0 = 1_700_000_000_000;\n\n    await queue.enqueue({ id: 'run-1' as any, flowId: 'flow-1' as any });\n\n    // Simulate multiple claim cycles\n    await queue.claimNext('owner-1', t0); // attempt becomes 1\n    // Simulate crash by recovering with new owner\n    await queue.recoverOrphanLeases('owner-2', t0 + 1);\n\n    const item = await queue.get('run-1' as any);\n    expect(item?.status).toBe('queued');\n    expect(item?.attempt).toBe(1); // Preserved, not reset\n\n    // Next claim will increment\n    const claimed = await queue.claimNext('owner-2', t0 + 2);\n    expect(claimed?.attempt).toBe(2);\n  });\n\n  it('rejects empty ownerId', async () => {\n    const queue = createQueueStore();\n    await expect(queue.recoverOrphanLeases('', Date.now())).rejects.toThrow('ownerId is required');\n  });\n\n  it('rejects invalid now', async () => {\n    const queue = createQueueStore();\n    await expect(queue.recoverOrphanLeases('owner', NaN)).rejects.toThrow('Invalid now');\n    await expect(queue.recoverOrphanLeases('owner', Infinity)).rejects.toThrow('Invalid now');\n  });\n});\n\n// ==================== RecoveryCoordinator Tests ====================\n\ndescribe('RecoveryCoordinator', () => {\n  function createMockStorage(): StoragePort & {\n    _queueMap: Map<string, RunQueueItem>;\n    _runsMap: Map<string, RunRecordV3>;\n  } {\n    const queueMap = new Map<string, RunQueueItem>();\n    const runsMap = new Map<string, RunRecordV3>();\n\n    const queue = {\n      list: vi.fn(async () => Array.from(queueMap.values())),\n      get: vi.fn(async (runId: string) => queueMap.get(runId) ?? null),\n      markDone: vi.fn(async (runId: string) => {\n        queueMap.delete(runId);\n      }),\n      recoverOrphanLeases: vi.fn(async (ownerId: string, now: number) => {\n        const requeuedRunning: Array<{ runId: string; prevOwnerId?: string }> = [];\n        const adoptedPaused: Array<{ runId: string; prevOwnerId?: string }> = [];\n\n        for (const [runId, item] of queueMap) {\n          if (item.status === 'running') {\n            const isOrphan = !item.lease || item.lease.ownerId !== ownerId;\n            if (isOrphan) {\n              const prevOwnerId = item.lease?.ownerId;\n              item.status = 'queued';\n              item.updatedAt = now;\n              delete (item as any).lease;\n              requeuedRunning.push({ runId, ...(prevOwnerId ? { prevOwnerId } : {}) });\n            }\n          } else if (item.status === 'paused') {\n            const isOrphan = !item.lease || item.lease.ownerId !== ownerId;\n            if (isOrphan) {\n              const prevOwnerId = item.lease?.ownerId;\n              item.updatedAt = now;\n              item.lease = { ownerId, expiresAt: now + 15_000 };\n              adoptedPaused.push({ runId, ...(prevOwnerId ? { prevOwnerId } : {}) });\n            }\n          }\n        }\n\n        return { requeuedRunning, adoptedPaused };\n      }),\n    };\n\n    const runs = {\n      get: vi.fn(async (id: string) => runsMap.get(id) ?? null),\n      patch: vi.fn(async (id: string, patch: Partial<RunRecordV3>) => {\n        const existing = runsMap.get(id);\n        if (existing) {\n          runsMap.set(id, { ...existing, ...patch });\n        }\n      }),\n    };\n\n    return {\n      flows: {} as any,\n      runs: runs as any,\n      events: {} as any,\n      queue: queue as any,\n      persistentVars: {} as any,\n      triggers: {} as any,\n      _queueMap: queueMap,\n      _runsMap: runsMap,\n    };\n  }\n\n  function createMockEventsBus(): EventsBus & { _events: RunEvent[] } {\n    const events: RunEvent[] = [];\n    return {\n      subscribe: vi.fn(() => () => {}),\n      append: vi.fn(async (event: any) => {\n        const fullEvent = { ...event, ts: event.ts ?? Date.now(), seq: events.length + 1 };\n        events.push(fullEvent);\n        return fullEvent;\n      }),\n      list: vi.fn(async () => []),\n      _events: events,\n    } as EventsBus & { _events: RunEvent[] };\n  }\n\n  function createRunRecord(id: string, status: string): RunRecordV3 {\n    return {\n      schemaVersion: 3,\n      id: id as any,\n      flowId: 'flow-1' as any,\n      status: status as any,\n      createdAt: Date.now(),\n      updatedAt: Date.now(),\n      attempt: 1,\n      maxAttempts: 3,\n      nextSeq: 0,\n    };\n  }\n\n  function createQueueItem(id: string, status: string, ownerId?: string): RunQueueItem {\n    return {\n      id: id as any,\n      flowId: 'flow-1' as any,\n      status: status as any,\n      createdAt: Date.now(),\n      updatedAt: Date.now(),\n      priority: 0,\n      attempt: 1,\n      maxAttempts: 3,\n      lease: ownerId ? { ownerId, expiresAt: Date.now() + 15_000 } : undefined,\n    };\n  }\n\n  it('requeues orphan running and emits run.recovered event', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n    const fixedNow = 1_700_000_000_000;\n\n    // Setup: running item with old owner\n    storage._queueMap.set('run-1', createQueueItem('run-1', 'running', 'old-owner'));\n    storage._runsMap.set('run-1', createRunRecord('run-1', 'running'));\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'new-owner',\n      now: () => fixedNow,\n    });\n\n    expect(result.requeuedRunning).toEqual(['run-1']);\n    expect(result.adoptedPaused).toEqual([]);\n    expect(result.cleanedTerminal).toEqual([]);\n\n    // Check RunRecord was patched\n    expect(storage.runs.patch).toHaveBeenCalledWith('run-1', {\n      status: 'queued',\n      updatedAt: fixedNow,\n    });\n\n    // Check event was emitted\n    expect(events._events).toHaveLength(1);\n    expect(events._events[0]).toMatchObject({\n      runId: 'run-1',\n      type: 'run.recovered',\n      reason: 'sw_restart',\n      fromStatus: 'running',\n      toStatus: 'queued',\n      prevOwnerId: 'old-owner',\n    });\n  });\n\n  it('adopts orphan paused without emitting event', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n    const fixedNow = 1_700_000_000_000;\n\n    // Setup: paused item with old owner\n    storage._queueMap.set('run-1', createQueueItem('run-1', 'paused', 'old-owner'));\n    storage._runsMap.set('run-1', createRunRecord('run-1', 'paused'));\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'new-owner',\n      now: () => fixedNow,\n    });\n\n    expect(result.requeuedRunning).toEqual([]);\n    expect(result.adoptedPaused).toEqual(['run-1']);\n    expect(result.cleanedTerminal).toEqual([]);\n\n    // No event for adopted paused (they stay paused)\n    expect(events._events).toHaveLength(0);\n  });\n\n  it('cleans terminal runs from queue', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n\n    // Setup: terminal run still in queue (crash between runner finish and scheduler markDone)\n    storage._queueMap.set('run-1', createQueueItem('run-1', 'running', 'old-owner'));\n    storage._runsMap.set('run-1', createRunRecord('run-1', 'succeeded'));\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'new-owner',\n      now: () => Date.now(),\n    });\n\n    expect(result.cleanedTerminal).toEqual(['run-1']);\n    expect(storage.queue.markDone).toHaveBeenCalledWith('run-1', expect.any(Number));\n  });\n\n  it('cleans queue items without RunRecord', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n\n    // Setup: queue item without RunRecord (orphan)\n    storage._queueMap.set('run-orphan', createQueueItem('run-orphan', 'queued'));\n    // Note: no corresponding RunRecord\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'new-owner',\n      now: () => Date.now(),\n    });\n\n    expect(result.cleanedTerminal).toEqual(['run-orphan']);\n  });\n\n  it('skips items already owned by current ownerId', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n\n    // Setup: running item with current owner\n    storage._queueMap.set('run-1', createQueueItem('run-1', 'running', 'current-owner'));\n    storage._runsMap.set('run-1', createRunRecord('run-1', 'running'));\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'current-owner',\n      now: () => Date.now(),\n    });\n\n    expect(result.requeuedRunning).toEqual([]);\n    expect(result.adoptedPaused).toEqual([]);\n    expect(result.cleanedTerminal).toEqual([]);\n    expect(events._events).toHaveLength(0);\n  });\n\n  it('handles mixed recovery scenario', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n    const fixedNow = 1_700_000_000_000;\n\n    // Setup: various scenarios\n    storage._queueMap.set(\n      'run-running-orphan',\n      createQueueItem('run-running-orphan', 'running', 'old-owner'),\n    );\n    storage._runsMap.set('run-running-orphan', createRunRecord('run-running-orphan', 'running'));\n\n    storage._queueMap.set(\n      'run-paused-orphan',\n      createQueueItem('run-paused-orphan', 'paused', 'old-owner'),\n    );\n    storage._runsMap.set('run-paused-orphan', createRunRecord('run-paused-orphan', 'paused'));\n\n    storage._queueMap.set('run-terminal', createQueueItem('run-terminal', 'running', 'old-owner'));\n    storage._runsMap.set('run-terminal', createRunRecord('run-terminal', 'failed'));\n\n    storage._queueMap.set(\n      'run-current-owner',\n      createQueueItem('run-current-owner', 'running', 'new-owner'),\n    );\n    storage._runsMap.set('run-current-owner', createRunRecord('run-current-owner', 'running'));\n\n    const result = await recoverFromCrash({\n      storage,\n      events,\n      ownerId: 'new-owner',\n      now: () => fixedNow,\n    });\n\n    expect(result.cleanedTerminal).toContain('run-terminal');\n    expect(result.requeuedRunning).toContain('run-running-orphan');\n    expect(result.adoptedPaused).toContain('run-paused-orphan');\n    // Current owner items are not affected\n    expect(result.requeuedRunning).not.toContain('run-current-owner');\n  });\n\n  it('throws if ownerId is empty', async () => {\n    const storage = createMockStorage();\n    const events = createMockEventsBus();\n\n    await expect(\n      recoverFromCrash({\n        storage,\n        events,\n        ownerId: '',\n        now: () => Date.now(),\n      }),\n    ).rejects.toThrow('ownerId is required');\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-unsafe-function-type */\n/**\n * @fileoverview Record-Replay V3 RPC API Tests\n * @description\n * Tests for the queue management RPC APIs:\n * - rr_v3.enqueueRun\n * - rr_v3.listQueue\n * - rr_v3.cancelQueueItem\n *\n * Tests for Flow CRUD RPC APIs:\n * - rr_v3.saveFlow\n * - rr_v3.deleteFlow\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';\nimport type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';\nimport type { RunScheduler } from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\nimport type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport { RpcServer } from '@/entrypoints/background/record-replay-v3/engine/transport/rpc-server';\n\n// ==================== Test Utilities ====================\n\nfunction createMockStorage(): StoragePort {\n  const flowsMap = new Map<string, FlowV3>();\n  const runsMap = new Map<string, RunRecordV3>();\n  const queueMap = new Map<string, RunQueueItem>();\n  const eventsLog: Array<{ runId: string; type: string }> = [];\n\n  return {\n    flows: {\n      list: vi.fn(async () => Array.from(flowsMap.values())),\n      get: vi.fn(async (id: string) => flowsMap.get(id) ?? null),\n      save: vi.fn(async (flow: FlowV3) => {\n        flowsMap.set(flow.id, flow);\n      }),\n      delete: vi.fn(async (id: string) => {\n        flowsMap.delete(id);\n      }),\n    },\n    runs: {\n      list: vi.fn(async () => Array.from(runsMap.values())),\n      get: vi.fn(async (id: string) => runsMap.get(id) ?? null),\n      save: vi.fn(async (record: RunRecordV3) => {\n        runsMap.set(record.id, record);\n      }),\n      patch: vi.fn(async (id: string, patch: Partial<RunRecordV3>) => {\n        const existing = runsMap.get(id);\n        if (existing) {\n          runsMap.set(id, { ...existing, ...patch });\n        }\n      }),\n    },\n    events: {\n      append: vi.fn(async (event: { runId: string; type: string }) => {\n        eventsLog.push(event);\n        return { ...event, ts: Date.now(), seq: eventsLog.length };\n      }),\n      list: vi.fn(async () => eventsLog),\n    },\n    queue: {\n      enqueue: vi.fn(async (input) => {\n        const now = Date.now();\n        const item: RunQueueItem = {\n          ...input,\n          priority: input.priority ?? 0,\n          maxAttempts: input.maxAttempts ?? 1,\n          status: 'queued',\n          createdAt: now,\n          updatedAt: now,\n          attempt: 0,\n        };\n        queueMap.set(input.id, item);\n        return item;\n      }),\n      claimNext: vi.fn(async () => null),\n      heartbeat: vi.fn(async () => {}),\n      reclaimExpiredLeases: vi.fn(async () => []),\n      markRunning: vi.fn(async () => {}),\n      markPaused: vi.fn(async () => {}),\n      markDone: vi.fn(async () => {}),\n      cancel: vi.fn(async (runId: string) => {\n        queueMap.delete(runId);\n      }),\n      get: vi.fn(async (runId: string) => queueMap.get(runId) ?? null),\n      list: vi.fn(async (status?: string) => {\n        const items = Array.from(queueMap.values());\n        if (status) {\n          return items.filter((item) => item.status === status);\n        }\n        return items;\n      }),\n    },\n    persistentVars: {\n      get: vi.fn(async () => undefined),\n      set: vi.fn(async () => ({ key: '', value: null, updatedAt: 0 })),\n      delete: vi.fn(async () => {}),\n      list: vi.fn(async () => []),\n    },\n    triggers: {\n      list: vi.fn(async () => []),\n      get: vi.fn(async () => null),\n      save: vi.fn(async () => {}),\n      delete: vi.fn(async () => {}),\n    },\n    // Expose internal maps for assertions\n    _internal: { flowsMap, runsMap, queueMap, eventsLog },\n  } as unknown as StoragePort & {\n    _internal: {\n      flowsMap: Map<string, FlowV3>;\n      runsMap: Map<string, RunRecordV3>;\n      queueMap: Map<string, RunQueueItem>;\n      eventsLog: Array<{ runId: string; type: string }>;\n    };\n  };\n}\n\nfunction createMockEventsBus(): EventsBus {\n  const subscribers: Array<(event: unknown) => void> = [];\n  return {\n    subscribe: vi.fn((callback: (event: unknown) => void) => {\n      subscribers.push(callback);\n      return () => {\n        const idx = subscribers.indexOf(callback);\n        if (idx >= 0) subscribers.splice(idx, 1);\n      };\n    }),\n    append: vi.fn(async (event) => {\n      const fullEvent = { ...event, ts: Date.now(), seq: 1 };\n      subscribers.forEach((cb) => cb(fullEvent));\n      return fullEvent as ReturnType<EventsBus['append']> extends Promise<infer T> ? T : never;\n    }),\n    list: vi.fn(async () => []),\n  } as EventsBus;\n}\n\nfunction createMockScheduler(): RunScheduler {\n  return {\n    start: vi.fn(),\n    stop: vi.fn(),\n    kick: vi.fn(async () => {}),\n    getState: vi.fn(() => ({\n      started: false,\n      ownerId: 'test-owner',\n      maxParallelRuns: 3,\n      activeRunIds: [],\n    })),\n    dispose: vi.fn(),\n  };\n}\n\nfunction createTestFlow(id: string, options: { withNodes?: boolean } = {}): FlowV3 {\n  const now = new Date().toISOString();\n  const nodes =\n    options.withNodes !== false\n      ? [\n          { id: 'node-start', kind: 'test', config: {} },\n          { id: 'node-end', kind: 'test', config: {} },\n        ]\n      : [];\n  return {\n    schemaVersion: 3,\n    id: id as FlowV3['id'],\n    name: `Test Flow ${id}`,\n    entryNodeId: 'node-start' as FlowV3['entryNodeId'],\n    nodes: nodes as FlowV3['nodes'],\n    edges: [{ id: 'edge-1', from: 'node-start', to: 'node-end' }] as FlowV3['edges'],\n    variables: [],\n    createdAt: now,\n    updatedAt: now,\n  };\n}\n\n// Helper type for accessing internal maps in mock storage\ninterface MockStorageInternal {\n  flowsMap: Map<string, FlowV3>;\n  runsMap: Map<string, RunRecordV3>;\n  queueMap: Map<string, RunQueueItem>;\n  eventsLog: Array<{ runId: string; type: string }>;\n}\n\n// Access _internal property with type safety\nfunction getInternal(storage: StoragePort): MockStorageInternal {\n  return (storage as unknown as { _internal: MockStorageInternal })._internal;\n}\n\n// ==================== Tests ====================\n\ndescribe('V3 RPC Queue Management APIs', () => {\n  let storage: ReturnType<typeof createMockStorage>;\n  let events: EventsBus;\n  let scheduler: RunScheduler;\n  let server: RpcServer;\n  let runIdCounter: number;\n  let fixedNow: number;\n\n  beforeEach(() => {\n    storage = createMockStorage();\n    events = createMockEventsBus();\n    scheduler = createMockScheduler();\n    runIdCounter = 0;\n    fixedNow = 1_700_000_000_000;\n\n    server = new RpcServer({\n      storage,\n      events,\n      scheduler,\n      generateRunId: () => `run-${++runIdCounter}`,\n      now: () => fixedNow,\n    });\n  });\n\n  describe('rr_v3.enqueueRun', () => {\n    it('creates run record, enqueues, emits event, and kicks scheduler', async () => {\n      // Setup: add a flow\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Act: call enqueueRun via handleRequest\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.enqueueRun', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      // Assert: run record created\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n      const savedRun = (storage.runs.save as ReturnType<typeof vi.fn>).mock.calls[0][0];\n      expect(savedRun).toMatchObject({\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        attempt: 0,\n        maxAttempts: 1,\n      });\n\n      // Assert: enqueued\n      expect(storage.queue.enqueue).toHaveBeenCalledTimes(1);\n\n      // Assert: event emitted via EventsBus\n      expect(events.append).toHaveBeenCalledWith(\n        expect.objectContaining({\n          runId: 'run-1',\n          type: 'run.queued',\n          flowId: 'flow-1',\n        }),\n      );\n\n      // Assert: scheduler kicked\n      expect(scheduler.kick).toHaveBeenCalledTimes(1);\n\n      // Assert: result\n      expect(result).toMatchObject({\n        runId: 'run-1',\n        position: 1,\n      });\n    });\n\n    it('throws if flowId is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.enqueueRun', params: {}, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flowId is required');\n    });\n\n    it('throws if flow does not exist', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.enqueueRun', params: { flowId: 'non-existent' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Flow \"non-existent\" not found');\n    });\n\n    it('respects custom priority and maxAttempts', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.enqueueRun',\n          params: { flowId: 'flow-1', priority: 10, maxAttempts: 3 },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      );\n\n      expect(storage.queue.enqueue).toHaveBeenCalledWith(\n        expect.objectContaining({\n          priority: 10,\n          maxAttempts: 3,\n        }),\n      );\n    });\n\n    it('passes args and debug config', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      const args = { url: 'https://example.com' };\n      const debug = { pauseOnStart: true, breakpoints: ['node-1'] };\n\n      await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.enqueueRun',\n          params: { flowId: 'flow-1', args, debug },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      );\n\n      expect(storage.runs.save).toHaveBeenCalledWith(\n        expect.objectContaining({\n          args,\n          debug,\n        }),\n      );\n    });\n\n    it('rejects NaN priority', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.enqueueRun',\n            params: { flowId: 'flow-1', priority: NaN },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('priority must be a finite number');\n    });\n\n    it('rejects Infinity maxAttempts', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.enqueueRun',\n            params: { flowId: 'flow-1', maxAttempts: Infinity },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('maxAttempts must be a finite number');\n    });\n\n    it('rejects maxAttempts < 1', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.enqueueRun',\n            params: { flowId: 'flow-1', maxAttempts: 0 },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('maxAttempts must be >= 1');\n    });\n\n    it('persists startNodeId in RunRecord when provided', async () => {\n      // Setup: add a flow with multiple nodes\n      const flow = createTestFlow('flow-start-node');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Act: enqueue with startNodeId\n      const targetNodeId = flow.nodes[0].id; // Use the first node\n      await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.enqueueRun',\n          params: { flowId: 'flow-start-node', startNodeId: targetNodeId },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      );\n\n      // Assert: RunRecord should have startNodeId\n      const runsMap = getInternal(storage).runsMap;\n      expect(runsMap.size).toBe(1);\n      const runRecord = Array.from(runsMap.values())[0];\n      expect(runRecord.startNodeId).toBe(targetNodeId);\n    });\n\n    it('throws if startNodeId does not exist in flow', async () => {\n      // Setup: add a flow\n      const flow = createTestFlow('flow-invalid-start');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Act & Assert\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.enqueueRun',\n            params: { flowId: 'flow-invalid-start', startNodeId: 'non-existent-node' },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('startNodeId \"non-existent-node\" not found in flow');\n    });\n  });\n\n  describe('rr_v3.listQueue', () => {\n    it('returns all queue items sorted by priority DESC and createdAt ASC', async () => {\n      // Setup: add items with different priorities and times\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 5,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n      getInternal(storage).queueMap.set('run-2', {\n        id: 'run-2',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 10,\n        createdAt: 2000,\n        updatedAt: 2000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n      getInternal(storage).queueMap.set('run-3', {\n        id: 'run-3',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 10,\n        createdAt: 1500,\n        updatedAt: 1500,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.listQueue', params: {}, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as RunQueueItem[];\n\n      // run-3 (priority 10, earlier) > run-2 (priority 10, later) > run-1 (priority 5)\n      expect(result.map((r) => r.id)).toEqual(['run-3', 'run-2', 'run-1']);\n    });\n\n    it('filters by status', async () => {\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n      getInternal(storage).queueMap.set('run-2', {\n        id: 'run-2',\n        flowId: 'flow-1',\n        status: 'running',\n        priority: 0,\n        createdAt: 2000,\n        updatedAt: 2000,\n        attempt: 1,\n        maxAttempts: 1,\n      });\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.listQueue', params: { status: 'queued' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as RunQueueItem[];\n\n      expect(result).toHaveLength(1);\n      expect(result[0].id).toBe('run-1');\n    });\n\n    it('rejects invalid status', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.listQueue', params: { status: 'invalid' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('status must be one of: queued, running, paused');\n    });\n  });\n\n  describe('rr_v3.cancelQueueItem', () => {\n    it('cancels queue item, patches run, and emits event', async () => {\n      // Setup\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n      getInternal(storage).runsMap.set('run-1', {\n        schemaVersion: 3,\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n        nextSeq: 0,\n      });\n\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      // Assert: queue.cancel called\n      expect(storage.queue.cancel).toHaveBeenCalledWith('run-1', fixedNow, undefined);\n\n      // Assert: run patched\n      expect(storage.runs.patch).toHaveBeenCalledWith('run-1', {\n        status: 'canceled',\n        updatedAt: fixedNow,\n        finishedAt: fixedNow,\n      });\n\n      // Assert: event emitted via EventsBus\n      expect(events.append).toHaveBeenCalledWith(\n        expect.objectContaining({\n          runId: 'run-1',\n          type: 'run.canceled',\n        }),\n      );\n\n      // Assert: result\n      expect(result).toMatchObject({ ok: true, runId: 'run-1' });\n    });\n\n    it('throws if runId is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.cancelQueueItem', params: {}, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('runId is required');\n    });\n\n    it('throws if queue item does not exist', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.cancelQueueItem',\n            params: { runId: 'non-existent' },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Queue item \"non-existent\" not found');\n    });\n\n    it('throws if queue item is not queued', async () => {\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'running',\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 1,\n        maxAttempts: 1,\n      });\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Cannot cancel queue item \"run-1\" with status \"running\"');\n    });\n\n    it('includes reason in cancel event', async () => {\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n\n      await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.cancelQueueItem',\n          params: { runId: 'run-1', reason: 'User requested cancellation' },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      );\n\n      expect(storage.queue.cancel).toHaveBeenCalledWith(\n        'run-1',\n        fixedNow,\n        'User requested cancellation',\n      );\n      expect(events.append).toHaveBeenCalledWith(\n        expect.objectContaining({\n          reason: 'User requested cancellation',\n        }),\n      );\n    });\n  });\n});\n\ndescribe('V3 RPC Flow CRUD APIs', () => {\n  let storage: ReturnType<typeof createMockStorage>;\n  let events: EventsBus;\n  let scheduler: RunScheduler;\n  let server: RpcServer;\n  let fixedNow: number;\n\n  beforeEach(() => {\n    storage = createMockStorage();\n    events = createMockEventsBus();\n    scheduler = createMockScheduler();\n    fixedNow = 1_700_000_000_000;\n\n    server = new RpcServer({\n      storage,\n      events,\n      scheduler,\n      now: () => fixedNow,\n    });\n  });\n\n  describe('rr_v3.saveFlow', () => {\n    it('saves a new flow with all required fields', async () => {\n      const flowInput = {\n        name: 'My New Flow',\n        entryNodeId: 'node-1',\n        nodes: [\n          { id: 'node-1', kind: 'click', config: { selector: '#btn' } },\n          { id: 'node-2', kind: 'delay', config: { ms: 1000 } },\n        ],\n        edges: [{ id: 'e1', from: 'node-1', to: 'node-2' }],\n      };\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as FlowV3;\n\n      // Assert: flow saved\n      expect(storage.flows.save).toHaveBeenCalledTimes(1);\n\n      // Assert: returned flow has all fields\n      expect(result.schemaVersion).toBe(3);\n      expect(result.id).toMatch(/^flow_\\d+_[a-z0-9]+$/);\n      expect(result.name).toBe('My New Flow');\n      expect(result.entryNodeId).toBe('node-1');\n      expect(result.nodes).toHaveLength(2);\n      expect(result.edges).toHaveLength(1);\n      expect(result.createdAt).toBeDefined();\n      expect(result.updatedAt).toBeDefined();\n    });\n\n    it('updates an existing flow', async () => {\n      // Setup: add existing flow with a past timestamp\n      const existing = createTestFlow('flow-1');\n      const pastDate = new Date(Date.now() - 100000).toISOString(); // 100 seconds ago\n      existing.createdAt = pastDate;\n      existing.updatedAt = pastDate;\n      getInternal(storage).flowsMap.set(existing.id, existing);\n\n      const flowInput = {\n        id: 'flow-1',\n        name: 'Updated Flow',\n        entryNodeId: 'node-start',\n        nodes: [{ id: 'node-start', kind: 'navigate', config: { url: 'https://example.com' } }],\n        edges: [],\n        createdAt: existing.createdAt, // Preserve original createdAt\n      };\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as FlowV3;\n\n      // Assert: flow updated\n      expect(result.id).toBe('flow-1');\n      expect(result.name).toBe('Updated Flow');\n      expect(result.createdAt).toBe(existing.createdAt);\n      expect(result.updatedAt).not.toBe(existing.updatedAt);\n    });\n\n    it('preserves createdAt when updating without providing it', async () => {\n      // Setup: add existing flow with a past timestamp\n      const existing = createTestFlow('flow-1');\n      const pastDate = new Date(Date.now() - 100000).toISOString();\n      existing.createdAt = pastDate;\n      existing.updatedAt = pastDate;\n      getInternal(storage).flowsMap.set(existing.id, existing);\n\n      // Update without providing createdAt - should inherit from existing\n      const flowInput = {\n        id: 'flow-1',\n        name: 'Updated Without CreatedAt',\n        entryNodeId: 'node-start',\n        nodes: [{ id: 'node-start', kind: 'test', config: {} }],\n        edges: [],\n        // Note: createdAt is NOT provided\n      };\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as FlowV3;\n\n      // Assert: createdAt is inherited from existing flow\n      expect(result.createdAt).toBe(existing.createdAt);\n      expect(result.updatedAt).not.toBe(existing.updatedAt);\n    });\n\n    it('throws if flow is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.saveFlow', params: {}, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flow is required');\n    });\n\n    it('throws if name is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flow.name is required');\n    });\n\n    it('throws if entryNodeId is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flow.entryNodeId is required');\n    });\n\n    it('throws if entryNodeId does not exist in nodes', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'non-existent',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Entry node \"non-existent\" does not exist in flow');\n    });\n\n    it('throws if edge references non-existent source node', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n                edges: [{ id: 'e1', from: 'non-existent', to: 'node-1' }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Edge \"e1\" references non-existent source node \"non-existent\"');\n    });\n\n    it('throws if edge references non-existent target node', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n                edges: [{ id: 'e1', from: 'node-1', to: 'non-existent' }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Edge \"e1\" references non-existent target node \"non-existent\"');\n    });\n\n    it('validates node structure', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1' }], // missing kind\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flow.nodes[0].kind is required');\n    });\n\n    it('generates edge ID if not provided', async () => {\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.saveFlow',\n          params: {\n            flow: {\n              name: 'Test',\n              entryNodeId: 'node-1',\n              nodes: [\n                { id: 'node-1', kind: 'test', config: {} },\n                { id: 'node-2', kind: 'test', config: {} },\n              ],\n              edges: [{ from: 'node-1', to: 'node-2' }], // no id\n            },\n          },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      )) as FlowV3;\n\n      expect(result.edges[0].id).toMatch(/^edge_0_[a-z0-9]+$/);\n    });\n\n    it('saves flow with optional fields', async () => {\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        {\n          method: 'rr_v3.saveFlow',\n          params: {\n            flow: {\n              name: 'Test',\n              description: 'A test flow',\n              entryNodeId: 'node-1',\n              nodes: [\n                { id: 'node-1', kind: 'test', config: {}, name: 'Start Node', disabled: false },\n              ],\n              edges: [],\n              // 符合 VariableDefinition 类型：name 必填，description/default/label 可选\n              variables: [\n                { name: 'url', description: 'Target URL', default: 'https://example.com' },\n              ],\n              // 符合 FlowPolicy 类型\n              policy: { runTimeoutMs: 30000, defaultNodePolicy: { onError: { kind: 'stop' } } },\n              meta: { tags: ['test', 'demo'] },\n            },\n          },\n          requestId: 'req-1',\n        },\n        { subscriptions: new Set() },\n      )) as FlowV3;\n\n      expect(result.description).toBe('A test flow');\n      expect(result.variables).toHaveLength(1);\n      expect(result.policy).toEqual({\n        runTimeoutMs: 30000,\n        defaultNodePolicy: { onError: { kind: 'stop' } },\n      });\n      expect(result.meta).toEqual({ tags: ['test', 'demo'] });\n      expect(result.nodes[0].name).toBe('Start Node');\n    });\n\n    it('throws if variable is missing name', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n                variables: [{ description: 'Missing name field' }],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flow.variables[0].name is required');\n    });\n\n    it('throws if duplicate variable names', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [{ id: 'node-1', kind: 'test', config: {} }],\n                variables: [\n                  { name: 'myVar' },\n                  { name: 'myVar' }, // duplicate\n                ],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Duplicate variable name: \"myVar\"');\n    });\n\n    it('throws if duplicate node IDs', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [\n                  { id: 'node-1', kind: 'test', config: {} },\n                  { id: 'node-1', kind: 'test', config: {} }, // duplicate\n                ],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Duplicate node ID: \"node-1\"');\n    });\n\n    it('throws if duplicate edge IDs', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          {\n            method: 'rr_v3.saveFlow',\n            params: {\n              flow: {\n                name: 'Test',\n                entryNodeId: 'node-1',\n                nodes: [\n                  { id: 'node-1', kind: 'test', config: {} },\n                  { id: 'node-2', kind: 'test', config: {} },\n                ],\n                edges: [\n                  { id: 'e1', from: 'node-1', to: 'node-2' },\n                  { id: 'e1', from: 'node-2', to: 'node-1' }, // duplicate\n                ],\n              },\n            },\n            requestId: 'req-1',\n          },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Duplicate edge ID: \"e1\"');\n    });\n  });\n\n  describe('rr_v3.deleteFlow', () => {\n    it('deletes an existing flow', async () => {\n      // Setup: add flow\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      expect(storage.flows.delete).toHaveBeenCalledWith('flow-1');\n      expect(result).toEqual({ ok: true, flowId: 'flow-1' });\n    });\n\n    it('throws if flowId is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.deleteFlow', params: {}, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flowId is required');\n    });\n\n    it('throws if flow does not exist', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.deleteFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Flow \"non-existent\" not found');\n    });\n\n    it('throws if flow has linked triggers', async () => {\n      // Setup: add flow and trigger\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Mock triggers.list to return a trigger linked to this flow\n      (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([\n        { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },\n      ]);\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Cannot delete flow \"flow-1\": it has 1 linked trigger(s): trigger-1');\n    });\n\n    it('throws if flow has multiple linked triggers', async () => {\n      // Setup\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([\n        { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },\n        { id: 'trigger-2', kind: 'cron', flowId: 'flow-1', enabled: true, cron: '0 * * * *' },\n      ]);\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow(\n        'Cannot delete flow \"flow-1\": it has 2 linked trigger(s): trigger-1, trigger-2',\n      );\n    });\n\n    it('throws if flow has queued runs', async () => {\n      // Setup\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Add queued run\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 0,\n        maxAttempts: 1,\n      });\n\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('Cannot delete flow \"flow-1\": it has 1 queued run(s): run-1');\n    });\n\n    it('allows deletion when runs are running (not queued)', async () => {\n      // Setup\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      // Add running run (not queued) - should NOT block deletion\n      getInternal(storage).queueMap.set('run-1', {\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'running', // running, not queued\n        priority: 0,\n        createdAt: 1000,\n        updatedAt: 1000,\n        attempt: 1,\n        maxAttempts: 1,\n      });\n\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      expect(result).toEqual({ ok: true, flowId: 'flow-1' });\n    });\n  });\n\n  describe('rr_v3.getFlow', () => {\n    it('returns flow by id', async () => {\n      const flow = createTestFlow('flow-1');\n      getInternal(storage).flowsMap.set(flow.id, flow);\n\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.getFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      expect(result).toEqual(flow);\n    });\n\n    it('returns null for non-existent flow', async () => {\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.getFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      expect(result).toBeNull();\n    });\n\n    it('throws if flowId is missing', async () => {\n      await expect(\n        (server as unknown as { handleRequest: Function }).handleRequest(\n          { method: 'rr_v3.getFlow', params: {}, requestId: 'req-1' },\n          { subscriptions: new Set() },\n        ),\n      ).rejects.toThrow('flowId is required');\n    });\n  });\n\n  describe('rr_v3.listFlows', () => {\n    it('returns all flows', async () => {\n      const flow1 = createTestFlow('flow-1');\n      const flow2 = createTestFlow('flow-2');\n      getInternal(storage).flowsMap.set(flow1.id, flow1);\n      getInternal(storage).flowsMap.set(flow2.id, flow2);\n\n      const result = (await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      )) as FlowV3[];\n\n      expect(result).toHaveLength(2);\n      expect(result.map((f) => f.id).sort()).toEqual(['flow-1', 'flow-2']);\n    });\n\n    it('returns empty array when no flows exist', async () => {\n      const result = await (server as unknown as { handleRequest: Function }).handleRequest(\n        { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },\n        { subscriptions: new Set() },\n      );\n\n      expect(result).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/runner.onError.contract.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 RunRunner onError Contracts\n * @description Verifies RunRunner onError behavior via event stream + final Run status.\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { z } from 'zod';\n\nimport type {\n  EdgeV3,\n  FlowV3,\n  NodeV3,\n  RunEvent,\n  RunRecordV3,\n  RunRunner,\n  NodeDefinition,\n  NodeExecutionResult,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport {\n  EDGE_LABELS,\n  FLOW_SCHEMA_VERSION,\n  InMemoryEventsBus,\n  PluginRegistry,\n  RR_ERROR_CODES,\n  createNotImplementedStoragePort,\n  createRRError,\n  createRunRunnerFactory,\n  resetBreakpointRegistry,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport type {\n  RunId,\n  PersistentVarRecord,\n  PersistentVarsStore,\n  RunsStore,\n} from '@/entrypoints/background/record-replay-v3';\n\n// ==================== Test Helpers ====================\n\ntype TestNodeConfig = {\n  action: 'succeed' | 'fail' | 'flaky';\n  failTimes?: number;\n  errorCode?: string;\n};\n\n/**\n * Create a test node definition that can succeed, fail, or be flaky\n */\nfunction createTestNodeDefinition(\n  callsByNodeId: Map<string, number>,\n): NodeDefinition<'test', TestNodeConfig> {\n  return {\n    kind: 'test',\n    schema: z\n      .object({\n        action: z.enum(['succeed', 'fail', 'flaky']),\n        failTimes: z.number().int().min(0).optional(),\n        errorCode: z.string().optional(),\n      })\n      .passthrough(),\n    execute: async (ctx, node): Promise<NodeExecutionResult> => {\n      const prev = callsByNodeId.get(ctx.nodeId) ?? 0;\n      const cur = prev + 1;\n      callsByNodeId.set(ctx.nodeId, cur);\n\n      const cfg = node.config as unknown as TestNodeConfig;\n      const error = createRRError(\n        (cfg.errorCode ?? RR_ERROR_CODES.TOOL_ERROR) as typeof RR_ERROR_CODES.TOOL_ERROR,\n        `test failure (${ctx.nodeId})`,\n      );\n\n      if (cfg.action === 'succeed') return { status: 'succeeded' };\n      if (cfg.action === 'fail') return { status: 'failed', error };\n\n      // flaky: fail for the first `failTimes` calls\n      const failTimes = Math.max(0, cfg.failTimes ?? 0);\n      if (cur <= failTimes) return { status: 'failed', error };\n      return { status: 'succeeded' };\n    },\n  };\n}\n\n/**\n * Create a test flow\n */\nfunction createFlow(entryNodeId: string, nodes: NodeV3[], edges: EdgeV3[]): FlowV3 {\n  const iso = new Date(0).toISOString();\n  return {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id: 'flow-onerror',\n    name: 'onError contract flow',\n    createdAt: iso,\n    updatedAt: iso,\n    entryNodeId,\n    nodes,\n    edges,\n  };\n}\n\n/**\n * Create an in-memory RunsStore for testing\n */\nfunction createInMemoryRunsStore(): { store: RunsStore; byId: Map<RunId, RunRecordV3> } {\n  const byId = new Map<RunId, RunRecordV3>();\n  const store: RunsStore = {\n    list: async () => Array.from(byId.values()),\n    get: async (id) => byId.get(id) ?? null,\n    save: async (record) => {\n      byId.set(record.id, record);\n    },\n    patch: async (id, patch) => {\n      const existing = byId.get(id);\n      if (!existing) {\n        throw createRRError(RR_ERROR_CODES.INTERNAL, `Run \"${id}\" not found`);\n      }\n      byId.set(id, {\n        ...existing,\n        ...patch,\n        id: existing.id,\n        schemaVersion: existing.schemaVersion,\n        updatedAt: Date.now(),\n      });\n    },\n  };\n  return { store, byId };\n}\n\n/**\n * Create an in-memory PersistentVarsStore for testing\n */\nfunction createInMemoryPersistentVarsStore(): PersistentVarsStore {\n  const byKey = new Map<string, PersistentVarRecord>();\n  return {\n    get: async (key) => byKey.get(key as string) as PersistentVarRecord | undefined,\n    set: async (key, value) => {\n      const prev = byKey.get(key as string);\n      const record: PersistentVarRecord = {\n        key,\n        value,\n        updatedAt: Date.now(),\n        version: (prev?.version ?? 0) + 1,\n      };\n      byKey.set(key as string, record);\n      return record;\n    },\n    delete: async (key) => {\n      byKey.delete(key as string);\n    },\n    list: async (prefix) => {\n      const all = Array.from(byKey.values());\n      if (!prefix) return all;\n      return all.filter((r) => r.key.startsWith(prefix));\n    },\n  };\n}\n\n/**\n * Extract node IDs from node.started events\n */\nfunction startedNodeIds(events: RunEvent[]): string[] {\n  return events\n    .filter((e) => e.type === 'node.started')\n    .map((e) => (e as Extract<RunEvent, { type: 'node.started' }>).nodeId);\n}\n\n/**\n * Extract node.failed events for a specific node\n */\nfunction nodeFailedEvents(\n  events: RunEvent[],\n  nodeId: string,\n): Array<Extract<RunEvent, { type: 'node.failed' }>> {\n  return events.filter(\n    (e): e is Extract<RunEvent, { type: 'node.failed' }> =>\n      e.type === 'node.failed' && e.nodeId === nodeId,\n  );\n}\n\n/**\n * List events from InMemoryEventsBus\n */\nasync function listEvents(bus: InMemoryEventsBus, runId: RunId): Promise<RunEvent[]> {\n  return bus.list({ runId });\n}\n\n/**\n * Create a complete runner context for testing\n */\nfunction createRunnerContext(\n  runId: RunId,\n  flow: FlowV3,\n): {\n  runner: RunRunner;\n  bus: InMemoryEventsBus;\n  runsById: Map<RunId, RunRecordV3>;\n  calls: Map<string, number>;\n} {\n  const calls = new Map<string, number>();\n  const plugins = new PluginRegistry();\n  plugins.registerNode(createTestNodeDefinition(calls));\n\n  const bus = new InMemoryEventsBus();\n  const { store: runs, byId: runsById } = createInMemoryRunsStore();\n\n  const storage = createNotImplementedStoragePort();\n  storage.runs = runs;\n  storage.persistentVars = createInMemoryPersistentVarsStore();\n\n  const factory = createRunRunnerFactory({ storage, events: bus, plugins });\n  const runner = factory.create(runId, { flow, tabId: 1 });\n\n  return { runner, bus, runsById, calls };\n}\n\n// ==================== Tests ====================\n\ndescribe('V3 RunRunner onError contracts', () => {\n  beforeEach(() => {\n    resetBreakpointRegistry();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n    vi.restoreAllMocks();\n  });\n\n  it('stop: node failure ends run as failed', async () => {\n    const runId = 'run-stop';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'fail' },\n          policy: { onError: { kind: 'stop' } },\n        },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('failed');\n    expect(runsById.get(runId)?.status).toBe('failed');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('stop');\n    expect(startedNodeIds(events)).toEqual(['A']);\n  });\n\n  it('continue: node failure continues to next node', async () => {\n    const runId = 'run-continue';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'fail' },\n          policy: { onError: { kind: 'continue' } },\n        },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('succeeded');\n    expect(runsById.get(runId)?.status).toBe('succeeded');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('continue');\n    expect(startedNodeIds(events)).toEqual(['A', 'B']);\n  });\n\n  it('goto edgeLabel: node failure jumps to ON_ERROR edge target', async () => {\n    const runId = 'run-goto-edge';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'fail' },\n          policy: {\n            onError: { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } },\n          },\n        },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n        { id: 'C', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [\n        { id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT },\n        { id: 'e2', from: 'A', to: 'C', label: EDGE_LABELS.ON_ERROR },\n      ],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('succeeded');\n    expect(runsById.get(runId)?.status).toBe('succeeded');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');\n    expect(startedNodeIds(events)).toEqual(['A', 'C']);\n  });\n\n  it('goto nodeId: node failure jumps to specified node', async () => {\n    const runId = 'run-goto-node';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'fail' },\n          policy: { onError: { kind: 'goto', target: { kind: 'node', nodeId: 'C' } } },\n        },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n        { id: 'C', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('succeeded');\n    expect(runsById.get(runId)?.status).toBe('succeeded');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');\n    expect(startedNodeIds(events)).toEqual(['A', 'C']);\n  });\n\n  it('retry: retries the configured number of times and can succeed', async () => {\n    const runId = 'run-retry-succeed';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'flaky', failTimes: 2 },\n          policy: { onError: { kind: 'retry' }, retry: { retries: 2, intervalMs: 0 } },\n        },\n      ],\n      [],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('succeeded');\n    expect(runsById.get(runId)?.status).toBe('succeeded');\n\n    const events = await listEvents(bus, runId);\n    const started = events.filter((e) => e.type === 'node.started') as Array<\n      Extract<RunEvent, { type: 'node.started' }>\n    >;\n    expect(started.map((e) => e.attempt)).toEqual([1, 2, 3]);\n\n    const failed = nodeFailedEvents(events, 'A');\n    expect(failed.map((e) => e.decision)).toEqual(['retry', 'retry']);\n  });\n\n  it('retry: uses backoff and fails after retries are exhausted', async () => {\n    const runId = 'run-retry-fail';\n    const flow = createFlow(\n      'A',\n      [\n        {\n          id: 'A',\n          kind: 'test',\n          config: { action: 'fail' },\n          policy: {\n            onError: { kind: 'retry' },\n            retry: { retries: 2, intervalMs: 100, backoff: 'linear' },\n          },\n        },\n      ],\n      [],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n\n    vi.useFakeTimers();\n    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');\n\n    const startPromise = runner.start();\n    await vi.runAllTimersAsync();\n    const result = await startPromise;\n\n    expect(result.status).toBe('failed');\n    expect(runsById.get(runId)?.status).toBe('failed');\n\n    const delays = setTimeoutSpy.mock.calls\n      .map((call) => call[1])\n      .filter((ms): ms is number => typeof ms === 'number' && ms > 0);\n    // Linear backoff: 100, 200\n    expect(delays).toContain(100);\n    expect(delays).toContain(200);\n\n    const events = await listEvents(bus, runId);\n    const started = events.filter((e) => e.type === 'node.started') as Array<\n      Extract<RunEvent, { type: 'node.started' }>\n    >;\n    expect(started.map((e) => e.attempt)).toEqual([1, 2, 3]);\n\n    const failed = nodeFailedEvents(events, 'A');\n    expect(failed).toHaveLength(3);\n    // Last retry should still be 'retry' as that's the decision made before checking max attempts\n    expect(failed.map((e) => e.decision)).toEqual(['retry', 'retry', 'retry']);\n  });\n\n  it('default: without onError policy, uses ON_ERROR edge when present', async () => {\n    const runId = 'run-default-goto';\n    const flow = createFlow(\n      'A',\n      [\n        { id: 'A', kind: 'test', config: { action: 'fail' } },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n        { id: 'C', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [\n        { id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT },\n        { id: 'e2', from: 'A', to: 'C', label: EDGE_LABELS.ON_ERROR },\n      ],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('succeeded');\n    expect(runsById.get(runId)?.status).toBe('succeeded');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');\n    expect(startedNodeIds(events)).toEqual(['A', 'C']);\n  });\n\n  it('default: without onError policy and without ON_ERROR edge, stops', async () => {\n    const runId = 'run-default-stop';\n    const flow = createFlow(\n      'A',\n      [\n        { id: 'A', kind: 'test', config: { action: 'fail' } },\n        { id: 'B', kind: 'test', config: { action: 'succeed' } },\n      ],\n      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],\n    );\n\n    const { runner, bus, runsById } = createRunnerContext(runId, flow);\n    const result = await runner.start();\n    expect(result.status).toBe('failed');\n    expect(runsById.get(runId)?.status).toBe('failed');\n\n    const events = await listEvents(bus, runId);\n    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('stop');\n    expect(startedNodeIds(events)).toEqual(['A']);\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/scheduler-integration.test.ts",
    "content": "/**\n * @fileoverview 并行调度集成测试 (P3-07)\n * @description\n * End-to-end tests for Scheduler + Queue + LeaseManager + Recovery\n * Uses real IndexedDB storage (fake-indexeddb) to verify integration.\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport { DEFAULT_QUEUE_CONFIG } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport {\n  createLeaseManager,\n  generateOwnerId,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/leasing';\nimport {\n  createRunScheduler,\n  type RunExecutor,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\nimport { InMemoryKeepaliveController } from '@/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive';\nimport {\n  createQueueStore,\n  createRunsStore,\n  closeRrV3Db,\n  deleteRrV3Db,\n} from '@/entrypoints/background/record-replay-v3';\nimport { recoverFromCrash } from '@/entrypoints/background/record-replay-v3/engine/recovery/recovery-coordinator';\n\n// ==================== Test Utilities ====================\n\nfunction createMockEventsBus() {\n  const events: unknown[] = [];\n  return {\n    subscribe: vi.fn(() => () => {}),\n    append: vi.fn(async (event: unknown) => {\n      const fullEvent = { ...(event as object), ts: Date.now(), seq: events.length + 1 };\n      events.push(fullEvent);\n      return fullEvent;\n    }),\n    list: vi.fn(async () => []),\n    _events: events,\n  };\n}\n\nfunction createMockStorage(\n  queueStore: ReturnType<typeof createQueueStore>,\n  runsStore: ReturnType<typeof createRunsStore>,\n) {\n  return {\n    flows: {} as any,\n    runs: runsStore,\n    events: {} as any,\n    queue: queueStore,\n    persistentVars: {} as any,\n    triggers: {} as any,\n  };\n}\n\nfunction createRunRecord(id: string, status: string): RunRecordV3 {\n  return {\n    schemaVersion: 3,\n    id: id as RunId,\n    flowId: 'flow-1' as any,\n    status: status as any,\n    createdAt: Date.now(),\n    updatedAt: Date.now(),\n    attempt: 0,\n    maxAttempts: 3,\n    nextSeq: 0,\n  };\n}\n\n// ==================== Integration Tests ====================\n\ndescribe('V3 Scheduler Integration', () => {\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n  });\n\n  describe('End-to-end scheduling', () => {\n    it('scheduler claims from real queue, executes, and marks done', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      const executed: string[] = [];\n      const executor: RunExecutor = async (item) => {\n        executed.push(item.id);\n        // Simulate short execution\n        await new Promise((resolve) => setTimeout(resolve, 10));\n      };\n\n      // Enqueue items\n      await queue.enqueue({ id: 'run-1' as any, flowId: 'flow-1' as any, priority: 0 });\n      await queue.enqueue({ id: 'run-2' as any, flowId: 'flow-1' as any, priority: 0 });\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 1 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      // Wait for execution\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      scheduler.stop();\n\n      // Both runs should be executed\n      expect(executed).toContain('run-1');\n      expect(executed).toContain('run-2');\n\n      // Queue should be empty\n      const remaining = await queue.list();\n      expect(remaining).toHaveLength(0);\n    });\n\n    it('respects maxParallelRuns with real queue', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      let concurrentCount = 0;\n      let maxConcurrent = 0;\n      const executionTimes: Map<string, { start: number; end?: number }> = new Map();\n\n      const executor: RunExecutor = async (item) => {\n        concurrentCount++;\n        maxConcurrent = Math.max(maxConcurrent, concurrentCount);\n        executionTimes.set(item.id, { start: Date.now() });\n\n        await new Promise((resolve) => setTimeout(resolve, 50));\n\n        executionTimes.get(item.id)!.end = Date.now();\n        concurrentCount--;\n      };\n\n      // Enqueue 5 items\n      for (let i = 0; i < 5; i++) {\n        await queue.enqueue({ id: `run-${i}` as any, flowId: 'flow-1' as any, priority: 0 });\n      }\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 2 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 10, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      // Wait for all executions\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      scheduler.stop();\n\n      // Max concurrent should not exceed 2\n      expect(maxConcurrent).toBeLessThanOrEqual(2);\n\n      // All runs should complete\n      expect(executionTimes.size).toBe(5);\n    });\n\n    it('maintains FIFO within same priority', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      const executionOrder: string[] = [];\n      const executor: RunExecutor = async (item) => {\n        executionOrder.push(item.id);\n        await new Promise((resolve) => setTimeout(resolve, 10));\n      };\n\n      // Enqueue in order with same priority\n      await queue.enqueue({ id: 'run-1' as any, flowId: 'flow-1' as any, priority: 0 });\n      await new Promise((resolve) => setTimeout(resolve, 5)); // Ensure different createdAt\n      await queue.enqueue({ id: 'run-2' as any, flowId: 'flow-1' as any, priority: 0 });\n      await new Promise((resolve) => setTimeout(resolve, 5));\n      await queue.enqueue({ id: 'run-3' as any, flowId: 'flow-1' as any, priority: 0 });\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 1 }, // Serial execution\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      await new Promise((resolve) => setTimeout(resolve, 200));\n\n      scheduler.stop();\n\n      // Should execute in FIFO order\n      expect(executionOrder).toEqual(['run-1', 'run-2', 'run-3']);\n    });\n\n    it('higher priority runs first', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      const executionOrder: string[] = [];\n      const executor: RunExecutor = async (item) => {\n        executionOrder.push(item.id);\n        await new Promise((resolve) => setTimeout(resolve, 10));\n      };\n\n      // Enqueue with different priorities (low first)\n      await queue.enqueue({ id: 'run-low' as any, flowId: 'flow-1' as any, priority: 0 });\n      await queue.enqueue({ id: 'run-high' as any, flowId: 'flow-1' as any, priority: 10 });\n      await queue.enqueue({ id: 'run-medium' as any, flowId: 'flow-1' as any, priority: 5 });\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 1 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      await new Promise((resolve) => setTimeout(resolve, 200));\n\n      scheduler.stop();\n\n      // Should execute in priority order (high -> medium -> low)\n      expect(executionOrder).toEqual(['run-high', 'run-medium', 'run-low']);\n    });\n  });\n\n  describe('Lease management', () => {\n    it('heartbeat keeps leases alive during long runs', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const config = {\n        ...DEFAULT_QUEUE_CONFIG,\n        leaseTtlMs: 100, // Short TTL for testing\n        heartbeatIntervalMs: 30, // Frequent heartbeat\n      };\n      const leaseManager = createLeaseManager(queue, config);\n      const ownerId = generateOwnerId();\n\n      let runningItem: RunQueueItem | null = null;\n      const executor: RunExecutor = async (item) => {\n        runningItem = item;\n        // Run longer than TTL\n        await new Promise((resolve) => setTimeout(resolve, 200));\n      };\n\n      await queue.enqueue({ id: 'long-run' as any, flowId: 'flow-1' as any });\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...config, maxParallelRuns: 1 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      // Wait for run to be claimed\n      await new Promise((resolve) => setTimeout(resolve, 50));\n      expect(runningItem).not.toBeNull();\n\n      // Check that lease is being renewed\n      const itemMidRun = await queue.get('long-run' as any);\n      expect(itemMidRun?.status).toBe('running');\n      expect(itemMidRun?.lease?.ownerId).toBe(ownerId);\n\n      // Wait for completion\n      await new Promise((resolve) => setTimeout(resolve, 200));\n\n      scheduler.stop();\n\n      // Run should complete successfully\n      const remaining = await queue.list();\n      expect(remaining).toHaveLength(0);\n    });\n\n    it('expired leases are reclaimed by periodic scan', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n\n      // Note: markRunning uses DEFAULT_LEASE_TTL_MS (15s) internally.\n      // To simulate an expired lease, we pass a past time that makes\n      // expiresAt (pastTime + 15s) be in the past relative to now.\n      const pastTime = Date.now() - DEFAULT_QUEUE_CONFIG.leaseTtlMs - 100; // expired 100ms ago\n      await queue.enqueue({ id: 'orphan-run' as any, flowId: 'flow-1' as any });\n      await queue.markRunning('orphan-run' as any, 'dead-owner', pastTime);\n\n      // Verify lease exists and is expired\n      const expiredItem = await queue.get('orphan-run' as any);\n      expect(expiredItem?.status).toBe('running');\n      expect(expiredItem?.lease?.expiresAt).toBe(pastTime + DEFAULT_QUEUE_CONFIG.leaseTtlMs);\n      expect(expiredItem?.lease?.expiresAt).toBeLessThan(Date.now());\n\n      // Manually trigger reclaim to simulate what scheduler does periodically\n      const reclaimedIds = await leaseManager.reclaimExpiredLeases(Date.now());\n      expect(reclaimedIds).toContain('orphan-run');\n\n      // Now queue item should be back to queued\n      const reclaimedItem = await queue.get('orphan-run' as any);\n      expect(reclaimedItem?.status).toBe('queued');\n\n      // New scheduler should pick it up\n      const ownerId = generateOwnerId();\n      const executed: string[] = [];\n      const executor: RunExecutor = async (item) => {\n        executed.push(item.id);\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 1 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      await new Promise((resolve) => setTimeout(resolve, 100));\n\n      scheduler.stop();\n\n      // Orphan run should be executed\n      expect(executed).toContain('orphan-run');\n    });\n  });\n\n  describe('Crash recovery simulation', () => {\n    it('recovers orphan running items after restart', async () => {\n      const queue = createQueueStore();\n      const runsStore = createRunsStore();\n      const events = createMockEventsBus();\n\n      // Simulate crash scenario: run was running when SW died\n      await queue.enqueue({ id: 'crashed-run' as any, flowId: 'flow-1' as any });\n      await queue.markRunning('crashed-run' as any, 'old-sw-owner', Date.now());\n      await runsStore.save(createRunRecord('crashed-run', 'running'));\n\n      // Simulate restart with new owner\n      const newOwnerId = generateOwnerId();\n      const storage = createMockStorage(queue, runsStore);\n\n      const result = await recoverFromCrash({\n        storage,\n        events: events as any,\n        ownerId: newOwnerId,\n        now: () => Date.now(),\n      });\n\n      // Run should be requeued\n      expect(result.requeuedRunning).toContain('crashed-run');\n\n      // Queue item should be back to queued\n      const item = await queue.get('crashed-run' as any);\n      expect(item?.status).toBe('queued');\n      expect(item?.lease).toBeUndefined();\n\n      // RunRecord should be updated\n      const run = await runsStore.get('crashed-run' as any);\n      expect(run?.status).toBe('queued');\n\n      // Event should be emitted\n      expect(events._events.some((e: any) => e.type === 'run.recovered')).toBe(true);\n    });\n\n    it('adopts orphan paused items after restart', async () => {\n      const queue = createQueueStore();\n      const runsStore = createRunsStore();\n      const events = createMockEventsBus();\n\n      // Simulate crash scenario: run was paused when SW died\n      await queue.enqueue({ id: 'paused-run' as any, flowId: 'flow-1' as any });\n      await queue.markPaused('paused-run' as any, 'old-sw-owner', Date.now());\n      await runsStore.save(createRunRecord('paused-run', 'paused'));\n\n      // Simulate restart with new owner\n      const newOwnerId = generateOwnerId();\n      const storage = createMockStorage(queue, runsStore);\n\n      const result = await recoverFromCrash({\n        storage,\n        events: events as any,\n        ownerId: newOwnerId,\n        now: () => Date.now(),\n      });\n\n      // Run should be adopted (stays paused)\n      expect(result.adoptedPaused).toContain('paused-run');\n\n      // Queue item should still be paused with new owner\n      const item = await queue.get('paused-run' as any);\n      expect(item?.status).toBe('paused');\n      expect(item?.lease?.ownerId).toBe(newOwnerId);\n    });\n\n    it('preserves attempt count across recovery', async () => {\n      const queue = createQueueStore();\n      const runsStore = createRunsStore();\n      const events = createMockEventsBus();\n\n      // Simulate a run that has already been attempted\n      await queue.enqueue({ id: 'retried-run' as any, flowId: 'flow-1' as any });\n      await queue.claimNext('old-owner', Date.now()); // attempt becomes 1\n      await runsStore.save({ ...createRunRecord('retried-run', 'running'), attempt: 1 });\n\n      // Simulate restart\n      const newOwnerId = generateOwnerId();\n      const storage = createMockStorage(queue, runsStore);\n\n      await recoverFromCrash({\n        storage,\n        events: events as any,\n        ownerId: newOwnerId,\n        now: () => Date.now(),\n      });\n\n      // Queue item should preserve attempt count\n      const item = await queue.get('retried-run' as any);\n      expect(item?.status).toBe('queued');\n      expect(item?.attempt).toBe(1); // Not reset\n\n      // Next claim will increment\n      const claimed = await queue.claimNext(newOwnerId, Date.now());\n      expect(claimed?.attempt).toBe(2);\n    });\n\n    it('cleans terminal runs left in queue due to crash', async () => {\n      const queue = createQueueStore();\n      const runsStore = createRunsStore();\n      const events = createMockEventsBus();\n\n      // Simulate crash scenario: run completed but queue item wasn't removed\n      await queue.enqueue({ id: 'completed-run' as any, flowId: 'flow-1' as any });\n      await queue.markRunning('completed-run' as any, 'old-owner', Date.now());\n      await runsStore.save(createRunRecord('completed-run', 'succeeded'));\n\n      // Simulate restart\n      const newOwnerId = generateOwnerId();\n      const storage = createMockStorage(queue, runsStore);\n\n      const result = await recoverFromCrash({\n        storage,\n        events: events as any,\n        ownerId: newOwnerId,\n        now: () => Date.now(),\n      });\n\n      // Run should be cleaned\n      expect(result.cleanedTerminal).toContain('completed-run');\n\n      // Queue should be empty\n      const remaining = await queue.list();\n      expect(remaining).toHaveLength(0);\n    });\n\n    it('recovery then scheduler works correctly', async () => {\n      const queue = createQueueStore();\n      const runsStore = createRunsStore();\n      const events = createMockEventsBus();\n      const keepalive = new InMemoryKeepaliveController();\n\n      // Simulate crash scenario\n      await queue.enqueue({ id: 'recover-run' as any, flowId: 'flow-1' as any });\n      await queue.markRunning('recover-run' as any, 'old-owner', Date.now());\n      await runsStore.save(createRunRecord('recover-run', 'running'));\n\n      // Recovery\n      const newOwnerId = generateOwnerId();\n      const storage = createMockStorage(queue, runsStore);\n\n      await recoverFromCrash({\n        storage,\n        events: events as any,\n        ownerId: newOwnerId,\n        now: () => Date.now(),\n      });\n\n      // Now start scheduler\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const executed: string[] = [];\n      const executor: RunExecutor = async (item) => {\n        executed.push(item.id);\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 1 },\n        ownerId: newOwnerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      scheduler.stop();\n\n      // Recovered run should be executed\n      expect(executed).toContain('recover-run');\n    });\n  });\n\n  describe('Concurrency', () => {\n    it('handles multiple concurrent enqueue/claim cycles', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      const executed = new Set<string>();\n      const executor: RunExecutor = async (item) => {\n        executed.add(item.id);\n        await new Promise((resolve) => setTimeout(resolve, 20));\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 3 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 10, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      // Concurrent enqueues while scheduler is running\n      const enqueuePromises = [];\n      for (let i = 0; i < 10; i++) {\n        enqueuePromises.push(\n          queue\n            .enqueue({\n              id: `run-${i}` as any,\n              flowId: 'flow-1' as any,\n              priority: Math.random() * 10,\n            })\n            .then(() => scheduler.kick()),\n        );\n      }\n\n      await Promise.all(enqueuePromises);\n\n      // Wait for all to complete\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      scheduler.stop();\n\n      // All runs should be executed exactly once\n      expect(executed.size).toBe(10);\n    });\n\n    it('no double execution under concurrent kicks', async () => {\n      const queue = createQueueStore();\n      const keepalive = new InMemoryKeepaliveController();\n      const leaseManager = createLeaseManager(queue, DEFAULT_QUEUE_CONFIG);\n      const ownerId = generateOwnerId();\n\n      const executionCounts = new Map<string, number>();\n      const executor: RunExecutor = async (item) => {\n        executionCounts.set(item.id, (executionCounts.get(item.id) ?? 0) + 1);\n        await new Promise((resolve) => setTimeout(resolve, 50));\n      };\n\n      // Pre-enqueue\n      for (let i = 0; i < 5; i++) {\n        await queue.enqueue({ id: `run-${i}` as any, flowId: 'flow-1' as any });\n      }\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive,\n        config: { ...DEFAULT_QUEUE_CONFIG, maxParallelRuns: 2 },\n        ownerId,\n        execute: executor,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n      });\n\n      scheduler.start();\n\n      // Hammer with concurrent kicks\n      const kickPromises = [];\n      for (let i = 0; i < 20; i++) {\n        kickPromises.push(scheduler.kick());\n      }\n      await Promise.all(kickPromises);\n\n      // Wait for completion\n      await new Promise((resolve) => setTimeout(resolve, 500));\n\n      scheduler.stop();\n\n      // Each run should execute exactly once\n      for (const [runId, count] of executionCounts) {\n        expect(count, `${runId} executed ${count} times`).toBe(1);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/scheduler.test.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 Scheduler Unit Tests\n * @description\n * Verifies maxParallelRuns enforcement and basic orchestration behavior:\n * - Never exceeds configured parallelism\n * - Automatically backfills when a run completes\n * - Reclaim interval is respected\n */\n\nimport { describe, expect, it } from 'vitest';\n\nimport type {\n  RunQueueConfig,\n  RunQueueItem,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport type { LeaseManager } from '@/entrypoints/background/record-replay-v3/engine/queue/leasing';\nimport {\n  createRunScheduler,\n  type RunExecutor,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\n\n// ==================== Test Utilities ====================\n\ninterface Deferred<T> {\n  promise: Promise<T>;\n  resolve: (value: T) => void;\n  reject: (reason?: unknown) => void;\n}\n\nfunction createDeferred<T>(): Deferred<T> {\n  let resolve!: (value: T) => void;\n  let reject!: (reason?: unknown) => void;\n  const promise = new Promise<T>((res, rej) => {\n    resolve = res;\n    reject = rej;\n  });\n  return { promise, resolve, reject };\n}\n\nfunction makeClaimedItem(id: string): RunQueueItem {\n  return {\n    id,\n    flowId: 'flow-1',\n    status: 'running',\n    createdAt: 1,\n    updatedAt: 1,\n    priority: 0,\n    attempt: 1,\n    maxAttempts: 1,\n  };\n}\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\n// Keepalive mocks\ntype KeepaliveLike = { acquire(tag: string): () => void };\n\nconst noopKeepalive: KeepaliveLike = {\n  acquire: () => () => {},\n};\n\nfunction createKeepaliveProbe(): {\n  keepalive: KeepaliveLike;\n  acquiredTags: string[];\n  releasedCount: () => number;\n} {\n  const acquiredTags: string[] = [];\n  let released = 0;\n\n  const keepalive: KeepaliveLike = {\n    acquire: (tag: string) => {\n      acquiredTags.push(tag);\n      let done = false;\n      return () => {\n        if (done) return;\n        done = true;\n        released += 1;\n      };\n    },\n  };\n\n  return { keepalive, acquiredTags, releasedCount: () => released };\n}\n\n// ==================== Tests ====================\n\ndescribe('V3 RunScheduler', () => {\n  describe('maxParallelRuns enforcement', () => {\n    it('enforces maxParallelRuns and backfills when a run finishes', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 2,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const ownerId = 'owner-1';\n      const fixedNow = 1_700_000_000_000;\n\n      const items: RunQueueItem[] = [\n        makeClaimedItem('run-1'),\n        makeClaimedItem('run-2'),\n        makeClaimedItem('run-3'),\n      ];\n\n      let claimCalls = 0;\n      const thirdClaimHappened = createDeferred<void>();\n      const doneIds: string[] = [];\n\n      const queue = {\n        claimNext: async () => {\n          claimCalls += 1;\n          if (claimCalls === 3) thirdClaimHappened.resolve(undefined);\n          return items.shift() ?? null;\n        },\n        markDone: async (runId: string) => {\n          doneIds.push(runId);\n        },\n      };\n\n      const started: string[] = [];\n      const runDeferreds = new Map<string, Deferred<void>>();\n      const run3Started = createDeferred<void>();\n\n      const execute: RunExecutor = async (item) => {\n        started.push(item.id);\n        const d = createDeferred<void>();\n        runDeferreds.set(item.id, d);\n        if (item.id === 'run-3') run3Started.resolve(undefined);\n        return d.promise;\n      };\n\n      let heartbeatStarted = 0;\n      let heartbeatStopped = 0;\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {\n          heartbeatStarted += 1;\n        },\n        stopHeartbeat: () => {\n          heartbeatStopped += 1;\n        },\n        reclaimExpiredLeases: async () => [],\n      };\n\n      const keepaliveProbe = createKeepaliveProbe();\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: keepaliveProbe.keepalive,\n        config,\n        ownerId,\n        execute,\n        now: () => fixedNow,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n\n      // Verify keepalive was acquired on start\n      expect(keepaliveProbe.acquiredTags).toEqual(['scheduler']);\n\n      await scheduler.kick();\n\n      expect(heartbeatStarted).toBe(1);\n      expect(started).toEqual(['run-1', 'run-2']);\n      expect(claimCalls).toBe(2);\n      expect(scheduler.getState().activeRunIds.sort()).toEqual(['run-1', 'run-2']);\n\n      // Complete one run and expect an automatic backfill (run-3)\n      runDeferreds.get('run-1')!.resolve(undefined);\n\n      await thirdClaimHappened.promise;\n      await run3Started.promise;\n\n      expect(claimCalls).toBe(3);\n      expect(started).toEqual(['run-1', 'run-2', 'run-3']);\n      expect(doneIds).toContain('run-1');\n      expect(scheduler.getState().activeRunIds.sort()).toEqual(['run-2', 'run-3']);\n\n      // Drain remaining runs for a clean shutdown\n      runDeferreds.get('run-2')!.resolve(undefined);\n      runDeferreds.get('run-3')!.resolve(undefined);\n      await scheduler.kick();\n\n      scheduler.stop();\n      expect(heartbeatStopped).toBe(1);\n\n      // Verify keepalive was released on stop\n      expect(keepaliveProbe.releasedCount()).toBe(1);\n    });\n\n    it('does not claim when maxParallelRuns is 0', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 0,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      let claimCalls = 0;\n      const queue = {\n        claimNext: async () => {\n          claimCalls += 1;\n          return null;\n        },\n        markDone: async () => {},\n      };\n\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {},\n        stopHeartbeat: () => {},\n        reclaimExpiredLeases: async () => [],\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'owner-1',\n        execute: async () => {},\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      await scheduler.kick();\n\n      expect(claimCalls).toBe(0);\n      scheduler.stop();\n    });\n\n    it('stops claiming when queue is empty', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 5,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const items: RunQueueItem[] = [makeClaimedItem('run-1'), makeClaimedItem('run-2')];\n\n      let claimCalls = 0;\n      const queue = {\n        claimNext: async () => {\n          claimCalls += 1;\n          return items.shift() ?? null;\n        },\n        markDone: async () => {},\n      };\n\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {},\n        stopHeartbeat: () => {},\n        reclaimExpiredLeases: async () => [],\n      };\n\n      const runDeferreds = new Map<string, Deferred<void>>();\n      const execute: RunExecutor = async (item) => {\n        const d = createDeferred<void>();\n        runDeferreds.set(item.id, d);\n        return d.promise;\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'owner-1',\n        execute,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      await scheduler.kick();\n\n      // Should have claimed all available items (2) then stopped when queue returned null\n      // Note: claimNext is called until it returns null to fill all slots up to maxParallelRuns\n      expect(claimCalls).toBeGreaterThanOrEqual(3); // At least: 2 successful + 1 null\n      expect(scheduler.getState().activeRunIds.sort()).toEqual(['run-1', 'run-2']);\n\n      runDeferreds.get('run-1')!.resolve(undefined);\n      runDeferreds.get('run-2')!.resolve(undefined);\n      scheduler.stop();\n    });\n  });\n\n  describe('lease reclamation', () => {\n    it('reclaims expired leases at the configured interval', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 0,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      let t = 1000;\n\n      const reclaimCalls: number[] = [];\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {},\n        stopHeartbeat: () => {},\n        reclaimExpiredLeases: async (now) => {\n          reclaimCalls.push(now);\n          return [];\n        },\n      };\n\n      const queue = {\n        claimNext: async () => null,\n        markDone: async () => {},\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'owner-1',\n        execute: async () => {},\n        now: () => t,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 100 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      await scheduler.kick();\n      expect(reclaimCalls).toEqual([1000]);\n\n      // Not enough time has passed\n      t = 1099;\n      await scheduler.kick();\n      expect(reclaimCalls).toEqual([1000]);\n\n      // Now enough time has passed\n      t = 1100;\n      await scheduler.kick();\n      expect(reclaimCalls).toEqual([1000, 1100]);\n\n      scheduler.stop();\n    });\n\n    it('does not reclaim when reclaimIntervalMs is 0', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 0,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const reclaimCalls: number[] = [];\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {},\n        stopHeartbeat: () => {},\n        reclaimExpiredLeases: async (now) => {\n          reclaimCalls.push(now);\n          return [];\n        },\n      };\n\n      const queue = {\n        claimNext: async () => null,\n        markDone: async () => {},\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'owner-1',\n        execute: async () => {},\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      await scheduler.kick();\n      await scheduler.kick();\n      await scheduler.kick();\n\n      expect(reclaimCalls).toEqual([]);\n      scheduler.stop();\n    });\n  });\n\n  describe('error handling', () => {\n    it('throws if ownerId is empty', () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 1,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      expect(() =>\n        createRunScheduler({\n          queue: { claimNext: async () => null, markDone: async () => {} },\n          leaseManager: {\n            startHeartbeat: () => {},\n            stopHeartbeat: () => {},\n            reclaimExpiredLeases: async () => [],\n          },\n          keepalive: noopKeepalive,\n          config,\n          ownerId: '',\n          execute: async () => {},\n        }),\n      ).toThrow('ownerId is required');\n    });\n\n    it('continues scheduling when executor throws', async () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 1,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const items: RunQueueItem[] = [makeClaimedItem('run-1'), makeClaimedItem('run-2')];\n\n      let claimCalls = 0;\n      const doneIds: string[] = [];\n      const queue = {\n        claimNext: async () => {\n          claimCalls += 1;\n          return items.shift() ?? null;\n        },\n        markDone: async (runId: string) => {\n          doneIds.push(runId);\n        },\n      };\n\n      const leaseManager: Pick<\n        LeaseManager,\n        'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'\n      > = {\n        startHeartbeat: () => {},\n        stopHeartbeat: () => {},\n        reclaimExpiredLeases: async () => [],\n      };\n\n      let executeCount = 0;\n      const run2Started = createDeferred<void>();\n      const execute: RunExecutor = async (item) => {\n        executeCount += 1;\n        if (item.id === 'run-1') {\n          throw new Error('Simulated failure');\n        }\n        run2Started.resolve(undefined);\n      };\n\n      const scheduler = createRunScheduler({\n        queue,\n        leaseManager,\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'owner-1',\n        execute,\n        tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      await scheduler.kick();\n\n      // Wait for run-2 to start (backfill after run-1 failure)\n      await run2Started.promise;\n\n      expect(executeCount).toBe(2);\n      expect(doneIds).toContain('run-1');\n\n      scheduler.stop();\n    });\n  });\n\n  describe('state inspection', () => {\n    it('getState returns correct information', () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 3,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const scheduler = createRunScheduler({\n        queue: { claimNext: async () => null, markDone: async () => {} },\n        leaseManager: {\n          startHeartbeat: () => {},\n          stopHeartbeat: () => {},\n          reclaimExpiredLeases: async () => [],\n        },\n        keepalive: noopKeepalive,\n        config,\n        ownerId: 'test-owner',\n        execute: async () => {},\n        logger: createSilentLogger(),\n      });\n\n      const state = scheduler.getState();\n      expect(state.started).toBe(false);\n      expect(state.ownerId).toBe('test-owner');\n      expect(state.maxParallelRuns).toBe(3);\n      expect(state.activeRunIds).toEqual([]);\n\n      scheduler.start();\n      expect(scheduler.getState().started).toBe(true);\n\n      scheduler.stop();\n      expect(scheduler.getState().started).toBe(false);\n    });\n\n    it('dispose stops the scheduler and clears state', () => {\n      const config: RunQueueConfig = {\n        maxParallelRuns: 1,\n        leaseTtlMs: 15_000,\n        heartbeatIntervalMs: 5_000,\n      };\n\n      const keepaliveProbe = createKeepaliveProbe();\n      let heartbeatStopped = 0;\n      const scheduler = createRunScheduler({\n        queue: { claimNext: async () => null, markDone: async () => {} },\n        leaseManager: {\n          startHeartbeat: () => {},\n          stopHeartbeat: () => {\n            heartbeatStopped += 1;\n          },\n          reclaimExpiredLeases: async () => [],\n        },\n        keepalive: keepaliveProbe.keepalive,\n        config,\n        ownerId: 'test-owner',\n        execute: async () => {},\n        logger: createSilentLogger(),\n      });\n\n      scheduler.start();\n      scheduler.dispose();\n\n      expect(scheduler.getState().started).toBe(false);\n      expect(heartbeatStopped).toBe(1);\n      expect(keepaliveProbe.releasedCount()).toBe(1);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/spec-smoke.test.ts",
    "content": "/**\n * @fileoverview V3 Spec Smoke Test\n * @description 验证 V3 类型定义和常量可正常导入使用\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\n\n// ==================== Domain Types ====================\nimport {\n  // JSON types\n  type JsonValue,\n  type JsonObject,\n  type UnixMillis,\n\n  // ID types\n  type FlowId,\n  type NodeId,\n  type RunId,\n  EDGE_LABELS,\n\n  // Error types\n  RR_ERROR_CODES,\n  type RRError,\n  createRRError,\n\n  // Policy types\n  type TimeoutPolicy,\n  type RetryPolicy,\n  type OnErrorPolicy,\n  type NodePolicy,\n  mergeNodePolicy,\n\n  // Variable types\n  type PersistentVariableName,\n  isPersistentVariable,\n  parseVariablePointer,\n\n  // Flow types\n  FLOW_SCHEMA_VERSION,\n  type FlowV3,\n  type NodeV3,\n  type EdgeV3,\n  findNodeById,\n\n  // Event types\n  type RunEvent,\n  type RunStatus,\n  type Unsubscribe,\n  RUN_SCHEMA_VERSION,\n  type RunRecordV3,\n  isTerminalStatus,\n  isActiveStatus,\n\n  // Debug types\n  type DebuggerState,\n  type DebuggerCommand,\n  createInitialDebuggerState,\n\n  // Trigger types\n  type TriggerKind,\n  type TriggerSpec,\n  isTriggerEnabled,\n} from '@/entrypoints/background/record-replay-v3';\n\ndescribe('V3 Domain Types', () => {\n  describe('Constants', () => {\n    it('should export EDGE_LABELS', () => {\n      expect(EDGE_LABELS).toBeDefined();\n      expect(EDGE_LABELS.DEFAULT).toBe('default');\n      expect(EDGE_LABELS.ON_ERROR).toBe('onError');\n      expect(EDGE_LABELS.TRUE).toBe('true');\n      expect(EDGE_LABELS.FALSE).toBe('false');\n    });\n\n    it('should export RR_ERROR_CODES', () => {\n      expect(RR_ERROR_CODES).toBeDefined();\n      expect(RR_ERROR_CODES.TIMEOUT).toBe('TIMEOUT');\n      expect(RR_ERROR_CODES.VALIDATION_ERROR).toBe('VALIDATION_ERROR');\n      expect(RR_ERROR_CODES.DAG_CYCLE).toBe('DAG_CYCLE');\n    });\n\n    it('should export schema versions', () => {\n      expect(FLOW_SCHEMA_VERSION).toBe(3);\n      expect(RUN_SCHEMA_VERSION).toBe(3);\n    });\n  });\n\n  describe('Error utilities', () => {\n    it('should create RRError', () => {\n      const error = createRRError(RR_ERROR_CODES.TIMEOUT, 'Operation timed out', {\n        retryable: true,\n        data: { timeout: 5000 },\n      });\n\n      expect(error.code).toBe('TIMEOUT');\n      expect(error.message).toBe('Operation timed out');\n      expect(error.retryable).toBe(true);\n      expect(error.data).toEqual({ timeout: 5000 });\n    });\n\n    it('should support error chaining', () => {\n      const cause = createRRError(RR_ERROR_CODES.NETWORK_REQUEST_FAILED, 'Network error');\n      const error = createRRError(RR_ERROR_CODES.TOOL_ERROR, 'Tool failed', { cause });\n\n      expect(error.cause).toBeDefined();\n      expect(error.cause?.code).toBe('NETWORK_REQUEST_FAILED');\n    });\n  });\n\n  describe('Policy utilities', () => {\n    it('should merge node policies', () => {\n      const flowDefault: NodePolicy = {\n        timeout: { ms: 30000 },\n        retry: { retries: 3, intervalMs: 1000 },\n      };\n\n      const nodePolicy: NodePolicy = {\n        timeout: { ms: 60000 },\n      };\n\n      const merged = mergeNodePolicy(flowDefault, nodePolicy);\n\n      expect(merged.timeout?.ms).toBe(60000); // Node overrides\n      expect(merged.retry?.retries).toBe(3); // Flow default\n    });\n\n    it('should handle undefined policies', () => {\n      expect(mergeNodePolicy(undefined, undefined)).toEqual({});\n      expect(mergeNodePolicy({ timeout: { ms: 5000 } }, undefined)).toEqual({\n        timeout: { ms: 5000 },\n      });\n    });\n  });\n\n  describe('Variable utilities', () => {\n    it('should detect persistent variables', () => {\n      expect(isPersistentVariable('$user')).toBe(true);\n      expect(isPersistentVariable('$config.theme')).toBe(true);\n      expect(isPersistentVariable('normalVar')).toBe(false);\n    });\n\n    it('should parse variable pointers', () => {\n      const ptr1 = parseVariablePointer('$user.name');\n      expect(ptr1?.scope).toBe('persistent');\n      expect(ptr1?.name).toBe('$user');\n      expect(ptr1?.path).toEqual(['name']);\n\n      const ptr2 = parseVariablePointer('localVar');\n      expect(ptr2?.scope).toBe('run');\n      expect(ptr2?.name).toBe('localVar');\n    });\n  });\n\n  describe('Flow utilities', () => {\n    const mockFlow: FlowV3 = {\n      schemaVersion: FLOW_SCHEMA_VERSION,\n      id: 'flow-1',\n      name: 'Test Flow',\n      createdAt: new Date().toISOString(),\n      updatedAt: new Date().toISOString(),\n      entryNodeId: 'node-1',\n      nodes: [\n        { id: 'node-1', kind: 'click', config: {} },\n        { id: 'node-2', kind: 'fill', config: {} },\n      ],\n      edges: [{ id: 'edge-1', from: 'node-1', to: 'node-2' }],\n    };\n\n    it('should find node by id', () => {\n      const node = findNodeById(mockFlow, 'node-1');\n      expect(node).toBeDefined();\n      expect(node?.kind).toBe('click');\n\n      expect(findNodeById(mockFlow, 'non-existent')).toBeUndefined();\n    });\n  });\n\n  describe('Event utilities', () => {\n    it('should check terminal status', () => {\n      expect(isTerminalStatus('succeeded')).toBe(true);\n      expect(isTerminalStatus('failed')).toBe(true);\n      expect(isTerminalStatus('canceled')).toBe(true);\n      expect(isTerminalStatus('running')).toBe(false);\n      expect(isTerminalStatus('queued')).toBe(false);\n    });\n\n    it('should check active status', () => {\n      expect(isActiveStatus('running')).toBe(true);\n      expect(isActiveStatus('paused')).toBe(true);\n      expect(isActiveStatus('succeeded')).toBe(false);\n      expect(isActiveStatus('queued')).toBe(false);\n    });\n  });\n\n  describe('Debug utilities', () => {\n    it('should create initial debugger state', () => {\n      const state = createInitialDebuggerState('run-1');\n\n      expect(state.runId).toBe('run-1');\n      expect(state.status).toBe('detached');\n      expect(state.execution).toBe('running');\n      expect(state.breakpoints).toEqual([]);\n      expect(state.stepMode).toBe('none');\n    });\n  });\n\n  describe('Trigger utilities', () => {\n    it('should check trigger enabled', () => {\n      const enabledTrigger: TriggerSpec = {\n        id: 'trigger-1',\n        kind: 'manual',\n        enabled: true,\n        flowId: 'flow-1',\n      };\n\n      const disabledTrigger: TriggerSpec = {\n        id: 'trigger-2',\n        kind: 'manual',\n        enabled: false,\n        flowId: 'flow-1',\n      };\n\n      expect(isTriggerEnabled(enabledTrigger)).toBe(true);\n      expect(isTriggerEnabled(disabledTrigger)).toBe(false);\n    });\n  });\n});\n\n// ==================== Engine Types ====================\nimport {\n  // Kernel\n  type ExecutionKernel,\n  type RunStartRequest,\n  createNotImplementedKernel,\n\n  // Queue\n  type RunQueue,\n  type RunQueueItem,\n  DEFAULT_QUEUE_CONFIG,\n  createNotImplementedQueue,\n\n  // Plugins\n  type NodeDefinition,\n  type PluginRegistry,\n  getPluginRegistry,\n  resetPluginRegistry,\n\n  // Transport\n  RR_V3_PORT_NAME,\n  type RpcMessage,\n  createRpcRequest,\n  InMemoryEventsBus,\n} from '@/entrypoints/background/record-replay-v3';\n\ndescribe('V3 Engine Types', () => {\n  describe('Kernel', () => {\n    it('should create not-implemented kernel', () => {\n      const kernel = createNotImplementedKernel();\n      expect(kernel).toBeDefined();\n      expect(() => kernel.onEvent(() => {})).toThrow('not implemented');\n    });\n  });\n\n  describe('Queue', () => {\n    it('should export default queue config', () => {\n      expect(DEFAULT_QUEUE_CONFIG).toBeDefined();\n      expect(DEFAULT_QUEUE_CONFIG.maxParallelRuns).toBe(3);\n      expect(DEFAULT_QUEUE_CONFIG.leaseTtlMs).toBe(15000);\n    });\n\n    it('should create not-implemented queue', () => {\n      const queue = createNotImplementedQueue();\n      expect(queue).toBeDefined();\n    });\n  });\n\n  describe('Plugin Registry', () => {\n    beforeEach(() => {\n      resetPluginRegistry();\n    });\n\n    it('should get global registry', () => {\n      const registry = getPluginRegistry();\n      expect(registry).toBeDefined();\n      expect(registry.listNodeKinds()).toEqual([]);\n    });\n\n    it('should register and retrieve nodes', () => {\n      const registry = getPluginRegistry();\n\n      const mockNodeDef: NodeDefinition = {\n        kind: 'test-node',\n        schema: { parse: (x: unknown) => x } as NodeDefinition['schema'],\n        execute: async () => ({ status: 'succeeded' }),\n      };\n\n      registry.registerNode(mockNodeDef);\n      expect(registry.hasNode('test-node')).toBe(true);\n      expect(registry.getNode('test-node')).toBe(mockNodeDef);\n    });\n  });\n\n  describe('Transport', () => {\n    it('should export port name', () => {\n      expect(RR_V3_PORT_NAME).toBe('rr_v3');\n    });\n\n    it('should create RPC request', () => {\n      const req = createRpcRequest('rr_v3.listRuns', { limit: 10 });\n\n      expect(req.type).toBe('rr_v3.request');\n      expect(req.method).toBe('rr_v3.listRuns');\n      expect(req.params).toEqual({ limit: 10 });\n      expect(req.requestId).toBeDefined();\n    });\n  });\n\n  describe('EventsBus (InMemory)', () => {\n    it('should append and list events', async () => {\n      const bus = new InMemoryEventsBus();\n\n      const event = await bus.append({\n        runId: 'run-1',\n        type: 'run.started',\n        flowId: 'flow-1',\n        tabId: 1,\n      });\n\n      expect(event.seq).toBe(1);\n      expect(event.ts).toBeDefined();\n\n      const events = await bus.list({ runId: 'run-1' });\n      expect(events).toHaveLength(1);\n      expect(events[0].type).toBe('run.started');\n    });\n\n    it('should support subscriptions', async () => {\n      const bus = new InMemoryEventsBus();\n      const received: RunEvent[] = [];\n\n      const unsub = bus.subscribe((event) => received.push(event));\n\n      await bus.append({ runId: 'run-1', type: 'run.queued', flowId: 'flow-1' });\n\n      expect(received).toHaveLength(1);\n\n      unsub();\n\n      await bus.append({ runId: 'run-1', type: 'run.started', flowId: 'flow-1', tabId: 1 });\n\n      // Should not receive after unsubscribe\n      expect(received).toHaveLength(1);\n    });\n  });\n});\n\n// ==================== Storage Types ====================\nimport {\n  RR_V3_DB_NAME,\n  RR_V3_DB_VERSION,\n  RR_V3_STORES,\n} from '@/entrypoints/background/record-replay-v3';\n\ndescribe('V3 Storage Constants', () => {\n  it('should export database constants', () => {\n    expect(RR_V3_DB_NAME).toBe('rr_v3');\n    expect(RR_V3_DB_VERSION).toBe(1);\n  });\n\n  it('should export store names', () => {\n    expect(RR_V3_STORES.FLOWS).toBe('flows');\n    expect(RR_V3_STORES.RUNS).toBe('runs');\n    expect(RR_V3_STORES.EVENTS).toBe('events');\n    expect(RR_V3_STORES.QUEUE).toBe('queue');\n    expect(RR_V3_STORES.PERSISTENT_VARS).toBe('persistent_vars');\n    expect(RR_V3_STORES.TRIGGERS).toBe('triggers');\n  });\n});\n\n// ==================== Version ====================\nimport { RR_V3_VERSION, IS_RR_V3 } from '@/entrypoints/background/record-replay-v3';\n\ndescribe('V3 Version', () => {\n  it('should export version info', () => {\n    expect(RR_V3_VERSION).toBe('3.0.0');\n    expect(IS_RR_V3).toBe(true);\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/trigger-manager.test.ts",
    "content": "/**\n * @fileoverview TriggerManager 测试 (P4-02)\n * @description\n * Tests for:\n * - TriggerManager lifecycle (start/stop/refresh)\n * - Handler installation/uninstallation\n * - Trigger firing and enqueueRun\n * - Storm protection (cooldown, maxQueued)\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type {\n  TriggerKind,\n  TriggerSpec,\n} from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';\nimport type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';\nimport type { RunScheduler } from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\nimport type {\n  TriggerFireCallback,\n  TriggerHandler,\n  TriggerHandlerFactory,\n} from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createTriggerManager } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-manager';\n\n// ==================== Test Utilities ====================\n\nfunction createTestFlow(id: string): FlowV3 {\n  return {\n    schemaVersion: 3,\n    id,\n    name: 'Test Flow',\n    createdAt: new Date(0).toISOString(),\n    updatedAt: new Date(0).toISOString(),\n    entryNodeId: 'node-1',\n    nodes: [{ id: 'node-1', kind: 'noop', config: {} }],\n    edges: [],\n  };\n}\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface TestHandler {\n  factory: TriggerHandlerFactory<TriggerKind>;\n  handler: TriggerHandler<TriggerKind>;\n  installed: Map<string, TriggerSpec>;\n  fire: (triggerId: string, ctx: { sourceTabId?: number; sourceUrl?: string }) => Promise<void>;\n}\n\nfunction createTestHandler(kind: TriggerKind): TestHandler {\n  const installed = new Map<string, TriggerSpec>();\n  let callback: TriggerFireCallback | null = null;\n\n  const handler: TriggerHandler<TriggerKind> = {\n    kind,\n    install: vi.fn(async (trigger: TriggerSpec) => {\n      installed.set(trigger.id, trigger);\n    }),\n    uninstall: vi.fn(async (triggerId: string) => {\n      installed.delete(triggerId);\n    }),\n    uninstallAll: vi.fn(async () => {\n      installed.clear();\n    }),\n    getInstalledIds: vi.fn(() => Array.from(installed.keys())),\n  };\n\n  const factory: TriggerHandlerFactory<TriggerKind> = (fireCallback) => {\n    callback = fireCallback;\n    return handler;\n  };\n\n  const fire = async (triggerId: string, ctx: { sourceTabId?: number; sourceUrl?: string }) => {\n    if (!callback) {\n      throw new Error('fireCallback not initialized');\n    }\n    await callback.onFire(triggerId, ctx);\n  };\n\n  return { factory, handler, installed, fire };\n}\n\n// ==================== TriggerManager Tests ====================\n\ndescribe('V3 TriggerManager', () => {\n  let time: number;\n  let runIdCounter: number;\n\n  let triggersList: TriggerSpec[];\n  let flowsMap: Map<string, FlowV3>;\n  let runsMap: Map<string, RunRecordV3>;\n  let queueMap: Map<string, RunQueueItem>;\n\n  let storage: Pick<StoragePort, 'triggers' | 'flows' | 'runs' | 'queue'>;\n  let events: Pick<EventsBus, 'append'>;\n  let scheduler: Pick<RunScheduler, 'kick'>;\n\n  beforeEach(() => {\n    time = 1_700_000_000_000;\n    runIdCounter = 0;\n\n    triggersList = [];\n    flowsMap = new Map();\n    runsMap = new Map();\n    queueMap = new Map();\n\n    storage = {\n      triggers: {\n        list: vi.fn(async () => triggersList),\n        get: vi.fn(async (id: string) => triggersList.find((t) => t.id === id) ?? null),\n        save: vi.fn(async (spec: TriggerSpec) => {\n          const idx = triggersList.findIndex((t) => t.id === spec.id);\n          if (idx >= 0) triggersList[idx] = spec;\n          else triggersList.push(spec);\n        }),\n        delete: vi.fn(async (id: string) => {\n          triggersList = triggersList.filter((t) => t.id !== id);\n        }),\n      },\n      flows: {\n        list: vi.fn(async () => Array.from(flowsMap.values())),\n        get: vi.fn(async (id: string) => flowsMap.get(id) ?? null),\n        save: vi.fn(async (flow: FlowV3) => {\n          flowsMap.set(flow.id, flow);\n        }),\n        delete: vi.fn(async (id: string) => {\n          flowsMap.delete(id);\n        }),\n      },\n      runs: {\n        list: vi.fn(async () => Array.from(runsMap.values())),\n        get: vi.fn(async (id: string) => runsMap.get(id) ?? null),\n        save: vi.fn(async (record: RunRecordV3) => {\n          runsMap.set(record.id, record);\n        }),\n        patch: vi.fn(async (id: string, patch: Partial<RunRecordV3>) => {\n          const existing = runsMap.get(id);\n          if (existing) runsMap.set(id, { ...existing, ...patch });\n        }),\n      },\n      queue: {\n        enqueue: vi.fn(async (input) => {\n          const now = time;\n          const item: RunQueueItem = {\n            ...input,\n            priority: input.priority ?? 0,\n            maxAttempts: input.maxAttempts ?? 1,\n            status: 'queued',\n            createdAt: now,\n            updatedAt: now,\n            attempt: 0,\n          };\n          queueMap.set(item.id, item);\n          return item;\n        }),\n        list: vi.fn(async (status?: string) => {\n          const items = Array.from(queueMap.values());\n          if (status) return items.filter((i) => i.status === status);\n          return items;\n        }),\n      } as unknown as StoragePort['queue'],\n    } as Pick<StoragePort, 'triggers' | 'flows' | 'runs' | 'queue'>;\n\n    events = {\n      append: vi.fn(async (event) => ({ ...event, ts: time, seq: 1 }) as unknown),\n    };\n\n    scheduler = {\n      kick: vi.fn(async () => {}),\n    };\n  });\n\n  describe('Lifecycle', () => {\n    it('installs enabled triggers on start', async () => {\n      const { factory, handler, installed } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n        {\n          id: 't2',\n          kind: 'command',\n          enabled: false,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await manager.start();\n\n      expect(handler.uninstallAll).toHaveBeenCalledTimes(1);\n      expect(handler.install).toHaveBeenCalledTimes(1);\n      expect(Array.from(installed.keys())).toEqual(['t1']);\n    });\n\n    it('stop uninstalls all triggers', async () => {\n      const { factory, handler, installed } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await manager.start();\n      expect(installed.size).toBe(1);\n\n      await manager.stop();\n      expect(handler.uninstallAll).toHaveBeenCalledTimes(2); // once in start, once in stop\n      expect(installed.size).toBe(0);\n    });\n\n    it('refresh resets installations when triggers change', async () => {\n      const { factory, handler, installed } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await manager.start();\n      expect(Array.from(installed.keys())).toEqual(['t1']);\n\n      // Disable trigger\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: false,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n      await manager.refresh();\n\n      expect(handler.uninstallAll).toHaveBeenCalledTimes(2);\n      expect(installed.size).toBe(0);\n    });\n\n    it('getState returns correct state', async () => {\n      const { factory } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      expect(manager.getState()).toEqual({\n        started: false,\n        installedTriggerIds: [],\n      });\n\n      await manager.start();\n\n      expect(manager.getState()).toEqual({\n        started: true,\n        installedTriggerIds: ['t1'],\n      });\n    });\n  });\n\n  describe('Trigger firing', () => {\n    it('enqueues a run on fire and records trigger context', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n          args: { foo: 'bar' },\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      await fire('t1', { sourceTabId: 123, sourceUrl: 'https://example.com' });\n\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n      const savedRun = (storage.runs.save as ReturnType<typeof vi.fn>).mock\n        .calls[0][0] as RunRecordV3;\n      expect(savedRun).toMatchObject({\n        id: 'run-1',\n        flowId: 'flow-1',\n        status: 'queued',\n        args: { foo: 'bar' },\n        trigger: {\n          triggerId: 't1',\n          kind: 'command',\n          firedAt: time,\n          sourceTabId: 123,\n          sourceUrl: 'https://example.com',\n        },\n      });\n\n      expect(scheduler.kick).toHaveBeenCalled();\n    });\n\n    it('ignores fire for non-installed trigger', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      await fire('unknown-trigger', {});\n\n      expect(storage.runs.save).not.toHaveBeenCalled();\n    });\n\n    it('ignores fire when manager is stopped', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n      await manager.stop();\n\n      await fire('t1', {});\n\n      expect(storage.runs.save).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Storm protection - cooldown', () => {\n    it('applies per-trigger cooldown', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { cooldownMs: 500 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // First fire - should succeed\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Second fire within cooldown - should be dropped\n      time += 200;\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Third fire after cooldown - should succeed\n      time += 600; // total 800ms > 500ms cooldown\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n\n    it('cooldown is per-trigger', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd1',\n        } as TriggerSpec,\n        {\n          id: 't2',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd2',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { cooldownMs: 500 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // Fire t1\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Fire t2 immediately - should succeed (different trigger)\n      await fire('t2', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n\n      // Fire t1 again within cooldown - should be dropped\n      time += 100;\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('Storm protection - maxQueued', () => {\n    it('applies global maxQueued cap', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { maxQueued: 1 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // First fire - should succeed\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Second fire - should be dropped (maxQueued reached)\n      time += 1;\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n    });\n\n    it('maxQueued cap allows more fires when queue drains', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { maxQueued: 1 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // First fire - should succeed\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Simulate queue drain\n      queueMap.clear();\n      time += 1;\n\n      // Fire again - should succeed\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('Multiple handler types', () => {\n    it('handles multiple trigger kinds', async () => {\n      const commandHandler = createTestHandler('command');\n      const urlHandler = createTestHandler('url');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n        {\n          id: 't2',\n          kind: 'url',\n          enabled: true,\n          flowId: 'flow-1',\n          match: [{ kind: 'domain', value: 'example.com' }],\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: {\n          command: commandHandler.factory,\n          url: urlHandler.factory,\n        },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      expect(commandHandler.installed.size).toBe(1);\n      expect(urlHandler.installed.size).toBe(1);\n\n      // Fire both\n      await commandHandler.fire('t1', {});\n      await urlHandler.fire('t2', { sourceUrl: 'https://example.com' });\n\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('Error handling', () => {\n    it('continues after handler install failure', async () => {\n      const { factory, installed } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd1',\n        } as TriggerSpec,\n        {\n          id: 't2',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd2',\n        } as TriggerSpec,\n      ];\n\n      // Make first install fail\n      let callCount = 0;\n      const originalFactory: TriggerHandlerFactory<TriggerKind> = (fireCallback) => {\n        const handler = factory(fireCallback);\n        const originalInstall = handler.install;\n        handler.install = vi.fn(async (trigger: TriggerSpec) => {\n          callCount++;\n          if (callCount === 1) {\n            throw new Error('Install failed');\n          }\n          return originalInstall(trigger);\n        });\n        return handler;\n      };\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: originalFactory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await manager.start();\n\n      // Only t2 should be installed\n      expect(installed.size).toBe(1);\n      expect(installed.has('t2')).toBe(true);\n    });\n\n    it('refresh throws when not started', async () => {\n      const { factory } = createTestHandler('command');\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await expect(manager.refresh()).rejects.toThrow('TriggerManager is not started');\n    });\n\n    it('continues after uninstallAll failure during refresh', async () => {\n      const { factory, installed } = createTestHandler('command');\n\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      let uninstallCallCount = 0;\n      const originalFactory: TriggerHandlerFactory<TriggerKind> = (fireCallback) => {\n        const handler = factory(fireCallback);\n        handler.uninstallAll = vi.fn(async () => {\n          uninstallCallCount++;\n          if (uninstallCallCount === 2) {\n            throw new Error('UninstallAll failed');\n          }\n          installed.clear();\n        });\n        return handler;\n      };\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: originalFactory },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n\n      await manager.start();\n\n      // Add new trigger\n      triggersList.push({\n        id: 't2',\n        kind: 'command',\n        enabled: true,\n        flowId: 'flow-1',\n        commandKey: 'cmd2',\n      } as TriggerSpec);\n\n      // Refresh should continue despite uninstallAll failure\n      await manager.refresh();\n      expect(installed.size).toBe(2);\n    });\n\n    it('cooldown rollback on enqueueRun failure', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      // Make enqueue fail\n      let enqueueCallCount = 0;\n      (storage.queue.enqueue as ReturnType<typeof vi.fn>).mockImplementation(async () => {\n        enqueueCallCount++;\n        if (enqueueCallCount === 1) {\n          throw new Error('Enqueue failed');\n        }\n        const now = time;\n        const item: RunQueueItem = {\n          id: `run-${runIdCounter}`,\n          flowId: 'flow-1',\n          priority: 0,\n          maxAttempts: 1,\n          status: 'queued',\n          createdAt: now,\n          updatedAt: now,\n          attempt: 0,\n        };\n        queueMap.set(item.id, item);\n        return item;\n      });\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { cooldownMs: 500 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // First fire fails, cooldown should be rolled back\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Immediate retry should succeed (cooldown was rolled back)\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('maxQueued does not affect cooldown', () => {\n    it('does not set cooldown when dropped due to maxQueued', async () => {\n      const { factory, fire } = createTestHandler('command');\n\n      flowsMap.set('flow-1', createTestFlow('flow-1'));\n      triggersList = [\n        {\n          id: 't1',\n          kind: 'command',\n          enabled: true,\n          flowId: 'flow-1',\n          commandKey: 'cmd',\n        } as TriggerSpec,\n      ];\n\n      const manager = createTriggerManager({\n        storage,\n        events,\n        scheduler,\n        handlerFactories: { command: factory },\n        storm: { cooldownMs: 500, maxQueued: 1 },\n        now: () => time,\n        generateRunId: () => `run-${++runIdCounter}`,\n        logger: createSilentLogger(),\n      });\n      await manager.start();\n\n      // First fire succeeds\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Second fire dropped due to maxQueued (but cooldown should still be set)\n      time += 100;\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // Clear queue, but within cooldown - should still be dropped\n      queueMap.clear();\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(1);\n\n      // After cooldown - should succeed\n      time += 500;\n      await fire('t1', {});\n      expect(storage.runs.save).toHaveBeenCalledTimes(2);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/triggers.test.ts",
    "content": "/**\n * @fileoverview 触发器测试 (P4-01)\n * @description\n * Tests for:\n * - TriggerStore CRUD operations\n * - TriggerSpec type validation\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest';\n\nimport type { TriggerSpec } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport {\n  createTriggersStore,\n  closeRrV3Db,\n  deleteRrV3Db,\n} from '@/entrypoints/background/record-replay-v3';\n\n// ==================== Test Utilities ====================\n\nfunction createUrlTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'url',\n    enabled: true,\n    flowId: flowId as any,\n    match: [{ kind: 'domain', value: 'example.com' }],\n  };\n}\n\nfunction createCronTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'cron',\n    enabled: true,\n    flowId: flowId as any,\n    cron: '0 9 * * *', // Every day at 9am\n    timezone: 'UTC',\n  };\n}\n\nfunction createCommandTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'command',\n    enabled: true,\n    flowId: flowId as any,\n    commandKey: 'run-flow-1',\n  };\n}\n\nfunction createContextMenuTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'contextMenu',\n    enabled: true,\n    flowId: flowId as any,\n    title: 'Run Flow',\n    contexts: ['page', 'selection'],\n  };\n}\n\nfunction createDomTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'dom',\n    enabled: true,\n    flowId: flowId as any,\n    selector: '#submit-button',\n    appear: true,\n    once: false,\n    debounceMs: 1000,\n  };\n}\n\nfunction createManualTrigger(id: string, flowId: string): TriggerSpec {\n  return {\n    id: id as any,\n    kind: 'manual',\n    enabled: true,\n    flowId: flowId as any,\n  };\n}\n\n// ==================== TriggerStore Tests ====================\n\ndescribe('TriggerStore CRUD', () => {\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n  });\n\n  describe('Basic CRUD', () => {\n    it('save and get a trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createUrlTrigger('trigger-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('trigger-1' as any);\n\n      expect(retrieved).not.toBeNull();\n      expect(retrieved).toMatchObject({\n        id: 'trigger-1',\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1',\n        match: [{ kind: 'domain', value: 'example.com' }],\n      });\n    });\n\n    it('get returns null for non-existent trigger', async () => {\n      const store = createTriggersStore();\n\n      const retrieved = await store.get('non-existent' as any);\n\n      expect(retrieved).toBeNull();\n    });\n\n    it('list returns all triggers', async () => {\n      const store = createTriggersStore();\n\n      await store.save(createUrlTrigger('trigger-1', 'flow-1'));\n      await store.save(createCronTrigger('trigger-2', 'flow-2'));\n      await store.save(createCommandTrigger('trigger-3', 'flow-3'));\n\n      const triggers = await store.list();\n\n      expect(triggers).toHaveLength(3);\n      expect(triggers.map((t) => t.id)).toContain('trigger-1');\n      expect(triggers.map((t) => t.id)).toContain('trigger-2');\n      expect(triggers.map((t) => t.id)).toContain('trigger-3');\n    });\n\n    it('list returns empty array when no triggers', async () => {\n      const store = createTriggersStore();\n\n      const triggers = await store.list();\n\n      expect(triggers).toHaveLength(0);\n    });\n\n    it('save updates existing trigger', async () => {\n      const store = createTriggersStore();\n\n      await store.save(createUrlTrigger('trigger-1', 'flow-1'));\n\n      // Update\n      const updated: TriggerSpec = {\n        id: 'trigger-1' as any,\n        kind: 'url',\n        enabled: false, // Changed\n        flowId: 'flow-1' as any,\n        match: [{ kind: 'url', value: 'https://example.com/new' }], // Changed\n      };\n      await store.save(updated);\n\n      const retrieved = await store.get('trigger-1' as any);\n      expect(retrieved).toMatchObject({\n        id: 'trigger-1',\n        enabled: false,\n        match: [{ kind: 'url', value: 'https://example.com/new' }],\n      });\n    });\n\n    it('delete removes a trigger', async () => {\n      const store = createTriggersStore();\n\n      await store.save(createUrlTrigger('trigger-1', 'flow-1'));\n      await store.delete('trigger-1' as any);\n\n      const retrieved = await store.get('trigger-1' as any);\n      expect(retrieved).toBeNull();\n    });\n\n    it('delete is idempotent for non-existent trigger', async () => {\n      const store = createTriggersStore();\n\n      // Should not throw\n      await expect(store.delete('non-existent' as any)).resolves.toBeUndefined();\n    });\n  });\n\n  describe('All trigger kinds', () => {\n    it('stores and retrieves URL trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createUrlTrigger('url-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('url-1' as any);\n\n      expect(retrieved?.kind).toBe('url');\n      expect((retrieved as any).match).toEqual([{ kind: 'domain', value: 'example.com' }]);\n    });\n\n    it('stores and retrieves cron trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createCronTrigger('cron-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('cron-1' as any);\n\n      expect(retrieved?.kind).toBe('cron');\n      expect((retrieved as any).cron).toBe('0 9 * * *');\n      expect((retrieved as any).timezone).toBe('UTC');\n    });\n\n    it('stores and retrieves command trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createCommandTrigger('cmd-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('cmd-1' as any);\n\n      expect(retrieved?.kind).toBe('command');\n      expect((retrieved as any).commandKey).toBe('run-flow-1');\n    });\n\n    it('stores and retrieves contextMenu trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createContextMenuTrigger('ctx-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('ctx-1' as any);\n\n      expect(retrieved?.kind).toBe('contextMenu');\n      expect((retrieved as any).title).toBe('Run Flow');\n      expect((retrieved as any).contexts).toEqual(['page', 'selection']);\n    });\n\n    it('stores and retrieves DOM trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createDomTrigger('dom-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('dom-1' as any);\n\n      expect(retrieved?.kind).toBe('dom');\n      expect((retrieved as any).selector).toBe('#submit-button');\n      expect((retrieved as any).appear).toBe(true);\n      expect((retrieved as any).once).toBe(false);\n      expect((retrieved as any).debounceMs).toBe(1000);\n    });\n\n    it('stores and retrieves manual trigger', async () => {\n      const store = createTriggersStore();\n      const trigger = createManualTrigger('manual-1', 'flow-1');\n\n      await store.save(trigger);\n      const retrieved = await store.get('manual-1' as any);\n\n      expect(retrieved?.kind).toBe('manual');\n    });\n  });\n\n  describe('Trigger with args', () => {\n    it('stores and retrieves trigger with args', async () => {\n      const store = createTriggersStore();\n      const trigger: TriggerSpec = {\n        ...createUrlTrigger('trigger-1', 'flow-1'),\n        args: {\n          mode: 'production',\n          retryCount: 3,\n          tags: ['important', 'automated'],\n        },\n      };\n\n      await store.save(trigger);\n      const retrieved = await store.get('trigger-1' as any);\n\n      expect(retrieved?.args).toEqual({\n        mode: 'production',\n        retryCount: 3,\n        tags: ['important', 'automated'],\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/url-trigger.test.ts",
    "content": "/**\n * @fileoverview URL Trigger Handler 测试 (P4-03)\n * @description\n * Tests for:\n * - URL matching semantics (domain, path, url prefix)\n * - Listener lifecycle (add/remove on install/uninstall)\n * - Edge cases (subframe, invalid URL)\n */\n\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { TriggerSpecByKind } from '@/entrypoints/background/record-replay-v3/domain/triggers';\nimport type { TriggerFireCallback } from '@/entrypoints/background/record-replay-v3/engine/triggers/trigger-handler';\nimport { createUrlTriggerHandlerFactory } from '@/entrypoints/background/record-replay-v3/engine/triggers/url-trigger';\n\n// ==================== Test Utilities ====================\n\nfunction createSilentLogger(): Pick<Console, 'debug' | 'info' | 'warn' | 'error'> {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\ninterface WebNavigationMock {\n  onCompleted: {\n    addListener: ReturnType<typeof vi.fn>;\n    removeListener: ReturnType<typeof vi.fn>;\n  };\n  emitCompleted: (details: { tabId: number; frameId: number; url: string }) => void;\n}\n\nfunction createWebNavigationMock(): WebNavigationMock {\n  const listeners = new Set<(details: unknown) => void>();\n\n  const onCompleted = {\n    addListener: vi.fn((cb: (details: unknown) => void) => {\n      listeners.add(cb);\n    }),\n    removeListener: vi.fn((cb: (details: unknown) => void) => {\n      listeners.delete(cb);\n    }),\n  };\n\n  return {\n    onCompleted,\n    emitCompleted: (details) => {\n      for (const cb of listeners) cb(details);\n    },\n  };\n}\n\n// ==================== URL Trigger Tests ====================\n\ndescribe('V3 UrlTriggerHandler', () => {\n  let webNav: WebNavigationMock;\n\n  beforeEach(() => {\n    webNav = createWebNavigationMock();\n    (globalThis.chrome as unknown as { webNavigation: unknown }).webNavigation = {\n      onCompleted: webNav.onCompleted,\n    };\n  });\n\n  describe('Domain matching', () => {\n    it('matches exact domain', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'example.com' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/page' });\n      expect(fireCallback.onFire).toHaveBeenCalledWith('t1', {\n        sourceTabId: 1,\n        sourceUrl: 'https://example.com/page',\n      });\n    });\n\n    it('matches subdomain', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'example.com' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://www.example.com/a' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://sub.sub.example.com/b' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(2);\n    });\n\n    it('avoids substring false-positives', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'example.com' }],\n      };\n\n      await handler.install(trigger);\n\n      // Should NOT match - domain contains \"example.com\" as substring but is not example.com or subdomain\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://notexample.com/a' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com.evil.com/a' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('handles domain with leading/trailing dots', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: '..example.com..' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/page' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('Path matching', () => {\n    it('matches path prefix', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'path', value: '/foo' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://any.com/foo/bar' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://any.com/foobar' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(2);\n    });\n\n    it('does not match non-matching path', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'path', value: '/foo' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://any.com/bar' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n\n    it('normalizes path without leading slash', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'path', value: 'foo' }], // No leading slash\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://any.com/foo/bar' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('URL prefix matching', () => {\n    it('matches full URL prefix', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'url', value: 'https://example.com/a' }],\n      };\n\n      await handler.install(trigger);\n\n      // Matches prefix with query/hash\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/a?x=1#hash' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n\n      // Matches prefix with additional path\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/a/b/c' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(2);\n    });\n\n    it('does not match non-matching URL', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'url', value: 'https://example.com/a' }],\n      };\n\n      await handler.install(trigger);\n\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/b' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Multiple rules (OR logic)', () => {\n    it('fires if any rule matches', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [\n          { kind: 'domain', value: 'example.com' },\n          { kind: 'path', value: '/special' },\n        ],\n      };\n\n      await handler.install(trigger);\n\n      // Match by domain\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://example.com/any' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(1);\n\n      // Match by path on different domain\n      webNav.emitCompleted({ tabId: 1, frameId: 0, url: 'https://other.com/special/page' });\n      expect(fireCallback.onFire).toHaveBeenCalledTimes(2);\n    });\n  });\n\n  describe('Frame filtering', () => {\n    it('ignores subframe navigations', async () => {\n      const fireCallback: TriggerFireCallback = {\n        onFire: vi.fn(async () => {}),\n      };\n\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const trigger: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'example.com' }],\n      };\n\n      await handler.install(trigger);\n\n      // frameId !== 0 should be ignored\n      webNav.emitCompleted({ tabId: 1, frameId: 1, url: 'https://example.com/' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n\n      webNav.emitCompleted({ tabId: 1, frameId: 99, url: 'https://example.com/' });\n      expect(fireCallback.onFire).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('Listener lifecycle', () => {\n    it('registers single listener on first install', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'a.com' }],\n      };\n\n      const t2: TriggerSpecByKind<'url'> = {\n        id: 't2' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'b.com' }],\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      // Only one listener should be added\n      expect(webNav.onCompleted.addListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('removes listener when all triggers uninstalled', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'a.com' }],\n      };\n\n      const t2: TriggerSpecByKind<'url'> = {\n        id: 't2' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'b.com' }],\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      await handler.uninstall('t1');\n      expect(webNav.onCompleted.removeListener).not.toHaveBeenCalled();\n\n      await handler.uninstall('t2');\n      expect(webNav.onCompleted.removeListener).toHaveBeenCalledTimes(1);\n    });\n\n    it('removes listener on uninstallAll', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'example.com' }],\n      };\n\n      await handler.install(t1);\n      await handler.uninstallAll();\n\n      expect(webNav.onCompleted.removeListener).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('getInstalledIds', () => {\n    it('returns installed trigger IDs', async () => {\n      const fireCallback: TriggerFireCallback = { onFire: vi.fn(async () => {}) };\n      const handler = createUrlTriggerHandlerFactory({ logger: createSilentLogger() })(\n        fireCallback,\n      );\n\n      const t1: TriggerSpecByKind<'url'> = {\n        id: 't1' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'a.com' }],\n      };\n\n      const t2: TriggerSpecByKind<'url'> = {\n        id: 't2' as never,\n        kind: 'url',\n        enabled: true,\n        flowId: 'flow-1' as never,\n        match: [{ kind: 'domain', value: 'b.com' }],\n      };\n\n      await handler.install(t1);\n      await handler.install(t2);\n\n      expect(handler.getInstalledIds().sort()).toEqual(['t1', 't2']);\n\n      await handler.uninstall('t1');\n      expect(handler.getInstalledIds()).toEqual(['t2']);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/v2-action-adapter.test.ts",
    "content": "/**\n * @fileoverview V2 Action Adapter unit tests\n * @description Tests for adaptV2ActionHandlerToV3NodeDefinition\n *\n * Coverage:\n * - varsPatch generation (set/delete)\n * - nextLabel mapping\n * - Error code mapping\n * - Tab/frame state vars\n * - paused/control directive handling\n * - Output capture\n */\n\nimport { describe, expect, it, vi } from 'vitest';\n\nimport type {\n  ActionExecutionContext,\n  ActionExecutionResult,\n  ActionHandler,\n} from '@/entrypoints/background/record-replay/actions/types';\nimport type {\n  NodeExecutionContext,\n  NodeExecutionResult,\n} from '@/entrypoints/background/record-replay-v3/engine/plugins/types';\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';\nimport type { RunId, NodeId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport { RR_ERROR_CODES } from '@/entrypoints/background/record-replay-v3/domain/errors';\nimport { FLOW_SCHEMA_VERSION } from '@/entrypoints/background/record-replay-v3/domain/flow';\n\nimport { adaptV2ActionHandlerToV3NodeDefinition } from '@/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter';\n\n// ==================== Test Fixtures ====================\n\nfunction createMockV3Context(overrides: Partial<NodeExecutionContext> = {}): NodeExecutionContext {\n  const flow: FlowV3 = {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id: 'test-flow',\n    name: 'Test Flow',\n    createdAt: new Date(0).toISOString(),\n    updatedAt: new Date(0).toISOString(),\n    entryNodeId: 'node-1',\n    nodes: [],\n    edges: [],\n  };\n\n  return {\n    runId: 'run-1' as RunId,\n    flow,\n    nodeId: 'node-1' as NodeId,\n    tabId: 1,\n    vars: {},\n    log: vi.fn(),\n    chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n    artifacts: {\n      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'mock-base64' }),\n    },\n    persistent: {\n      get: vi.fn().mockResolvedValue(undefined),\n      set: vi.fn().mockResolvedValue(undefined),\n      delete: vi.fn().mockResolvedValue(undefined),\n    },\n    ...overrides,\n  };\n}\n\nfunction createMockNode(id = 'node-1', config: Record<string, unknown> = {}) {\n  return {\n    id: id as NodeId,\n    kind: 'test' as const,\n    config,\n  };\n}\n\ntype TestActionType = 'test';\n\nfunction createMockHandler(\n  runFn: (\n    ctx: ActionExecutionContext,\n    action: unknown,\n  ) => Promise<ActionExecutionResult<TestActionType>>,\n): ActionHandler<TestActionType> {\n  return {\n    type: 'test' as TestActionType,\n    run: runFn,\n  };\n}\n\n// ==================== Tests ====================\n\ndescribe('adaptV2ActionHandlerToV3NodeDefinition', () => {\n  describe('Basic execution', () => {\n    it('returns succeeded for successful V2 handler', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n    });\n\n    it('maps V2 failed status to V3 failed', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'failed',\n        error: { code: 'TIMEOUT', message: 'Timed out' },\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.TIMEOUT);\n      expect(result.error?.message).toBe('Timed out');\n    });\n\n    it('handles V2 handler throwing exception', async () => {\n      const handler = createMockHandler(async () => {\n        throw new Error('Unexpected error');\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.INTERNAL);\n      expect(result.error?.message).toContain('Unexpected error');\n    });\n  });\n\n  describe('varsPatch generation', () => {\n    it('generates set patch for new variable', async () => {\n      const handler = createMockHandler(async (ctx) => {\n        ctx.vars['newVar'] = 'value';\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({ vars: {} });\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({ op: 'set', name: 'newVar', value: 'value' });\n    });\n\n    it('generates set patch for modified variable', async () => {\n      const handler = createMockHandler(async (ctx) => {\n        ctx.vars['existing'] = 'modified';\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({ vars: { existing: 'original' } });\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({ op: 'set', name: 'existing', value: 'modified' });\n    });\n\n    it('generates delete patch for removed variable', async () => {\n      const handler = createMockHandler(async (ctx) => {\n        delete ctx.vars['toDelete'];\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({ vars: { toDelete: 'value' } });\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({ op: 'delete', name: 'toDelete' });\n    });\n\n    it('handles deep object changes', async () => {\n      const handler = createMockHandler(async (ctx) => {\n        ctx.vars['obj'] = { nested: { value: 42 } };\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({ vars: { obj: { nested: { value: 1 } } } });\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({\n        op: 'set',\n        name: 'obj',\n        value: { nested: { value: 42 } },\n      });\n    });\n\n    it('does not generate patch when vars unchanged', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({ vars: { existing: 'value' } });\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toBeUndefined();\n    });\n  });\n\n  describe('nextLabel mapping', () => {\n    it('maps nextLabel to chooseNext result', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        nextLabel: 'true',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'true' });\n    });\n\n    it('does not set next when no nextLabel', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toBeUndefined();\n    });\n  });\n\n  describe('Error code mapping', () => {\n    const errorCodes: Array<{ v2Code: string; v3Code: string }> = [\n      { v2Code: 'VALIDATION_ERROR', v3Code: RR_ERROR_CODES.VALIDATION_ERROR },\n      { v2Code: 'TIMEOUT', v3Code: RR_ERROR_CODES.TIMEOUT },\n      { v2Code: 'TAB_NOT_FOUND', v3Code: RR_ERROR_CODES.TAB_NOT_FOUND },\n      { v2Code: 'FRAME_NOT_FOUND', v3Code: RR_ERROR_CODES.FRAME_NOT_FOUND },\n      { v2Code: 'TARGET_NOT_FOUND', v3Code: RR_ERROR_CODES.TARGET_NOT_FOUND },\n      { v2Code: 'ELEMENT_NOT_VISIBLE', v3Code: RR_ERROR_CODES.ELEMENT_NOT_VISIBLE },\n      { v2Code: 'NAVIGATION_FAILED', v3Code: RR_ERROR_CODES.NAVIGATION_FAILED },\n      { v2Code: 'NETWORK_REQUEST_FAILED', v3Code: RR_ERROR_CODES.NETWORK_REQUEST_FAILED },\n      { v2Code: 'SCRIPT_FAILED', v3Code: RR_ERROR_CODES.SCRIPT_FAILED },\n      { v2Code: 'DOWNLOAD_FAILED', v3Code: RR_ERROR_CODES.TOOL_ERROR },\n      { v2Code: 'ASSERTION_FAILED', v3Code: RR_ERROR_CODES.TOOL_ERROR },\n      { v2Code: 'UNKNOWN', v3Code: RR_ERROR_CODES.INTERNAL },\n    ];\n\n    errorCodes.forEach(({ v2Code, v3Code }) => {\n      it(`maps V2 ${v2Code} to V3 ${v3Code}`, async () => {\n        const handler = createMockHandler(async () => ({\n          status: 'failed',\n          error: { code: v2Code as any, message: 'Test error' },\n        }));\n\n        const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n        const ctx = createMockV3Context();\n        const node = createMockNode();\n\n        const result = await nodeDef.execute(ctx, node as any);\n\n        expect(result.status).toBe('failed');\n        expect(result.error?.code).toBe(v3Code);\n      });\n    });\n  });\n\n  describe('Tab/frame state vars', () => {\n    it('persists newTabId as __rr_v2__tabId', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        newTabId: 42,\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({\n        op: 'set',\n        name: '__rr_v2__tabId',\n        value: 42,\n      });\n    });\n\n    it('persists ctx.frameId as __rr_v2__frameId', async () => {\n      const handler = createMockHandler(async (ctx) => {\n        ctx.frameId = 5;\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.varsPatch).toContainEqual({\n        op: 'set',\n        name: '__rr_v2__frameId',\n        value: 5,\n      });\n    });\n\n    it('reads tabId from __rr_v2__tabId var', async () => {\n      let capturedTabId: number | undefined;\n      const handler = createMockHandler(async (ctx) => {\n        capturedTabId = ctx.tabId;\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({\n        tabId: 1,\n        vars: { __rr_v2__tabId: 99 },\n      });\n      const node = createMockNode();\n\n      await nodeDef.execute(ctx, node as any);\n\n      expect(capturedTabId).toBe(99);\n    });\n\n    it('reads frameId from __rr_v2__frameId var', async () => {\n      let capturedFrameId: number | undefined;\n      const handler = createMockHandler(async (ctx) => {\n        capturedFrameId = ctx.frameId;\n        return { status: 'success' };\n      });\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context({\n        vars: { __rr_v2__frameId: 7 },\n      });\n      const node = createMockNode();\n\n      await nodeDef.execute(ctx, node as any);\n\n      expect(capturedFrameId).toBe(7);\n    });\n\n    it('supports custom state var names', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        newTabId: 42,\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler, {\n        stateVars: { tabIdVar: 'custom_tab', frameIdVar: 'custom_frame' },\n      });\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.varsPatch).toContainEqual({\n        op: 'set',\n        name: 'custom_tab',\n        value: 42,\n      });\n    });\n  });\n\n  describe('Unsupported V2 behaviors', () => {\n    it('returns failed for paused status', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'paused',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.RUN_PAUSED);\n    });\n\n    it('returns failed for control directive (foreach)', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        control: {\n          kind: 'foreach' as const,\n          listVar: 'items',\n          itemVar: 'item',\n          subflowId: 'subflow-1',\n        },\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.UNSUPPORTED_NODE);\n      expect(result.error?.message).toContain('foreach');\n    });\n\n    it('returns failed for control directive (while)', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        control: {\n          kind: 'while' as const,\n          condition: { left: 'a', op: '==', right: 'b' },\n          subflowId: 'subflow-1',\n          maxIterations: 10,\n        },\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.UNSUPPORTED_NODE);\n      expect(result.error?.message).toContain('while');\n    });\n  });\n\n  describe('Output capture', () => {\n    it('captures output in outputs map', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        output: { extracted: 'data' },\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode('extract-node');\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.outputs).toEqual({\n        'extract-node': { extracted: 'data' },\n      });\n    });\n\n    it('respects includeOutput: false option', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n        output: { extracted: 'data' },\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler, {\n        includeOutput: false,\n      });\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.outputs).toBeUndefined();\n    });\n\n    it('does not include outputs when no output', async () => {\n      const handler = createMockHandler(async () => ({\n        status: 'success',\n      }));\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.outputs).toBeUndefined();\n    });\n  });\n\n  describe('Validation', () => {\n    it('calls handler validate and returns error on failure', async () => {\n      const handler: ActionHandler<TestActionType> = {\n        type: 'test' as TestActionType,\n        validate: () => ({ ok: false, errors: ['Invalid config'] }),\n        run: async () => ({ status: 'success' }),\n      };\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('failed');\n      expect(result.error?.code).toBe(RR_ERROR_CODES.VALIDATION_ERROR);\n      expect(result.error?.message).toContain('Invalid config');\n    });\n\n    it('proceeds with execution when validation passes', async () => {\n      const handler: ActionHandler<TestActionType> = {\n        type: 'test' as TestActionType,\n        validate: () => ({ ok: true }),\n        run: async () => ({ status: 'success' }),\n      };\n\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(handler);\n      const ctx = createMockV3Context();\n      const node = createMockNode();\n\n      const result = await nodeDef.execute(ctx, node as any);\n\n      expect(result.status).toBe('succeeded');\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/v2-adapter-integration.test.ts",
    "content": "/**\n * @fileoverview V2 Action Adapter integration tests\n * @description Tests the full flow of V2 handlers through V3 runner\n *\n * This test uses real V2 handlers (like 'if') to verify:\n * - Handler registration works\n * - V3 runner can execute V2 handlers\n * - Edge following based on nextLabel\n * - Event emission for adapted nodes\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { FlowV3 } from '@/entrypoints/background/record-replay-v3';\nimport {\n  FLOW_SCHEMA_VERSION,\n  RUN_SCHEMA_VERSION,\n  closeRrV3Db,\n  deleteRrV3Db,\n  resetBreakpointRegistry,\n} from '@/entrypoints/background/record-replay-v3';\n\nimport { PluginRegistry } from '@/entrypoints/background/record-replay-v3/engine/plugins/registry';\nimport { ifHandler } from '@/entrypoints/background/record-replay/actions/handlers/control-flow';\nimport { delayHandler } from '@/entrypoints/background/record-replay/actions/handlers/delay';\nimport { adaptV2ActionHandlerToV3NodeDefinition } from '@/entrypoints/background/record-replay-v3/engine/plugins/v2-action-adapter';\nimport { createV3E2EHarness, type V3E2EHarness, type RpcClient } from './v3-e2e-harness';\n\n// ==================== Test Fixtures ====================\n\n/**\n * Create a Flow that uses the 'if' node with branching\n */\nfunction createIfBranchingFlow(id: string, conditionVar: string): FlowV3 {\n  const iso = new Date(0).toISOString();\n  return {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id,\n    name: `If Branching Flow ${id}`,\n    createdAt: iso,\n    updatedAt: iso,\n    entryNodeId: 'if-node',\n    nodes: [\n      {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: {\n            kind: 'truthy',\n            value: { kind: 'var', ref: { name: conditionVar } },\n          },\n          trueLabel: 'true',\n          falseLabel: 'false',\n        },\n      },\n      {\n        id: 'true-node',\n        kind: 'test',\n        config: { action: 'succeed', outputs: { result: 'true-path' } },\n      },\n      {\n        id: 'false-node',\n        kind: 'test',\n        config: { action: 'succeed', outputs: { result: 'false-path' } },\n      },\n    ],\n    edges: [\n      { id: 'e1', from: 'if-node', to: 'true-node', label: 'true' },\n      { id: 'e2', from: 'if-node', to: 'false-node', label: 'false' },\n    ],\n  };\n}\n\n/**\n * Create a simple delay flow to test timing-based handlers\n */\nfunction createDelayFlow(id: string, delayMs: number): FlowV3 {\n  const iso = new Date(0).toISOString();\n  return {\n    schemaVersion: FLOW_SCHEMA_VERSION,\n    id,\n    name: `Delay Flow ${id}`,\n    createdAt: iso,\n    updatedAt: iso,\n    entryNodeId: 'delay-node',\n    nodes: [\n      {\n        id: 'delay-node',\n        kind: 'delay',\n        config: { ms: delayMs },\n      },\n    ],\n    edges: [],\n  };\n}\n\n// ==================== Custom Harness with V2 Handlers ====================\n\n/**\n * Extended harness that registers real V2 handlers\n */\nfunction createV2IntegrationHarness(): V3E2EHarness {\n  // First create base harness (which registers 'test' node)\n  const harness = createV3E2EHarness({ autoStartScheduler: false });\n\n  // Register V2 handlers via adapter\n  // Note: We need to access the internal plugins registry\n  // For this test, we'll directly register to the runner factory's plugins\n  return harness;\n}\n\n// ==================== Tests ====================\n\ndescribe('V2 Action Adapter Integration', () => {\n  let h: V3E2EHarness;\n  let client: RpcClient;\n  let plugins: PluginRegistry;\n\n  beforeEach(async () => {\n    await deleteRrV3Db();\n    closeRrV3Db();\n    resetBreakpointRegistry();\n\n    // Create harness without auto-starting scheduler\n    h = createV3E2EHarness({ autoStartScheduler: false });\n    client = h.createClient();\n\n    // Create a separate plugin registry for testing\n    plugins = new PluginRegistry();\n  });\n\n  afterEach(async () => {\n    await h.dispose();\n  });\n\n  describe('if handler through adapter', () => {\n    it('adapts if handler to V3 NodeDefinition', () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      expect(nodeDef.kind).toBe('if');\n      expect(typeof nodeDef.execute).toBe('function');\n    });\n\n    it('registers if handler in PluginRegistry', () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n      plugins.registerNode(nodeDef as any);\n\n      expect(plugins.hasNode('if')).toBe(true);\n      expect(plugins.getNode('if')).toBeDefined();\n    });\n\n    it('evaluates truthy condition and returns true label', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { flag: true },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'flag' } } },\n          trueLabel: 'yes',\n          falseLabel: 'no',\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'yes' });\n    });\n\n    it('evaluates falsy condition and returns false label', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { flag: false },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'flag' } } },\n          trueLabel: 'yes',\n          falseLabel: 'no',\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'no' });\n    });\n\n    it('handles compare condition (eq)', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { value: 42 },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: {\n            kind: 'compare',\n            left: { kind: 'var', ref: { name: 'value' } },\n            op: 'eq',\n            right: 42,\n          },\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'true' });\n    });\n\n    it('handles branches mode', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { status: 'pending' },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'branches',\n          branches: [\n            {\n              label: 'completed',\n              condition: {\n                kind: 'compare',\n                left: { kind: 'var', ref: { name: 'status' } },\n                op: 'eq',\n                right: 'done',\n              },\n            },\n            {\n              label: 'in-progress',\n              condition: {\n                kind: 'compare',\n                left: { kind: 'var', ref: { name: 'status' } },\n                op: 'eq',\n                right: 'pending',\n              },\n            },\n          ],\n          elseLabel: 'unknown',\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n\n      expect(result.status).toBe('succeeded');\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'in-progress' });\n    });\n  });\n\n  describe('delay handler through adapter', () => {\n    it('adapts delay handler and executes', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(delayHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'delay-node',\n        tabId: 1,\n        vars: {},\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'delay-node',\n        kind: 'delay',\n        config: { sleep: 10 }, // delay handler uses 'sleep' param\n      };\n\n      const startTime = Date.now();\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n      const elapsed = Date.now() - startTime;\n\n      expect(result.status).toBe('succeeded');\n      expect(elapsed).toBeGreaterThanOrEqual(9); // Allow some tolerance\n    });\n\n    it('supports variable-based delay', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(delayHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'delay-node',\n        tabId: 1,\n        vars: { waitTime: 15 },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'delay-node',\n        kind: 'delay',\n        config: {\n          sleep: { kind: 'var', ref: { name: 'waitTime' } },\n        },\n      };\n\n      const startTime = Date.now();\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n      const elapsed = Date.now() - startTime;\n\n      expect(result.status).toBe('succeeded');\n      expect(elapsed).toBeGreaterThanOrEqual(14);\n    });\n  });\n\n  describe('Complex conditions', () => {\n    it('handles AND condition', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { a: true, b: true },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: {\n            kind: 'and',\n            conditions: [\n              { kind: 'truthy', value: { kind: 'var', ref: { name: 'a' } } },\n              { kind: 'truthy', value: { kind: 'var', ref: { name: 'b' } } },\n            ],\n          },\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'true' });\n    });\n\n    it('handles OR condition', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { a: false, b: true },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: {\n            kind: 'or',\n            conditions: [\n              { kind: 'truthy', value: { kind: 'var', ref: { name: 'a' } } },\n              { kind: 'truthy', value: { kind: 'var', ref: { name: 'b' } } },\n            ],\n          },\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'true' });\n    });\n\n    it('handles NOT condition', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const mockCtx = {\n        runId: 'run-1',\n        flow: { policy: {} } as any,\n        nodeId: 'if-node',\n        tabId: 1,\n        vars: { flag: false },\n        log: vi.fn(),\n        chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n        artifacts: { screenshot: vi.fn() },\n        persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n      };\n\n      const node = {\n        id: 'if-node',\n        kind: 'if',\n        config: {\n          mode: 'binary',\n          condition: {\n            kind: 'not',\n            condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'flag' } } },\n          },\n        },\n      };\n\n      const result = await nodeDef.execute(mockCtx as any, node as any);\n      expect(result.next).toEqual({ kind: 'edgeLabel', label: 'true' });\n    });\n\n    it('handles string comparison operators', async () => {\n      const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(ifHandler);\n\n      const testCases = [\n        { op: 'contains', value: 'hello world', right: 'world', expected: true },\n        { op: 'containsI', value: 'Hello World', right: 'WORLD', expected: true },\n        { op: 'startsWith', value: 'hello world', right: 'hello', expected: true },\n        { op: 'endsWith', value: 'hello world', right: 'world', expected: true },\n        { op: 'regex', value: 'test123', right: '\\\\d+', expected: true },\n      ];\n\n      for (const { op, value, right, expected } of testCases) {\n        const mockCtx = {\n          runId: 'run-1',\n          flow: { policy: {} } as any,\n          nodeId: 'if-node',\n          tabId: 1,\n          vars: { str: value },\n          log: vi.fn(),\n          chooseNext: (label: string) => ({ kind: 'edgeLabel' as const, label }),\n          artifacts: { screenshot: vi.fn() },\n          persistent: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },\n        };\n\n        const node = {\n          id: 'if-node',\n          kind: 'if',\n          config: {\n            mode: 'binary',\n            condition: {\n              kind: 'compare',\n              left: { kind: 'var', ref: { name: 'str' } },\n              op,\n              right,\n            },\n          },\n        };\n\n        const result = await nodeDef.execute(mockCtx as any, node as any);\n        const expectedLabel = expected ? 'true' : 'false';\n        expect(result.next).toEqual({ kind: 'edgeLabel', label: expectedLabel });\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/v2-to-v3-conversion.test.ts",
    "content": "/**\n * @fileoverview V2 to V3 Flow Conversion Tests\n * @description 测试 V2→V3 转换逻辑，特别是 entryNodeId 计算\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  convertFlowV2ToV3,\n  convertFlowV3ToV2,\n} from '@/entrypoints/background/record-replay-v3/storage/import/v2-to-v3';\n\n// ==================== Test Helpers ====================\n\nfunction createV2Flow(overrides: Partial<Parameters<typeof convertFlowV2ToV3>[0]> = {}) {\n  return {\n    id: 'test-flow',\n    name: 'Test Flow',\n    version: 2,\n    nodes: [],\n    edges: [],\n    ...overrides,\n  };\n}\n\n// ==================== entryNodeId Calculation Tests ====================\n\ndescribe('convertFlowV2ToV3 - entryNodeId calculation', () => {\n  describe('basic scenarios', () => {\n    it('selects the only executable node as entry', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [{ id: 'nav-1', type: 'navigate' }],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.data?.entryNodeId).toBe('nav-1');\n      expect(result.warnings).toHaveLength(0);\n    });\n\n    it('selects node with inDegree=0 as entry', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-1', type: 'navigate' },\n            { id: 'click-1', type: 'click' },\n          ],\n          edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.data?.entryNodeId).toBe('nav-1');\n    });\n  });\n\n  describe('trigger node handling', () => {\n    it('ignores trigger node when selecting entry', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'trigger-1', type: 'trigger' },\n            { id: 'nav-1', type: 'navigate' },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.data?.entryNodeId).toBe('nav-1');\n    });\n\n    it('ignores edges from trigger node when calculating inDegree', () => {\n      // Scenario: trigger → navigate → click\n      // Without this fix, navigate would have inDegree=1 and not be selected\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'trigger-1', type: 'trigger' },\n            { id: 'nav-1', type: 'navigate' },\n            { id: 'click-1', type: 'click' },\n          ],\n          edges: [\n            { id: 'e1', from: 'trigger-1', to: 'nav-1' },\n            { id: 'e2', from: 'nav-1', to: 'click-1' },\n          ],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // navigate should be entry because trigger edges are ignored\n      expect(result.data?.entryNodeId).toBe('nav-1');\n    });\n\n    it('returns error when only trigger nodes exist', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [{ id: 'trigger-1', type: 'trigger' }],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(false);\n      expect(result.errors).toContain('Could not determine entry node. No valid root node found.');\n    });\n  });\n\n  describe('multiple root nodes - stable selection', () => {\n    it('warns and selects by UI coordinates (leftmost, then topmost)', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-b', type: 'navigate', ui: { x: 200, y: 100 } },\n            { id: 'nav-a', type: 'navigate', ui: { x: 100, y: 200 } },\n            { id: 'nav-c', type: 'navigate', ui: { x: 100, y: 100 } },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // nav-c has smallest x, and smallest y at that x\n      expect(result.data?.entryNodeId).toBe('nav-c');\n      expect(result.warnings.length).toBeGreaterThan(0);\n      expect(result.warnings.some((w) => w.includes('Multiple inDegree=0'))).toBe(true);\n      expect(result.warnings.some((w) => w.includes('ui(x=100, y=100)'))).toBe(true);\n    });\n\n    it('selects by ID when no UI coordinates available', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-b', type: 'navigate' },\n            { id: 'nav-a', type: 'navigate' },\n            { id: 'nav-c', type: 'navigate' },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // nav-a comes first alphabetically\n      expect(result.data?.entryNodeId).toBe('nav-a');\n      expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);\n    });\n\n    it('uses UI for nodes that have it, ignoring nodes without UI', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-a', type: 'navigate' }, // no UI\n            { id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // nav-b has UI coordinates, so it's preferred\n      expect(result.data?.entryNodeId).toBe('nav-b');\n    });\n  });\n\n  describe('cycle detection', () => {\n    it('falls back using stable selection when graph has cycle (no inDegree=0)', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-1', type: 'navigate' },\n            { id: 'click-1', type: 'click' },\n          ],\n          edges: [\n            { id: 'e1', from: 'nav-1', to: 'click-1' },\n            { id: 'e2', from: 'click-1', to: 'nav-1' },\n          ],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.data?.entryNodeId).toBeTruthy();\n      expect(result.warnings.some((w) => w.includes('cycles'))).toBe(true);\n    });\n\n    it('uses stable selection (by id) for cycle fallback', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'z-node', type: 'navigate' },\n            { id: 'a-node', type: 'click' },\n          ],\n          edges: [\n            { id: 'e1', from: 'z-node', to: 'a-node' },\n            { id: 'e2', from: 'a-node', to: 'z-node' },\n          ],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // Should select 'a-node' as it comes first alphabetically\n      expect(result.data?.entryNodeId).toBe('a-node');\n      expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);\n    });\n\n    it('uses stable selection (by UI) for cycle fallback when UI available', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'a-node', type: 'navigate', ui: { x: 200, y: 100 } },\n            { id: 'z-node', type: 'click', ui: { x: 100, y: 100 } },\n          ],\n          edges: [\n            { id: 'e1', from: 'a-node', to: 'z-node' },\n            { id: 'e2', from: 'z-node', to: 'a-node' },\n          ],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // Should select 'z-node' as it has smaller x coordinate\n      expect(result.data?.entryNodeId).toBe('z-node');\n      expect(result.warnings.some((w) => w.includes('ui(x=100'))).toBe(true);\n    });\n  });\n\n  describe('UI coordinate edge cases', () => {\n    it('treats NaN coordinates as invalid UI', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-a', type: 'navigate', ui: { x: NaN, y: 100 } },\n            { id: 'nav-b', type: 'navigate' },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // Both nodes have no valid UI, should use ID sorting\n      expect(result.data?.entryNodeId).toBe('nav-a');\n      expect(result.warnings.some((w) => w.includes('by id'))).toBe(true);\n    });\n\n    it('treats Infinity coordinates as invalid UI', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-a', type: 'navigate', ui: { x: Infinity, y: 100 } },\n            { id: 'nav-b', type: 'navigate', ui: { x: 50, y: 50 } },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // Only nav-b has valid UI\n      expect(result.data?.entryNodeId).toBe('nav-b');\n    });\n\n    it('uses id as tie-breaker when UI coordinates are equal', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [\n            { id: 'nav-z', type: 'navigate', ui: { x: 100, y: 100 } },\n            { id: 'nav-a', type: 'navigate', ui: { x: 100, y: 100 } },\n          ],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(true);\n      // Same coordinates, should use ID as tie-breaker\n      expect(result.data?.entryNodeId).toBe('nav-a');\n    });\n  });\n\n  describe('empty and error cases', () => {\n    it('returns error when no nodes exist', () => {\n      const result = convertFlowV2ToV3(\n        createV2Flow({\n          nodes: [],\n          edges: [],\n        }),\n      );\n\n      expect(result.success).toBe(false);\n      expect(result.errors).toContain('V2 Flow has no nodes');\n    });\n  });\n});\n\n// ==================== Roundtrip Tests ====================\n\ndescribe('V2 <-> V3 roundtrip conversion', () => {\n  it('preserves basic flow structure through roundtrip', () => {\n    const original = createV2Flow({\n      name: 'Roundtrip Test',\n      description: 'Test description',\n      nodes: [\n        { id: 'nav-1', type: 'navigate', config: { url: 'https://example.com' } },\n        { id: 'click-1', type: 'click', config: { selector: '#btn' } },\n      ],\n      edges: [{ id: 'e1', from: 'nav-1', to: 'click-1' }],\n    });\n\n    const toV3 = convertFlowV2ToV3(original);\n    expect(toV3.success).toBe(true);\n\n    const backToV2 = convertFlowV3ToV2(toV3.data!);\n    expect(backToV2.success).toBe(true);\n\n    // Check structure preserved\n    expect(backToV2.data?.name).toBe(original.name);\n    expect(backToV2.data?.description).toBe(original.description);\n    expect(backToV2.data?.nodes).toHaveLength(2);\n    expect(backToV2.data?.edges).toHaveLength(1);\n  });\n\n  it('preserves node configs through roundtrip', () => {\n    const original = createV2Flow({\n      nodes: [\n        {\n          id: 'nav-1',\n          type: 'navigate',\n          name: 'Go to site',\n          disabled: true,\n          config: { url: 'https://example.com', waitUntil: 'load' },\n          ui: { x: 100, y: 200 },\n        },\n      ],\n      edges: [],\n    });\n\n    const toV3 = convertFlowV2ToV3(original);\n    const backToV2 = convertFlowV3ToV2(toV3.data!);\n\n    const node = backToV2.data?.nodes?.[0];\n    expect(node?.type).toBe('navigate');\n    expect(node?.name).toBe('Go to site');\n    expect(node?.disabled).toBe(true);\n    expect(node?.config).toEqual({ url: 'https://example.com', waitUntil: 'load' });\n    expect(node?.ui).toEqual({ x: 100, y: 200 });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/record-replay-v3/v3-e2e-harness.ts",
    "content": "/**\n * @fileoverview Record-Replay V3 service-level E2E test harness\n * @description\n * Assembles a complete V3 runtime (IndexedDB storage + scheduler + runner)\n * and drives it through RpcServer.handleRequest() to avoid Port mocking complexity.\n *\n * Design notes:\n * - Service-level testing: calls internal handler directly, not through Port\n * - Event streaming: reuses RpcServer.broadcastEvent subscription filtering logic\n * - waitForTerminal: uses EventsBus subscription to wait for terminal events, avoiding kick() race\n *\n * WARNING: This harness accesses RpcServer private members (connections/handleRequest/broadcastEvent)\n * via type casting. If RpcServer changes to use ES private fields (#private), these tests will break.\n * All such access is centralized in getRpcServerInternals() for easier maintenance.\n */\n\nimport { vi } from 'vitest';\nimport { z } from 'zod';\n\nimport type { JsonObject } from '@/entrypoints/background/record-replay-v3/domain/json';\nimport type { RunId } from '@/entrypoints/background/record-replay-v3/domain/ids';\nimport type {\n  RunEvent,\n  RunRecordV3,\n} from '@/entrypoints/background/record-replay-v3/domain/events';\nimport type {\n  RunQueueConfig,\n  RunQueueItem,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';\nimport type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';\nimport type {\n  RunScheduler,\n  RunExecutor,\n} from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\nimport type {\n  NodeDefinition,\n  NodeExecutionResult,\n} from '@/entrypoints/background/record-replay-v3/engine/plugins/types';\n\nimport { createStoragePort, closeRrV3Db } from '@/entrypoints/background/record-replay-v3';\n\nimport { StorageBackedEventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';\nimport { DEFAULT_QUEUE_CONFIG } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';\nimport { createLeaseManager } from '@/entrypoints/background/record-replay-v3/engine/queue/leasing';\nimport { createRunScheduler } from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';\nimport { InMemoryKeepaliveController } from '@/entrypoints/background/record-replay-v3/engine/keepalive/offscreen-keepalive';\nimport { PluginRegistry } from '@/entrypoints/background/record-replay-v3/engine/plugins/registry';\nimport {\n  createRunRunnerFactory,\n  type RunRunnerFactory,\n} from '@/entrypoints/background/record-replay-v3/engine/kernel/runner';\nimport {\n  createRunnerRegistry,\n  type RunnerRegistry,\n} from '@/entrypoints/background/record-replay-v3/engine/kernel/debug-controller';\nimport { createNotImplementedArtifactService } from '@/entrypoints/background/record-replay-v3/engine/kernel/artifacts';\nimport { RpcServer } from '@/entrypoints/background/record-replay-v3/engine/transport/rpc-server';\nimport {\n  RR_ERROR_CODES,\n  createRRError,\n} from '@/entrypoints/background/record-replay-v3/domain/errors';\nimport { isTerminalStatus } from '@/entrypoints/background/record-replay-v3/domain/events';\n\n// ==================== Types ====================\n\ntype Logger = Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;\n\ninterface TestNodeConfig {\n  action: 'succeed' | 'fail';\n  outputs?: JsonObject;\n  delayMs?: number;\n}\n\n/**\n * E2E Harness 配置选项\n */\nexport interface V3E2EHarnessOptions {\n  /** Owner ID（标识调度器实例） */\n  ownerId?: string;\n  /** 调度器配置覆盖 */\n  schedulerConfig?: Partial<RunQueueConfig>;\n  /** 是否自动启动调度器（默认 true） */\n  autoStartScheduler?: boolean;\n  /** 时间源（用于测试注入） */\n  now?: () => number;\n  /** 日志器 */\n  logger?: Logger;\n}\n\n/**\n * RPC 客户端接口\n */\nexport interface RpcClient {\n  /** 收到的所有消息 */\n  readonly messages: unknown[];\n  /** 调用 RPC 方法 */\n  call<T = unknown>(method: string, params?: JsonObject): Promise<T>;\n  /** 清空消息 */\n  clearMessages(): void;\n  /** 获取流式推送的事件 */\n  getStreamedEvents(): RunEvent[];\n}\n\n/**\n * E2E Harness 接口\n */\nexport interface V3E2EHarness {\n  readonly ownerId: string;\n  readonly storage: StoragePort;\n  readonly events: EventsBus;\n  readonly scheduler: RunScheduler;\n  readonly runners: RunnerRegistry;\n  readonly rpcServer: RpcServer;\n\n  /** 创建 RPC 客户端 */\n  createClient(): RpcClient;\n\n  /** 等待特定事件 */\n  waitForEvent(\n    runId: RunId,\n    predicate: (event: RunEvent) => boolean,\n    opts?: { timeoutMs?: number },\n  ): Promise<RunEvent>;\n\n  /** 等待 Run 到达终态 */\n  waitForTerminal(runId: RunId, opts?: { timeoutMs?: number }): Promise<RunRecordV3>;\n\n  /** 等待队列项被移除 */\n  waitForQueueItemGone(runId: RunId, opts?: { timeoutMs?: number }): Promise<void>;\n\n  /** 列出 Run 的所有事件 */\n  listEvents(runId: RunId): Promise<RunEvent[]>;\n\n  /** 销毁 harness，释放资源 */\n  dispose(): Promise<void>;\n}\n\n// ==================== RpcServer Test Internals ====================\n\n/**\n * RpcServer internal access interface for testing.\n * Centralizes all private member access to make maintenance easier.\n */\ninterface RpcServerInternals {\n  connections: Map<string, RpcConnection>;\n  handleRequest<T>(req: unknown, conn: RpcConnection): Promise<T>;\n  broadcastEvent(event: RunEvent): void;\n}\n\ninterface RpcConnection {\n  port: chrome.runtime.Port;\n  subscriptions: Set<RunId | null>;\n}\n\n/**\n * Get RpcServer internals for testing.\n * WARNING: This accesses private members via type casting.\n */\nfunction getRpcServerInternals(server: RpcServer): RpcServerInternals {\n  const s = server as unknown as {\n    connections: Map<string, RpcConnection>;\n    handleRequest: <T>(req: unknown, conn: RpcConnection) => Promise<T>;\n    broadcastEvent: (event: RunEvent) => void;\n  };\n  return {\n    connections: s.connections,\n    handleRequest: s.handleRequest.bind(s),\n    broadcastEvent: s.broadcastEvent.bind(s),\n  };\n}\n\n// ==================== Utilities ====================\n\nfunction createSilentLogger(): Logger {\n  return {\n    debug: () => {},\n    info: () => {},\n    warn: () => {},\n    error: () => {},\n  };\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * 创建测试用 Node 定义\n * @description 一个简单的测试节点，支持成功/失败/延迟\n */\nfunction createTestNodeDefinition(): NodeDefinition<'test', TestNodeConfig> {\n  return {\n    kind: 'test',\n    schema: z\n      .object({\n        action: z.enum(['succeed', 'fail']),\n        outputs: z.record(z.any()).optional(),\n        delayMs: z.number().optional(),\n      })\n      .passthrough() as z.ZodType<TestNodeConfig>,\n    execute: async (_ctx, node): Promise<NodeExecutionResult> => {\n      const cfg = node.config as unknown as TestNodeConfig;\n\n      // 模拟延迟\n      if (cfg.delayMs && cfg.delayMs > 0) {\n        await sleep(cfg.delayMs);\n      }\n\n      if (cfg.action === 'fail') {\n        return {\n          status: 'failed',\n          error: createRRError(RR_ERROR_CODES.TOOL_ERROR, 'Test node intentionally failed'),\n        };\n      }\n\n      return {\n        status: 'succeeded',\n        ...(cfg.outputs ? { outputs: cfg.outputs } : {}),\n      };\n    },\n  };\n}\n\n// ==================== Factory ====================\n\n/**\n * 创建 V3 E2E 测试 harness\n * @description 组装完整的 V3 runtime 用于集成测试\n */\nexport function createV3E2EHarness(options: V3E2EHarnessOptions = {}): V3E2EHarness {\n  const logger = options.logger ?? createSilentLogger();\n  const now = options.now ?? (() => Date.now());\n  const ownerId = options.ownerId ?? 'e2e-owner';\n\n  // 1) Storage\n  const storage = createStoragePort();\n\n  // 2) EventsBus\n  const events = new StorageBackedEventsBus(storage.events);\n\n  // 3) Plugins - 注册测试节点\n  const plugins = new PluginRegistry();\n  plugins.registerNode(createTestNodeDefinition());\n\n  // 4) RunnerRegistry\n  const runners = createRunnerRegistry();\n\n  // 5) RunRunnerFactory\n  const runnerFactory = createRunRunnerFactory({\n    storage,\n    events,\n    plugins,\n    now,\n    artifactService: createNotImplementedArtifactService(),\n  });\n\n  // 6) RunExecutor - 连接 scheduler 和 runner\n  const execute: RunExecutor = createE2EExecutor({\n    storage,\n    events,\n    runnerFactory,\n    runners,\n    now,\n    logger,\n  });\n\n  // 7) Scheduler 配置\n  const config: RunQueueConfig = {\n    ...DEFAULT_QUEUE_CONFIG,\n    maxParallelRuns: 1,\n    ...options.schedulerConfig,\n  };\n\n  // 8) Keepalive + LeaseManager + Scheduler\n  const keepalive = new InMemoryKeepaliveController();\n  const leaseManager = createLeaseManager(storage.queue, config);\n\n  const scheduler = createRunScheduler({\n    queue: storage.queue,\n    leaseManager,\n    keepalive,\n    config,\n    ownerId,\n    execute,\n    now,\n    tuning: { pollIntervalMs: 0, reclaimIntervalMs: 0 },\n    logger,\n  });\n\n  // 9) RpcServer\n  const rpcServer = new RpcServer({\n    storage,\n    events,\n    scheduler,\n    runners,\n    now,\n  });\n\n  // Get internals via centralized helper\n  const rpcInternals = getRpcServerInternals(rpcServer);\n\n  // 10) Forward EventsBus events to RpcServer.broadcastEvent\n  const unsubscribeForward = events.subscribe((event) => {\n    try {\n      rpcInternals.broadcastEvent(event);\n    } catch (e) {\n      logger.warn('[V3E2EHarness] broadcastEvent failed:', e);\n    }\n  });\n\n  // 11) Start scheduler if configured\n  if (options.autoStartScheduler ?? true) {\n    scheduler.start();\n  }\n\n  // Client management\n  let clientSeq = 0;\n  let requestSeq = 0;\n  const clientConnIds = new Set<string>();\n\n  function createClient(): RpcClient {\n    const connId = `e2e-conn-${++clientSeq}`;\n    const messages: unknown[] = [];\n\n    const port = {\n      postMessage: (msg: unknown) => {\n        messages.push(msg);\n      },\n      disconnect: vi.fn(),\n    } as unknown as chrome.runtime.Port;\n\n    const connection: RpcConnection = {\n      port,\n      subscriptions: new Set<RunId | null>(),\n    };\n\n    // Inject into RpcServer internals so broadcastEvent() can push to this client\n    rpcInternals.connections.set(connId, connection);\n    clientConnIds.add(connId);\n\n    return {\n      messages,\n      call: async <T>(method: string, params?: JsonObject): Promise<T> => {\n        const req = {\n          type: 'rr_v3.request' as const,\n          requestId: `e2e-req-${++requestSeq}`,\n          method,\n          ...(params ? { params } : {}),\n        };\n        return rpcInternals.handleRequest<T>(req, connection);\n      },\n      clearMessages: () => {\n        messages.splice(0, messages.length);\n      },\n      getStreamedEvents: () => {\n        return messages\n          .filter(\n            (m): m is { type: 'rr_v3.event'; event: RunEvent } =>\n              typeof m === 'object' &&\n              m !== null &&\n              (m as { type?: string }).type === 'rr_v3.event',\n          )\n          .map((m) => m.event);\n      },\n    };\n  }\n\n  async function waitForEvent(\n    runId: RunId,\n    predicate: (event: RunEvent) => boolean,\n    opts?: { timeoutMs?: number },\n  ): Promise<RunEvent> {\n    const timeoutMs = opts?.timeoutMs ?? 5_000;\n\n    // Fast-path: 检查已持久化的事件\n    try {\n      const existing = await storage.events.list(runId);\n      const found = existing.find(predicate);\n      if (found) return found;\n    } catch {\n      // ignore and fall back to subscription\n    }\n\n    return new Promise<RunEvent>((resolve, reject) => {\n      const timer = setTimeout(() => {\n        unsubscribe();\n        reject(new Error(`Timed out waiting for event (runId=${runId})`));\n      }, timeoutMs);\n\n      const unsubscribe = events.subscribe(\n        (event) => {\n          if (!predicate(event)) return;\n          clearTimeout(timer);\n          unsubscribe();\n          resolve(event);\n        },\n        { runId },\n      );\n    });\n  }\n\n  async function waitForTerminal(\n    runId: RunId,\n    opts?: { timeoutMs?: number },\n  ): Promise<RunRecordV3> {\n    const timeoutMs = opts?.timeoutMs ?? 10_000;\n\n    // 先检查当前状态\n    const initial = await storage.runs.get(runId);\n    if (!initial) {\n      throw new Error(`Run \"${runId}\" not found`);\n    }\n    if (isTerminalStatus(initial.status)) {\n      return initial;\n    }\n\n    // 等待终态事件\n    await waitForEvent(\n      runId,\n      (e) => e.type === 'run.succeeded' || e.type === 'run.failed' || e.type === 'run.canceled',\n      { timeoutMs },\n    );\n\n    const done = await storage.runs.get(runId);\n    if (!done) {\n      throw new Error(`Run \"${runId}\" not found after terminal event`);\n    }\n    return done;\n  }\n\n  async function waitForQueueItemGone(runId: RunId, opts?: { timeoutMs?: number }): Promise<void> {\n    const timeoutMs = opts?.timeoutMs ?? 5_000;\n    const startedAt = Date.now();\n\n    for (;;) {\n      const item = await storage.queue.get(runId);\n      if (!item) return;\n\n      if (Date.now() - startedAt >= timeoutMs) {\n        throw new Error(\n          `Timed out waiting for queue item to be removed (runId=${runId}, status=${item.status})`,\n        );\n      }\n\n      await sleep(10);\n    }\n  }\n\n  async function listEvents(runId: RunId): Promise<RunEvent[]> {\n    return storage.events.list(runId);\n  }\n\n  async function dispose(): Promise<void> {\n    // 取消事件转发\n    try {\n      unsubscribeForward();\n    } catch {\n      // ignore\n    }\n\n    // 停止 scheduler\n    try {\n      scheduler.dispose();\n    } catch {\n      // ignore\n    }\n\n    // 释放 lease manager\n    try {\n      leaseManager.dispose();\n    } catch {\n      // ignore\n    }\n\n    // Remove injected client connections\n    for (const connId of clientConnIds) {\n      rpcInternals.connections.delete(connId);\n    }\n    clientConnIds.clear();\n\n    // 关闭 IDB 连接\n    closeRrV3Db();\n  }\n\n  return {\n    ownerId,\n    storage,\n    events,\n    scheduler,\n    runners,\n    rpcServer,\n    createClient,\n    waitForEvent,\n    waitForTerminal,\n    waitForQueueItemGone,\n    listEvents,\n    dispose,\n  };\n}\n\n// ==================== Internal Helpers ====================\n\n/**\n * 创建 E2E 测试用的 RunExecutor\n */\nfunction createE2EExecutor(deps: {\n  storage: StoragePort;\n  events: EventsBus;\n  runnerFactory: RunRunnerFactory;\n  runners: RunnerRegistry;\n  now: () => number;\n  logger: Logger;\n}): RunExecutor {\n  return async (item: RunQueueItem): Promise<void> => {\n    const runId = item.id;\n\n    // 1. 获取 RunRecord\n    const run = await deps.storage.runs.get(runId);\n    if (!run) {\n      deps.logger.warn(`[E2E] RunRecord not found for queue item \"${runId}\", skipping`);\n      return;\n    }\n\n    // 2. 获取 Flow\n    const flow = await deps.storage.flows.get(item.flowId);\n    if (!flow) {\n      await failRun(deps, runId, `Flow \"${item.flowId}\" not found`);\n      return;\n    }\n\n    // 3. 同步 attempt/tabId 到 RunRecord\n    const tabId = item.tabId ?? run.tabId ?? 1;\n    try {\n      await deps.storage.runs.patch(runId, {\n        attempt: item.attempt,\n        maxAttempts: item.maxAttempts,\n        tabId,\n      });\n    } catch {\n      // ignore\n    }\n\n    // 4. 创建并运行 Runner\n    const runner = deps.runnerFactory.create(runId, {\n      flow,\n      tabId,\n      args: item.args ?? run.args,\n      startNodeId: run.startNodeId,\n      debug: item.debug ?? run.debug,\n    });\n\n    deps.runners.register(runId, runner);\n    try {\n      await runner.start();\n    } finally {\n      deps.runners.unregister(runId);\n    }\n  };\n}\n\n/**\n * 将 Run 标记为失败\n */\nasync function failRun(\n  deps: { storage: StoragePort; events: EventsBus; now: () => number },\n  runId: RunId,\n  message: string,\n): Promise<void> {\n  const t = deps.now();\n  const error = createRRError(RR_ERROR_CODES.VALIDATION_ERROR, message);\n\n  await deps.storage.runs.patch(runId, {\n    status: 'failed',\n    finishedAt: t,\n    tookMs: 0,\n    error,\n  });\n\n  await deps.events.append({\n    runId,\n    type: 'run.failed',\n    error,\n  });\n}\n"
  },
  {
    "path": "app/chrome-extension/tests/vitest.setup.ts",
    "content": "/**\n * @fileoverview Vitest Global Setup\n * @description Provides global configuration and polyfills for test environment\n */\n\nimport { vi } from 'vitest';\n\n// Provide IndexedDB globals (jsdom doesn't include them)\nimport 'fake-indexeddb/auto';\n\n// Mock chrome API (basic placeholder)\nif (typeof globalThis.chrome === 'undefined') {\n  (globalThis as unknown as { chrome: object }).chrome = {\n    runtime: {\n      id: 'test-extension-id',\n      sendMessage: vi.fn().mockResolvedValue(undefined),\n      onMessage: {\n        addListener: vi.fn(),\n        removeListener: vi.fn(),\n      },\n      connect: vi.fn().mockReturnValue({\n        onMessage: { addListener: vi.fn(), removeListener: vi.fn() },\n        onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() },\n        postMessage: vi.fn(),\n        disconnect: vi.fn(),\n      }),\n    },\n    storage: {\n      local: {\n        get: vi.fn().mockResolvedValue({}),\n        set: vi.fn().mockResolvedValue(undefined),\n        remove: vi.fn().mockResolvedValue(undefined),\n      },\n    },\n    tabs: {\n      query: vi.fn().mockResolvedValue([]),\n      get: vi.fn().mockResolvedValue(null),\n      create: vi.fn().mockResolvedValue({ id: 1 }),\n      update: vi.fn().mockResolvedValue({}),\n      remove: vi.fn().mockResolvedValue(undefined),\n      captureVisibleTab: vi.fn().mockResolvedValue('data:image/png;base64,'),\n      onRemoved: { addListener: vi.fn(), removeListener: vi.fn() },\n      onCreated: { addListener: vi.fn(), removeListener: vi.fn() },\n      onUpdated: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n    webRequest: {\n      onBeforeRequest: { addListener: vi.fn(), removeListener: vi.fn() },\n      onCompleted: { addListener: vi.fn(), removeListener: vi.fn() },\n      onErrorOccurred: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n    webNavigation: {\n      onCommitted: { addListener: vi.fn(), removeListener: vi.fn() },\n      onDOMContentLoaded: { addListener: vi.fn(), removeListener: vi.fn() },\n      onCompleted: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n    debugger: {\n      onEvent: { addListener: vi.fn(), removeListener: vi.fn() },\n      onDetach: { addListener: vi.fn(), removeListener: vi.fn() },\n      attach: vi.fn().mockResolvedValue(undefined),\n      detach: vi.fn().mockResolvedValue(undefined),\n      sendCommand: vi.fn().mockResolvedValue({}),\n    },\n    commands: {\n      onCommand: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n    contextMenus: {\n      create: vi.fn(),\n      remove: vi.fn(),\n      onClicked: { addListener: vi.fn(), removeListener: vi.fn() },\n    },\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/design-tokens.test.ts",
    "content": "/**\n * Unit tests for Design Tokens Module (Phase 5.4)\n *\n * Tests cover:\n * - token-resolver: var() parsing and formatting\n * - token-detector: CSSOM scanning (mocked)\n * - design-tokens-service: caching and query\n *\n * Note: jsdom doesn't have full CSSOM support, so we mock stylesheet APIs.\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  createTokenResolver,\n  createTokenDetector,\n  createDesignTokensService,\n  type CssVarName,\n} from '@/entrypoints/web-editor-v2/core/design-tokens';\n\n// =============================================================================\n// Test Setup\n// =============================================================================\n\nbeforeEach(() => {\n  document.body.innerHTML = '';\n  vi.restoreAllMocks();\n});\n\nafterEach(() => {\n  vi.restoreAllMocks();\n});\n\n// =============================================================================\n// Token Resolver Tests\n// =============================================================================\n\ndescribe('token-resolver: formatCssVar', () => {\n  it('formats simple var() without fallback', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.formatCssVar('--color-primary')).toBe('var(--color-primary)');\n  });\n\n  it('formats var() with fallback', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.formatCssVar('--color-primary', 'blue')).toBe('var(--color-primary, blue)');\n  });\n\n  it('trims fallback whitespace', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.formatCssVar('--spacing', '  16px  ')).toBe('var(--spacing, 16px)');\n  });\n\n  it('ignores empty fallback', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.formatCssVar('--x', '')).toBe('var(--x)');\n    expect(resolver.formatCssVar('--x', '   ')).toBe('var(--x)');\n  });\n});\n\ndescribe('token-resolver: parseCssVar', () => {\n  it('parses simple var()', () => {\n    const resolver = createTokenResolver();\n    const result = resolver.parseCssVar('var(--color)');\n    expect(result).toEqual({ name: '--color' });\n  });\n\n  it('parses var() with fallback', () => {\n    const resolver = createTokenResolver();\n    const result = resolver.parseCssVar('var(--color, blue)');\n    expect(result).toEqual({ name: '--color', fallback: 'blue' });\n  });\n\n  it('parses var() with complex fallback', () => {\n    const resolver = createTokenResolver();\n    const result = resolver.parseCssVar('var(--color, rgba(0, 0, 0, 0.5))');\n    expect(result).toEqual({ name: '--color', fallback: 'rgba(0, 0, 0, 0.5)' });\n  });\n\n  it('parses var() with nested var() fallback', () => {\n    const resolver = createTokenResolver();\n    const result = resolver.parseCssVar('var(--color, var(--fallback))');\n    expect(result).toEqual({ name: '--color', fallback: 'var(--fallback)' });\n  });\n\n  it('returns null for non-var values', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.parseCssVar('blue')).toBeNull();\n    expect(resolver.parseCssVar('rgb(0,0,0)')).toBeNull();\n    expect(resolver.parseCssVar('')).toBeNull();\n    expect(resolver.parseCssVar('  ')).toBeNull();\n  });\n\n  it('returns null for invalid var()', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.parseCssVar('var()')).toBeNull();\n    expect(resolver.parseCssVar('var(invalid)')).toBeNull(); // No -- prefix\n    expect(resolver.parseCssVar('var(--')).toBeNull(); // Unclosed\n  });\n\n  it('handles whitespace in var()', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.parseCssVar('  var(  --x  )  ')).toEqual({ name: '--x' });\n    expect(resolver.parseCssVar('var( --x , blue )')).toEqual({\n      name: '--x',\n      fallback: 'blue',\n    });\n  });\n});\n\ndescribe('token-resolver: extractCssVarNames', () => {\n  it('extracts single var() reference', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.extractCssVarNames('var(--color)')).toEqual(['--color']);\n  });\n\n  it('extracts multiple var() references', () => {\n    const resolver = createTokenResolver();\n    const names = resolver.extractCssVarNames('calc(var(--a) + var(--b)) var(--c)');\n    expect(names).toEqual(['--a', '--b', '--c']);\n  });\n\n  it('returns empty array for no vars', () => {\n    const resolver = createTokenResolver();\n    expect(resolver.extractCssVarNames('blue')).toEqual([]);\n    expect(resolver.extractCssVarNames('')).toEqual([]);\n  });\n\n  it('handles nested var() in fallback', () => {\n    const resolver = createTokenResolver();\n    // Only extracts top-level names (regex limitation, but good enough for Phase 5.4)\n    const names = resolver.extractCssVarNames('var(--color, var(--fallback))');\n    expect(names).toContain('--color');\n    expect(names).toContain('--fallback');\n  });\n});\n\ndescribe('token-resolver: readComputedValue', () => {\n  it('reads custom property from element', () => {\n    const div = document.createElement('div');\n    div.style.setProperty('--test-color', 'red');\n    document.body.append(div);\n\n    const resolver = createTokenResolver();\n    // Note: jsdom may not fully support computed custom properties\n    // This test verifies the API works without errors\n    const value = resolver.readComputedValue(div, '--test-color');\n    // jsdom returns empty for custom props, but in real browser it would work\n    expect(typeof value).toBe('string');\n  });\n\n  it('returns empty string for unset property', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    const resolver = createTokenResolver();\n    expect(resolver.readComputedValue(div, '--nonexistent')).toBe('');\n  });\n});\n\ndescribe('token-resolver: resolveToken', () => {\n  it('returns available for set token', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    // Mock getComputedStyle\n    vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n      getPropertyValue: (name: string) => (name === '--color' ? 'red' : ''),\n    } as CSSStyleDeclaration);\n\n    const resolver = createTokenResolver();\n    const result = resolver.resolveToken(div, '--color');\n\n    expect(result.token).toBe('--color');\n    expect(result.computedValue).toBe('red');\n    expect(result.availability).toBe('available');\n  });\n\n  it('returns unset for missing token', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n      getPropertyValue: () => '',\n    } as CSSStyleDeclaration);\n\n    const resolver = createTokenResolver();\n    const result = resolver.resolveToken(div, '--missing');\n\n    expect(result.availability).toBe('unset');\n  });\n});\n\ndescribe('token-resolver: resolveTokenForProperty', () => {\n  it('builds CSS value for property', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    const resolver = createTokenResolver();\n    const result = resolver.resolveTokenForProperty(div, '--color', 'color');\n\n    expect(result.token).toBe('--color');\n    expect(result.cssProperty).toBe('color');\n    expect(result.cssValue).toBe('var(--color)');\n    expect(result.method).toBe('computed');\n  });\n\n  it('includes fallback in CSS value', () => {\n    const div = document.createElement('div');\n\n    const resolver = createTokenResolver();\n    const result = resolver.resolveTokenForProperty(div, '--color', 'background-color', {\n      fallback: 'white',\n    });\n\n    expect(result.cssValue).toBe('var(--color, white)');\n  });\n});\n\n// =============================================================================\n// Token Detector Tests\n// =============================================================================\n\ndescribe('token-detector: collectInlineTokenNames', () => {\n  it('collects token names from element inline style', () => {\n    const div = document.createElement('div');\n    div.style.setProperty('--custom-var', '10px');\n    div.style.setProperty('color', 'red'); // Regular property, should be ignored\n    document.body.append(div);\n\n    const detector = createTokenDetector();\n    const names = detector.collectInlineTokenNames(div);\n\n    expect(names.has('--custom-var' as CssVarName)).toBe(true);\n    expect(names.size).toBe(1);\n  });\n\n  it('collects from ancestor chain', () => {\n    const parent = document.createElement('div');\n    parent.style.setProperty('--parent-var', '20px');\n\n    const child = document.createElement('div');\n    child.style.setProperty('--child-var', '10px');\n\n    parent.append(child);\n    document.body.append(parent);\n\n    const detector = createTokenDetector();\n    const names = detector.collectInlineTokenNames(child);\n\n    expect(names.has('--parent-var' as CssVarName)).toBe(true);\n    expect(names.has('--child-var' as CssVarName)).toBe(true);\n  });\n\n  it('respects maxDepth option', () => {\n    const grandparent = document.createElement('div');\n    grandparent.style.setProperty('--grandparent-var', '30px');\n\n    const parent = document.createElement('div');\n    parent.style.setProperty('--parent-var', '20px');\n\n    const child = document.createElement('div');\n    child.style.setProperty('--child-var', '10px');\n\n    grandparent.append(parent);\n    parent.append(child);\n    document.body.append(grandparent);\n\n    const detector = createTokenDetector();\n    const names = detector.collectInlineTokenNames(child, { maxDepth: 1 });\n\n    // Should only include child and parent (depth 0 and 1)\n    expect(names.has('--child-var' as CssVarName)).toBe(true);\n    expect(names.has('--parent-var' as CssVarName)).toBe(true);\n    expect(names.has('--grandparent-var' as CssVarName)).toBe(false);\n  });\n\n  it('returns empty set for element without custom props', () => {\n    const div = document.createElement('div');\n    div.style.color = 'red';\n    document.body.append(div);\n\n    const detector = createTokenDetector();\n    const names = detector.collectInlineTokenNames(div);\n\n    expect(names.size).toBe(0);\n  });\n});\n\ndescribe('token-detector: collectRootIndex', () => {\n  it('returns empty index when no stylesheets', () => {\n    // jsdom has empty styleSheets by default\n    const detector = createTokenDetector();\n    const index = detector.collectRootIndex(document);\n\n    expect(index.rootType).toBe('document');\n    expect(index.tokens.size).toBe(0);\n    expect(index.warnings).toEqual([]);\n    expect(index.stats.styleSheets).toBeGreaterThanOrEqual(0);\n  });\n\n  it('handles missing styleSheets gracefully', () => {\n    const detector = createTokenDetector();\n\n    // Mock document with no styleSheets\n    const mockRoot = {\n      styleSheets: null,\n      adoptedStyleSheets: undefined,\n    } as unknown as Document;\n\n    const index = detector.collectRootIndex(mockRoot);\n    expect(index.tokens.size).toBe(0);\n  });\n});\n\n// =============================================================================\n// Design Tokens Service Tests\n// =============================================================================\n\ndescribe('design-tokens-service: basic operations', () => {\n  it('creates service successfully', () => {\n    const service = createDesignTokensService();\n    expect(service).toBeDefined();\n    expect(typeof service.getRootTokens).toBe('function');\n    expect(typeof service.getContextTokens).toBe('function');\n    service.dispose();\n  });\n\n  it('getRootTokens returns empty for document without tokens', () => {\n    const service = createDesignTokensService();\n    const result = service.getRootTokens(document);\n\n    expect(result.tokens).toEqual([]);\n    expect(result.warnings).toBeDefined();\n    expect(result.stats).toBeDefined();\n\n    service.dispose();\n  });\n\n  it('getContextTokens filters to available tokens', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    // Mock to return no tokens\n    vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n      getPropertyValue: () => '',\n    } as CSSStyleDeclaration);\n\n    const service = createDesignTokensService();\n    const result = service.getContextTokens(div);\n\n    // Should be empty since no tokens resolve\n    expect(result.tokens).toEqual([]);\n\n    service.dispose();\n  });\n});\n\ndescribe('design-tokens-service: utility methods', () => {\n  it('formatCssVar delegates to resolver', () => {\n    const service = createDesignTokensService();\n    expect(service.formatCssVar('--x')).toBe('var(--x)');\n    expect(service.formatCssVar('--x', 'y')).toBe('var(--x, y)');\n    service.dispose();\n  });\n\n  it('parseCssVar delegates to resolver', () => {\n    const service = createDesignTokensService();\n    expect(service.parseCssVar('var(--x)')).toEqual({ name: '--x' });\n    expect(service.parseCssVar('invalid')).toBeNull();\n    service.dispose();\n  });\n\n  it('extractCssVarNames delegates to resolver', () => {\n    const service = createDesignTokensService();\n    expect(service.extractCssVarNames('var(--a) var(--b)')).toEqual(['--a', '--b']);\n    service.dispose();\n  });\n});\n\ndescribe('design-tokens-service: cache invalidation', () => {\n  it('invalidateRoot clears cache and emits event', () => {\n    const service = createDesignTokensService();\n    const handler = vi.fn();\n\n    service.onInvalidation(handler);\n    service.invalidateRoot(document, 'manual');\n\n    expect(handler).toHaveBeenCalledTimes(1);\n    expect(handler).toHaveBeenCalledWith(\n      expect.objectContaining({\n        root: document,\n        rootType: 'document',\n        reason: 'manual',\n      }),\n    );\n\n    service.dispose();\n  });\n\n  it('onInvalidation returns unsubscribe function', () => {\n    const service = createDesignTokensService();\n    const handler = vi.fn();\n\n    const unsubscribe = service.onInvalidation(handler);\n    service.invalidateRoot(document);\n    expect(handler).toHaveBeenCalledTimes(1);\n\n    unsubscribe();\n    service.invalidateRoot(document);\n    expect(handler).toHaveBeenCalledTimes(1); // Not called again\n\n    service.dispose();\n  });\n});\n\ndescribe('design-tokens-service: dispose', () => {\n  it('can be called multiple times safely', () => {\n    const service = createDesignTokensService();\n    expect(() => {\n      service.dispose();\n      service.dispose();\n    }).not.toThrow();\n  });\n\n  it('clears invalidation listeners on dispose', () => {\n    const service = createDesignTokensService();\n    const handler = vi.fn();\n\n    service.onInvalidation(handler);\n    service.dispose();\n\n    // After dispose, invalidation shouldn't call handler\n    // (but we can't easily test this without exposing internals)\n    expect(handler).not.toHaveBeenCalled();\n  });\n});\n\ndescribe('design-tokens-service: resolveToken', () => {\n  it('resolves token for element', () => {\n    const div = document.createElement('div');\n    document.body.append(div);\n\n    vi.spyOn(window, 'getComputedStyle').mockReturnValue({\n      getPropertyValue: (name: string) => (name === '--color' ? '#ff0000' : ''),\n    } as CSSStyleDeclaration);\n\n    const service = createDesignTokensService();\n    const result = service.resolveToken(div, '--color');\n\n    expect(result.token).toBe('--color');\n    expect(result.computedValue).toBe('#ff0000');\n    expect(result.availability).toBe('available');\n\n    service.dispose();\n  });\n});\n\ndescribe('design-tokens-service: resolveTokenForProperty', () => {\n  it('builds CSS value for applying token', () => {\n    const div = document.createElement('div');\n\n    const service = createDesignTokensService();\n    const result = service.resolveTokenForProperty(div, '--spacing', 'padding', {\n      fallback: '8px',\n    });\n\n    expect(result.cssValue).toBe('var(--spacing, 8px)');\n    expect(result.cssProperty).toBe('padding');\n\n    service.dispose();\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/drag-reorder-controller.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 Drag Reorder Controller.\n *\n * These tests focus on the container axis detection and side calculation:\n * - Flex row support (Bug 2 fix)\n * - Reverse layout handling\n * - Insertion line direction\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport type { RestoreFn } from './test-utils/dom';\nimport { mockBoundingClientRect, mockGetComputedStyle } from './test-utils/dom';\n\n// =============================================================================\n// Test Utilities\n// =============================================================================\n\n// Import the internal functions we want to test\n// Since they're not exported, we'll test through the public API behavior\n// For unit testing internal logic, we can create a separate test module\n\n/**\n * Helper to determine container axis from computed style.\n * This mirrors the internal getContainerAxis logic for testing.\n */\nfunction getContainerAxisFromStyle(style: {\n  display: string;\n  flexDirection?: string;\n  flexWrap?: string;\n}): { axis: 'x' | 'y'; reverse: boolean } | null {\n  const { display, flexDirection, flexWrap } = style;\n\n  // Reject grid\n  if (display === 'grid' || display === 'inline-grid') return null;\n\n  // Handle flex\n  if (display === 'flex' || display === 'inline-flex') {\n    // Reject wrapped flex (2D)\n    if (flexWrap === 'wrap' || flexWrap === 'wrap-reverse') return null;\n\n    switch (flexDirection) {\n      case 'row':\n        return { axis: 'x', reverse: false };\n      case 'row-reverse':\n        return { axis: 'x', reverse: true };\n      case 'column':\n        return { axis: 'y', reverse: false };\n      case 'column-reverse':\n        return { axis: 'y', reverse: true };\n      default:\n        return { axis: 'y', reverse: false };\n    }\n  }\n\n  // Default to vertical\n  return { axis: 'y', reverse: false };\n}\n\n/**\n * Helper to calculate side with hysteresis.\n * This mirrors the internal chooseSideWithHysteresis logic.\n */\nfunction chooseSide(\n  clientPos: number,\n  rectStart: number,\n  rectSize: number,\n  axis: 'x' | 'y',\n  reverse: boolean,\n): 'before' | 'after' {\n  const mid = rectStart + rectSize / 2;\n  const effectivePos = reverse ? -clientPos : clientPos;\n  const effectiveMid = reverse ? -mid : mid;\n  return effectivePos < effectiveMid ? 'before' : 'after';\n}\n\n// =============================================================================\n// Test Setup\n// =============================================================================\n\nlet restores: RestoreFn[] = [];\n\nbeforeEach(() => {\n  restores = [];\n  document.body.innerHTML = '';\n});\n\nafterEach(() => {\n  for (let i = restores.length - 1; i >= 0; i--) {\n    restores[i]!();\n  }\n  restores = [];\n  vi.restoreAllMocks();\n});\n\n// =============================================================================\n// Container Axis Detection Tests\n// =============================================================================\n\ndescribe('drag-reorder: container axis detection', () => {\n  it('flex-direction: row returns X axis', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'flex',\n      flexDirection: 'row',\n    });\n    expect(result).toEqual({ axis: 'x', reverse: false });\n  });\n\n  it('flex-direction: row-reverse returns X axis with reverse', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'flex',\n      flexDirection: 'row-reverse',\n    });\n    expect(result).toEqual({ axis: 'x', reverse: true });\n  });\n\n  it('flex-direction: column returns Y axis', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'flex',\n      flexDirection: 'column',\n    });\n    expect(result).toEqual({ axis: 'y', reverse: false });\n  });\n\n  it('flex-direction: column-reverse returns Y axis with reverse', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'flex',\n      flexDirection: 'column-reverse',\n    });\n    expect(result).toEqual({ axis: 'y', reverse: true });\n  });\n\n  it('non-flex layout returns Y axis (block flow)', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'block',\n    });\n    expect(result).toEqual({ axis: 'y', reverse: false });\n  });\n\n  it('grid layout returns null (not supported)', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'grid',\n    });\n    expect(result).toBeNull();\n  });\n\n  it('flex-wrap: wrap returns null (2D not supported)', () => {\n    const result = getContainerAxisFromStyle({\n      display: 'flex',\n      flexDirection: 'row',\n      flexWrap: 'wrap',\n    });\n    expect(result).toBeNull();\n  });\n});\n\n// =============================================================================\n// Side Calculation Tests\n// =============================================================================\n\ndescribe('drag-reorder: side calculation', () => {\n  describe('X axis (horizontal)', () => {\n    it('left half returns \"before\"', () => {\n      // rect: left=100, width=100 (100-200), mid=150\n      // clientX=120 (left of mid)\n      const side = chooseSide(120, 100, 100, 'x', false);\n      expect(side).toBe('before');\n    });\n\n    it('right half returns \"after\"', () => {\n      // rect: left=100, width=100, mid=150\n      // clientX=180 (right of mid)\n      const side = chooseSide(180, 100, 100, 'x', false);\n      expect(side).toBe('after');\n    });\n  });\n\n  describe('X axis with reverse (row-reverse)', () => {\n    it('right half returns \"before\" in reverse mode', () => {\n      // In row-reverse, visual left is DOM right\n      // rect: left=100, width=100, mid=150\n      // clientX=180 (visual right = DOM before)\n      const side = chooseSide(180, 100, 100, 'x', true);\n      expect(side).toBe('before');\n    });\n\n    it('left half returns \"after\" in reverse mode', () => {\n      // rect: left=100, width=100, mid=150\n      // clientX=120 (visual left = DOM after)\n      const side = chooseSide(120, 100, 100, 'x', true);\n      expect(side).toBe('after');\n    });\n  });\n\n  describe('Y axis (vertical)', () => {\n    it('top half returns \"before\"', () => {\n      // rect: top=100, height=100 (100-200), mid=150\n      // clientY=120 (above mid)\n      const side = chooseSide(120, 100, 100, 'y', false);\n      expect(side).toBe('before');\n    });\n\n    it('bottom half returns \"after\"', () => {\n      // rect: top=100, height=100, mid=150\n      // clientY=180 (below mid)\n      const side = chooseSide(180, 100, 100, 'y', false);\n      expect(side).toBe('after');\n    });\n  });\n\n  describe('Y axis with reverse (column-reverse)', () => {\n    it('bottom half returns \"before\" in reverse mode', () => {\n      const side = chooseSide(180, 100, 100, 'y', true);\n      expect(side).toBe('before');\n    });\n\n    it('top half returns \"after\" in reverse mode', () => {\n      const side = chooseSide(120, 100, 100, 'y', true);\n      expect(side).toBe('after');\n    });\n  });\n});\n\n// =============================================================================\n// Insertion Line Direction Tests\n// =============================================================================\n\ndescribe('drag-reorder: insertion line direction', () => {\n  it('horizontal layout should produce vertical line (x1 === x2)', () => {\n    // For flex-row, insertion line should be vertical\n    // This is a conceptual test - actual line is calculated in computeInsertPosition\n\n    const rect = { left: 100, top: 50, width: 80, height: 40 };\n    const axis = 'x';\n    const side = 'before';\n    const reverse = false;\n\n    // Calculate expected line position\n    const beforeX = reverse ? rect.left + rect.width : rect.left;\n    const afterX = reverse ? rect.left : rect.left + rect.width;\n    const x = side === 'before' ? beforeX : afterX;\n\n    // Vertical line: x1 === x2\n    const line = {\n      x1: x,\n      y1: rect.top,\n      x2: x,\n      y2: rect.top + rect.height,\n    };\n\n    expect(line.x1).toBe(line.x2); // Vertical line\n    expect(line.x1).toBe(rect.left); // At left edge for \"before\"\n  });\n\n  it('vertical layout should produce horizontal line (y1 === y2)', () => {\n    const rect = { left: 100, top: 50, width: 80, height: 40 };\n    const axis = 'y';\n    const side = 'before';\n    const reverse = false;\n\n    // Calculate expected line position\n    const beforeY = reverse ? rect.top + rect.height : rect.top;\n    const afterY = reverse ? rect.top : rect.top + rect.height;\n    const y = side === 'before' ? beforeY : afterY;\n\n    // Horizontal line: y1 === y2\n    const line = {\n      x1: rect.left,\n      y1: y,\n      x2: rect.left + rect.width,\n      y2: y,\n    };\n\n    expect(line.y1).toBe(line.y2); // Horizontal line\n    expect(line.y1).toBe(rect.top); // At top edge for \"before\"\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/event-controller.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 Event Controller.\n *\n * These tests focus on the selecting mode behavior:\n * - Clicking within selection subtree prepares drag candidate\n * - Clicking outside selection triggers reselection (Bug 1 fix)\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  createEventController,\n  type EventController,\n  type EventControllerOptions,\n  type Modifiers,\n} from '@/entrypoints/web-editor-v2/core/event-controller';\n\nimport type { RestoreFn } from './test-utils/dom';\nimport { mockBoundingClientRect } from './test-utils/dom';\n\n// =============================================================================\n// Test Utilities\n// =============================================================================\n\nconst NO_MODIFIERS: Modifiers = { alt: false, shift: false, ctrl: false, meta: false };\n\n/**\n * Check if an element is part of the editor overlay.\n */\nfunction isOverlayElement(node: unknown): boolean {\n  return node instanceof Element && node.getAttribute('data-overlay') === 'true';\n}\n\n/**\n * Create a minimal mock PointerEvent for testing.\n * jsdom doesn't support PointerEvent, so we create a MouseEvent and extend it.\n */\nfunction createPointerEvent(\n  type: string,\n  options: {\n    clientX?: number;\n    clientY?: number;\n    button?: number;\n    pointerId?: number;\n    target?: EventTarget | null;\n  } = {},\n): MouseEvent & { pointerId: number } {\n  const event = new MouseEvent(type, {\n    bubbles: true,\n    cancelable: true,\n    clientX: options.clientX ?? 0,\n    clientY: options.clientY ?? 0,\n    button: options.button ?? 0,\n  });\n\n  // Add pointerId property (jsdom doesn't have PointerEvent)\n  Object.defineProperty(event, 'pointerId', {\n    value: options.pointerId ?? 1,\n    writable: false,\n  });\n\n  // Mock composedPath to return target path\n  if (options.target) {\n    vi.spyOn(event, 'composedPath').mockReturnValue([options.target as EventTarget]);\n  }\n\n  return event as MouseEvent & { pointerId: number };\n}\n\n// =============================================================================\n// Test Setup\n// =============================================================================\n\nlet restores: RestoreFn[] = [];\nlet controller: EventController | null = null;\n\nbeforeEach(() => {\n  restores = [];\n  document.body.innerHTML = '';\n});\n\nafterEach(() => {\n  controller?.dispose();\n  controller = null;\n  for (let i = restores.length - 1; i >= 0; i--) {\n    restores[i]!();\n  }\n  restores = [];\n  vi.restoreAllMocks();\n});\n\n// =============================================================================\n// Selecting Mode Tests (Bug 1 Fix)\n// =============================================================================\n\ndescribe('event-controller: selecting mode click behavior', () => {\n  it('clicking within selection subtree prepares drag candidate (does not trigger onSelect)', () => {\n    // Setup DOM\n    const selected = document.createElement('div');\n    selected.id = 'selected';\n    const child = document.createElement('span');\n    child.id = 'child';\n    selected.appendChild(child);\n    document.body.appendChild(selected);\n\n    // Mock rect for selected element\n    restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));\n    restores.push(mockBoundingClientRect(child, { left: 10, top: 10, width: 50, height: 50 }));\n\n    // Setup callbacks\n    const onSelect = vi.fn();\n    const onStartDrag = vi.fn().mockReturnValue(true);\n\n    const options: EventControllerOptions = {\n      isOverlayElement,\n      isEditorUiElement: () => false,\n      getSelectedElement: () => selected,\n      getEditingElement: () => null,\n      findTargetForSelect: () => child,\n      onHover: vi.fn(),\n      onSelect,\n      onDeselect: vi.fn(),\n      onStartDrag,\n    };\n\n    controller = createEventController(options);\n    controller.setMode('selecting');\n\n    // Simulate pointerdown within selected element\n    const event = createPointerEvent('pointerdown', {\n      clientX: 20,\n      clientY: 20,\n      target: child,\n    });\n\n    document.dispatchEvent(event);\n\n    // onSelect should NOT be called (we're preparing drag instead)\n    expect(onSelect).not.toHaveBeenCalled();\n  });\n\n  it('clicking outside selection triggers reselection (Bug 1 fix)', () => {\n    // Setup DOM\n    const selected = document.createElement('div');\n    selected.id = 'selected';\n    document.body.appendChild(selected);\n\n    const other = document.createElement('div');\n    other.id = 'other';\n    document.body.appendChild(other);\n\n    // Mock rects\n    restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));\n    restores.push(mockBoundingClientRect(other, { left: 200, top: 0, width: 100, height: 100 }));\n\n    // Setup callbacks\n    const onSelect = vi.fn();\n    const onStartDrag = vi.fn().mockReturnValue(true);\n\n    const options: EventControllerOptions = {\n      isOverlayElement,\n      isEditorUiElement: () => false,\n      getSelectedElement: () => selected,\n      getEditingElement: () => null,\n      findTargetForSelect: () => other, // Returns the \"other\" element as target\n      onHover: vi.fn(),\n      onSelect,\n      onDeselect: vi.fn(),\n      onStartDrag,\n    };\n\n    controller = createEventController(options);\n    controller.setMode('selecting');\n\n    // Simulate mousedown outside selected element (on \"other\")\n    // Use mousedown since jsdom doesn't support PointerEvent\n    const event = new MouseEvent('mousedown', {\n      bubbles: true,\n      cancelable: true,\n      clientX: 250, // Outside selected (0-100), inside other (200-300)\n      clientY: 50,\n      button: 0,\n    });\n\n    // Mock composedPath to return a path that does NOT include \"selected\"\n    // This simulates clicking outside the selection\n    vi.spyOn(event, 'composedPath').mockReturnValue([other, document.body, document]);\n\n    document.dispatchEvent(event);\n\n    // onSelect SHOULD be called with the new element\n    expect(onSelect).toHaveBeenCalledWith(other, expect.any(Object));\n  });\n\n  it('clicking outside with no valid target does not trigger onSelect', () => {\n    // Setup DOM\n    const selected = document.createElement('div');\n    selected.id = 'selected';\n    document.body.appendChild(selected);\n\n    // Mock rect\n    restores.push(mockBoundingClientRect(selected, { left: 0, top: 0, width: 100, height: 100 }));\n\n    // Setup callbacks\n    const onSelect = vi.fn();\n\n    const options: EventControllerOptions = {\n      isOverlayElement,\n      isEditorUiElement: () => false,\n      getSelectedElement: () => selected,\n      getEditingElement: () => null,\n      findTargetForSelect: () => null, // No valid target found\n      onHover: vi.fn(),\n      onSelect,\n      onDeselect: vi.fn(),\n      onStartDrag: vi.fn(),\n    };\n\n    controller = createEventController(options);\n    controller.setMode('selecting');\n\n    // Simulate pointerdown outside selected element\n    const event = createPointerEvent('pointerdown', {\n      clientX: 500,\n      clientY: 500,\n      target: document.body,\n    });\n\n    document.dispatchEvent(event);\n\n    // onSelect should NOT be called (no valid target)\n    expect(onSelect).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/locator.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 locator utilities.\n *\n * These tests run in jsdom and validate:\n * - Fingerprint generation (tag, id, class, text normalization)\n * - DOM path computation\n * - Selector candidate strategies (ID > data-attrs > classes > path > anchor)\n * - Locator creation and resolution\n * - Locator key stability\n * - Shadow host chain detection\n */\n\nimport { beforeEach, describe, expect, it } from 'vitest';\n\nimport type { ElementLocator } from '@/common/web-editor-types';\nimport {\n  computeDomPath,\n  computeFingerprint,\n  createElementLocator,\n  generateCssSelector,\n  generateSelectorCandidates,\n  getShadowHostChain,\n  locateElement,\n  locatorKey,\n} from '@/entrypoints/web-editor-v2/core/locator';\n\n// =============================================================================\n// Test Utilities\n// =============================================================================\n\nconst supportsShadowDom =\n  typeof (document.createElement('div') as HTMLElement).attachShadow === 'function';\nconst itIfShadow = supportsShadowDom ? it : it.skip;\n\nbeforeEach(() => {\n  document.body.innerHTML = '';\n});\n\n// =============================================================================\n// computeFingerprint Tests\n// =============================================================================\n\ndescribe('locator: computeFingerprint', () => {\n  it('includes tag, id, class list, and normalized text', () => {\n    const el = document.createElement('button');\n    el.id = 'save';\n    el.className = 'btn primary';\n    el.textContent = '  Hello   world \\n ok  ';\n    document.body.append(el);\n\n    const fp = computeFingerprint(el);\n\n    expect(fp).toContain('button');\n    expect(fp).toContain('id=save');\n    expect(fp).toContain('class=btn.primary');\n    expect(fp).toContain('text=Hello world ok');\n  });\n\n  it('limits classes to 8 tokens', () => {\n    const el = document.createElement('div');\n    el.className = Array.from({ length: 10 }, (_, i) => `c${i}`).join(' ');\n    document.body.append(el);\n\n    const fp = computeFingerprint(el);\n    const classPart = fp.split('|').find((p) => p.startsWith('class='));\n\n    // Should have exactly 8 classes\n    const classes = classPart?.replace('class=', '').split('.') ?? [];\n    expect(classes).toHaveLength(8);\n    expect(classes).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7']);\n  });\n\n  it('truncates text to 32 characters', () => {\n    const el = document.createElement('div');\n    el.textContent = 'a'.repeat(40);\n    document.body.append(el);\n\n    const fp = computeFingerprint(el);\n    const textPart = fp.split('|').find((p) => p.startsWith('text='));\n    const text = textPart?.replace('text=', '') ?? '';\n\n    expect(text.length).toBeLessThanOrEqual(32);\n  });\n\n  it('returns only tag when id/class/text are empty', () => {\n    const el = document.createElement('div');\n    document.body.append(el);\n\n    expect(computeFingerprint(el)).toBe('div');\n  });\n\n  it('normalizes whitespace in text', () => {\n    const el = document.createElement('span');\n    el.textContent = '\\n  foo   bar\\t\\tbaz  \\n';\n    document.body.append(el);\n\n    const fp = computeFingerprint(el);\n    expect(fp).toContain('text=foo bar baz');\n  });\n\n  it('preserves class order from classList', () => {\n    const el = document.createElement('div');\n    el.className = 'z-class a-class m-class';\n    document.body.append(el);\n\n    const fp = computeFingerprint(el);\n    // Classes are preserved in their original order from classList\n    expect(fp).toContain('class=z-class.a-class.m-class');\n  });\n});\n\n// =============================================================================\n// computeDomPath Tests\n// =============================================================================\n\ndescribe('locator: computeDomPath', () => {\n  it('computes stable indices for nested elements in document', () => {\n    const container = document.createElement('div');\n    const first = document.createElement('span');\n    const second = document.createElement('span');\n    container.append(first, second);\n    document.body.append(container);\n\n    const firstPath = computeDomPath(first);\n    const secondPath = computeDomPath(second);\n\n    // Second child should have higher last index\n    expect(secondPath[secondPath.length - 1]).toBeGreaterThan(\n      firstPath[firstPath.length - 1] as number,\n    );\n  });\n\n  it('returns different paths for siblings', () => {\n    const a = document.createElement('div');\n    const b = document.createElement('div');\n    document.body.append(a, b);\n\n    expect(computeDomPath(a)).not.toEqual(computeDomPath(b));\n  });\n\n  itIfShadow('computes index within a ShadowRoot boundary', () => {\n    const host = document.createElement('div');\n    document.body.append(host);\n\n    const shadow = host.attachShadow({ mode: 'open' });\n    const a = document.createElement('div');\n    const b = document.createElement('div');\n    shadow.append(a, b);\n\n    // Path within shadow should start from 0\n    const pathA = computeDomPath(a);\n    const pathB = computeDomPath(b);\n\n    expect(pathA[0]).toBe(0);\n    expect(pathB[0]).toBe(1);\n  });\n});\n\n// =============================================================================\n// Selector Generation Tests\n// =============================================================================\n\ndescribe('locator: generateSelectorCandidates', () => {\n  it('prefers unique id selector first', () => {\n    const el = document.createElement('div');\n    el.id = 'unique';\n    document.body.append(el);\n\n    const candidates = generateSelectorCandidates(el, { root: document });\n    expect(candidates[0]).toBe('#unique');\n  });\n\n  it('uses data-testid when unique', () => {\n    const el = document.createElement('button');\n    el.setAttribute('data-testid', 'save-btn');\n    document.body.append(el);\n\n    const candidates = generateSelectorCandidates(el, { root: document });\n    expect(candidates[0]).toBe('[data-testid=\"save-btn\"]');\n  });\n\n  it('uses tag+data-testid when attribute alone is not unique', () => {\n    const div = document.createElement('div');\n    div.setAttribute('data-testid', 'dup');\n    const span = document.createElement('span');\n    span.setAttribute('data-testid', 'dup');\n    document.body.append(div, span);\n\n    const candidates = generateSelectorCandidates(div, { root: document });\n    expect(candidates[0]).toBe('div[data-testid=\"dup\"]');\n  });\n\n  it('uses tag+class when class alone is not unique', () => {\n    const a = document.createElement('div');\n    a.className = 'item';\n    const b = document.createElement('button');\n    b.className = 'item';\n    document.body.append(a, b);\n\n    const candidates = generateSelectorCandidates(b, { root: document });\n    expect(candidates[0]).toBe('button.item');\n  });\n\n  it('uses class pair selector when only the combination is unique', () => {\n    const target = document.createElement('div');\n    target.className = 'a b';\n    const onlyA = document.createElement('div');\n    onlyA.className = 'a';\n    const onlyB = document.createElement('div');\n    onlyB.className = 'b';\n    document.body.append(target, onlyA, onlyB);\n\n    const candidates = generateSelectorCandidates(target, { root: document });\n    expect(candidates[0]).toBe('.a.b');\n  });\n\n  it('generates multiple candidates', () => {\n    const el = document.createElement('div');\n    el.id = 'myid';\n    el.className = 'myclass';\n    el.setAttribute('data-testid', 'mytest');\n    document.body.append(el);\n\n    const candidates = generateSelectorCandidates(el, { root: document, maxCandidates: 5 });\n    expect(candidates.length).toBeGreaterThan(1);\n    expect(candidates).toContain('#myid');\n  });\n\n  it('falls back to structural path selector when no unique attrs/classes exist', () => {\n    const section = document.createElement('section');\n    const p = document.createElement('p');\n    section.append(p);\n    document.body.append(section);\n\n    const candidates = generateSelectorCandidates(p, { root: document });\n    // Should include a path-based selector\n    const hasPath = candidates.some((c) => c.includes('>'));\n    expect(hasPath).toBe(true);\n  });\n\n  it('respects maxCandidates option', () => {\n    const el = document.createElement('div');\n    el.id = 'test';\n    el.className = 'a b c';\n    el.setAttribute('data-testid', 'x');\n    document.body.append(el);\n\n    const candidates = generateSelectorCandidates(el, { root: document, maxCandidates: 2 });\n    expect(candidates.length).toBeLessThanOrEqual(2);\n  });\n});\n\ndescribe('locator: generateCssSelector', () => {\n  it('returns the best single selector', () => {\n    const el = document.createElement('div');\n    el.id = 'unique';\n    document.body.append(el);\n\n    expect(generateCssSelector(el, { root: document })).toBe('#unique');\n  });\n\n  it('returns empty string for orphan element', () => {\n    const el = document.createElement('div');\n    // Element not in document\n    const selector = generateCssSelector(el);\n    // May return a selector or empty depending on implementation\n    expect(typeof selector).toBe('string');\n  });\n});\n\n// =============================================================================\n// Locator Creation & Resolution Tests\n// =============================================================================\n\ndescribe('locator: createElementLocator', () => {\n  it('creates a locator with selectors, fingerprint, and dom path', () => {\n    const el = document.createElement('div');\n    el.id = 'target';\n    el.className = 'box';\n    el.textContent = 'Hello';\n    document.body.append(el);\n\n    const locator = createElementLocator(el);\n\n    expect(locator.selectors.length).toBeGreaterThan(0);\n    expect(locator.selectors[0]).toBe('#target');\n    expect(locator.fingerprint).toBe(computeFingerprint(el));\n    expect(locator.path).toEqual(computeDomPath(el));\n  });\n\n  itIfShadow('includes shadowHostChain when element is inside Shadow DOM', () => {\n    const host = document.createElement('div');\n    host.id = 'host';\n    document.body.append(host);\n\n    const shadow = host.attachShadow({ mode: 'open' });\n    const target = document.createElement('span');\n    target.id = 'inner';\n    shadow.append(target);\n\n    const locator = createElementLocator(target);\n\n    expect(locator.shadowHostChain).toBeDefined();\n    expect(locator.shadowHostChain!.length).toBeGreaterThan(0);\n  });\n});\n\ndescribe('locator: locateElement', () => {\n  it('locates an element from its own locator', () => {\n    const el = document.createElement('div');\n    el.id = 'target';\n    el.textContent = 'Hello';\n    document.body.append(el);\n\n    const locator = createElementLocator(el);\n    const found = locateElement(locator, document);\n\n    expect(found).toBe(el);\n  });\n\n  it('tries multiple selectors and falls back to later candidates', () => {\n    const el = document.createElement('div');\n    el.id = 'target';\n    document.body.append(el);\n\n    const locator: ElementLocator = {\n      selectors: ['#missing', '#target'],\n      fingerprint: computeFingerprint(el),\n      path: [],\n    };\n\n    expect(locateElement(locator, document)).toBe(el);\n  });\n\n  it('returns null when selector is not unique', () => {\n    const a = document.createElement('div');\n    a.className = 'x';\n    const b = document.createElement('div');\n    b.className = 'x';\n    document.body.append(a, b);\n\n    const locator: ElementLocator = {\n      selectors: ['.x'],\n      fingerprint: computeFingerprint(a),\n      path: [],\n    };\n\n    // Should return null because .x matches 2 elements\n    expect(locateElement(locator, document)).toBeNull();\n  });\n\n  it('returns null when fingerprint does not match', () => {\n    const el = document.createElement('div');\n    el.id = 'a';\n    document.body.append(el);\n\n    const locator: ElementLocator = {\n      selectors: ['#a'],\n      fingerprint: 'div|id=wrong', // Wrong fingerprint\n      path: [],\n    };\n\n    expect(locateElement(locator, document)).toBeNull();\n  });\n\n  it('handles element removal gracefully', () => {\n    const el = document.createElement('div');\n    el.id = 'temp';\n    document.body.append(el);\n\n    const locator = createElementLocator(el);\n    el.remove();\n\n    expect(locateElement(locator, document)).toBeNull();\n  });\n\n  itIfShadow('locates element inside nested ShadowRoot via shadowHostChain', () => {\n    const outerHost = document.createElement('div');\n    outerHost.id = 'outer-host';\n    document.body.append(outerHost);\n\n    const outerShadow = outerHost.attachShadow({ mode: 'open' });\n\n    const innerHost = document.createElement('div');\n    innerHost.id = 'inner-host';\n    outerShadow.append(innerHost);\n\n    const innerShadow = innerHost.attachShadow({ mode: 'open' });\n    const target = document.createElement('span');\n    target.id = 'shadow-target';\n    innerShadow.append(target);\n\n    const locator = createElementLocator(target);\n    const found = locateElement(locator, document);\n\n    expect(found).toBe(target);\n  });\n});\n\n// =============================================================================\n// Shadow Host Chain Tests\n// =============================================================================\n\ndescribe('locator: getShadowHostChain', () => {\n  it('returns undefined for element not in Shadow DOM', () => {\n    const el = document.createElement('div');\n    document.body.append(el);\n\n    expect(getShadowHostChain(el)).toBeUndefined();\n  });\n\n  itIfShadow('returns chain for single-level Shadow DOM', () => {\n    const host = document.createElement('div');\n    host.id = 'myhost';\n    document.body.append(host);\n\n    const shadow = host.attachShadow({ mode: 'open' });\n    const inner = document.createElement('span');\n    shadow.append(inner);\n\n    const chain = getShadowHostChain(inner);\n    expect(chain).toBeDefined();\n    expect(chain!.length).toBe(1);\n    expect(chain![0]).toBe('#myhost');\n  });\n\n  itIfShadow('returns chain for nested Shadow DOMs', () => {\n    const outer = document.createElement('div');\n    outer.id = 'outer';\n    document.body.append(outer);\n\n    const outerShadow = outer.attachShadow({ mode: 'open' });\n    const inner = document.createElement('div');\n    inner.id = 'inner';\n    outerShadow.append(inner);\n\n    const innerShadow = inner.attachShadow({ mode: 'open' });\n    const target = document.createElement('span');\n    innerShadow.append(target);\n\n    const chain = getShadowHostChain(target);\n    expect(chain).toBeDefined();\n    expect(chain!.length).toBe(2);\n    expect(chain![0]).toBe('#outer');\n    expect(chain![1]).toBe('#inner');\n  });\n});\n\n// =============================================================================\n// locatorKey Tests\n// =============================================================================\n\ndescribe('locator: locatorKey', () => {\n  it('generates a stable key including selectors', () => {\n    const el = document.createElement('div');\n    el.id = 'k1';\n    document.body.append(el);\n\n    const locator = createElementLocator(el);\n    const key = locatorKey(locator);\n\n    expect(key).toContain('sel:');\n    expect(key).toContain('#k1');\n  });\n\n  it('differs for different locators', () => {\n    const a = document.createElement('div');\n    a.id = 'a';\n    const b = document.createElement('div');\n    b.id = 'b';\n    document.body.append(a, b);\n\n    const keyA = locatorKey(createElementLocator(a));\n    const keyB = locatorKey(createElementLocator(b));\n\n    expect(keyA).not.toBe(keyB);\n  });\n\n  it('is deterministic for same element', () => {\n    const el = document.createElement('div');\n    el.id = 'stable';\n    document.body.append(el);\n\n    const key1 = locatorKey(createElementLocator(el));\n    const key2 = locatorKey(createElementLocator(el));\n\n    expect(key1).toBe(key2);\n  });\n\n  itIfShadow('includes shadow host chain in the key when present', () => {\n    const host = document.createElement('div');\n    host.id = 'host';\n    document.body.append(host);\n    const shadow = host.attachShadow({ mode: 'open' });\n\n    const target = document.createElement('span');\n    target.id = 't';\n    shadow.append(target);\n\n    const locator = createElementLocator(target);\n    const key = locatorKey(locator);\n\n    expect(key).toContain('shadow:');\n    expect(key).toContain('#host');\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/property-panel-live-sync.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 Property Panel Live Style Sync.\n *\n * These tests focus on:\n * - MutationObserver setup for style attribute changes (Bug 3 fix)\n * - rAF throttling of refresh calls\n * - Proper cleanup on target change and dispose\n */\n\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\n// =============================================================================\n// Test Setup\n// =============================================================================\n\n// Mock MutationObserver\nlet mockObserverCallback: MutationCallback | null = null;\nlet mockObserverDisconnect: ReturnType<typeof vi.fn>;\n\nclass MockMutationObserver {\n  callback: MutationCallback;\n\n  constructor(callback: MutationCallback) {\n    this.callback = callback;\n    mockObserverCallback = callback;\n  }\n\n  observe = vi.fn();\n  disconnect = vi.fn(() => {\n    mockObserverDisconnect?.();\n  });\n  takeRecords = vi.fn(() => []);\n}\n\nbeforeEach(() => {\n  mockObserverCallback = null;\n  mockObserverDisconnect = vi.fn();\n\n  // Install mock MutationObserver\n  vi.stubGlobal('MutationObserver', MockMutationObserver);\n\n  // Mock requestAnimationFrame\n  vi.stubGlobal(\n    'requestAnimationFrame',\n    vi.fn((cb: FrameRequestCallback) => {\n      // Execute immediately for testing\n      cb(performance.now());\n      return 1;\n    }),\n  );\n\n  vi.stubGlobal('cancelAnimationFrame', vi.fn());\n});\n\nafterEach(() => {\n  vi.unstubAllGlobals();\n  vi.restoreAllMocks();\n});\n\n// =============================================================================\n// MutationObserver Integration Tests\n// =============================================================================\n\ndescribe('property-panel: live style sync', () => {\n  it('should observe style attribute changes on target element', () => {\n    // This is a conceptual test for the MutationObserver setup\n    // The actual implementation is in property-panel.ts\n\n    const target = document.createElement('div');\n    const observer = new MockMutationObserver(() => {});\n\n    observer.observe(target, {\n      attributes: true,\n      attributeFilter: ['style'],\n    });\n\n    expect(observer.observe).toHaveBeenCalledWith(target, {\n      attributes: true,\n      attributeFilter: ['style'],\n    });\n  });\n\n  it('should trigger callback when style changes', () => {\n    const callback = vi.fn();\n    const observer = new MockMutationObserver(callback);\n    const target = document.createElement('div');\n\n    observer.observe(target, { attributes: true, attributeFilter: ['style'] });\n\n    // Simulate style mutation with a minimal MutationRecord-like object\n    if (mockObserverCallback) {\n      mockObserverCallback(\n        [\n          {\n            type: 'attributes',\n            target,\n            attributeName: 'style',\n            attributeNamespace: null,\n            oldValue: null,\n            addedNodes: { length: 0 } as unknown as NodeList,\n            removedNodes: { length: 0 } as unknown as NodeList,\n            previousSibling: null,\n            nextSibling: null,\n          } as MutationRecord,\n        ],\n        observer as unknown as MutationObserver,\n      );\n    }\n\n    expect(callback).toHaveBeenCalled();\n  });\n\n  it('should disconnect observer when target changes', () => {\n    const observer = new MockMutationObserver(() => {});\n    observer.disconnect();\n    expect(observer.disconnect).toHaveBeenCalled();\n  });\n});\n\n// =============================================================================\n// rAF Throttling Tests\n// =============================================================================\n\ndescribe('property-panel: rAF throttling', () => {\n  it('should coalesce multiple style changes into single refresh', () => {\n    let rafCallCount = 0;\n    let scheduledCallback: FrameRequestCallback | null = null;\n\n    vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {\n      rafCallCount++;\n      scheduledCallback = cb;\n      return rafCallCount;\n    });\n\n    // Simulate the throttling logic\n    let rafId: number | null = null;\n    const refreshCalls: number[] = [];\n\n    function scheduleRefresh(): void {\n      if (rafId !== null) return; // Already scheduled\n      rafId = requestAnimationFrame(() => {\n        rafId = null;\n        refreshCalls.push(Date.now());\n      });\n    }\n\n    // Schedule multiple refreshes\n    scheduleRefresh();\n    scheduleRefresh();\n    scheduleRefresh();\n\n    // Only one rAF should be scheduled\n    expect(rafCallCount).toBe(1);\n\n    // Execute the callback\n    if (scheduledCallback) {\n      scheduledCallback(performance.now());\n    }\n\n    // Only one refresh should have occurred\n    expect(refreshCalls.length).toBe(1);\n  });\n\n  it('should cancel pending rAF on cleanup', () => {\n    const cancelRaf = vi.fn();\n    vi.stubGlobal('cancelAnimationFrame', cancelRaf);\n\n    let rafId: number | null = requestAnimationFrame(() => {});\n\n    // Cleanup\n    if (rafId !== null) {\n      cancelAnimationFrame(rafId);\n      rafId = null;\n    }\n\n    expect(cancelRaf).toHaveBeenCalled();\n  });\n});\n\n// =============================================================================\n// Lifecycle Tests\n// =============================================================================\n\ndescribe('property-panel: observer lifecycle', () => {\n  it('should disconnect old observer before connecting new one', () => {\n    const disconnectCalls: string[] = [];\n\n    class TrackedObserver {\n      id: string;\n      constructor(id: string) {\n        this.id = id;\n      }\n      observe = vi.fn();\n      disconnect = vi.fn(() => {\n        disconnectCalls.push(this.id);\n      });\n    }\n\n    // Simulate target change\n    const observer1 = new TrackedObserver('observer1');\n    const observer2 = new TrackedObserver('observer2');\n\n    // First target\n    const target1 = document.createElement('div');\n    observer1.observe(target1, { attributes: true });\n\n    // Change target - should disconnect old observer first\n    observer1.disconnect();\n    observer2.observe(document.createElement('div'), { attributes: true });\n\n    expect(disconnectCalls).toContain('observer1');\n  });\n\n  it('should handle null target gracefully', () => {\n    // When target is null, should disconnect and not create new observer\n    const observer = new MockMutationObserver(() => {});\n\n    // Simulate setTarget(null)\n    observer.disconnect();\n\n    expect(observer.disconnect).toHaveBeenCalled();\n  });\n\n  it('should handle disconnected target gracefully', () => {\n    const callback = vi.fn();\n    const observer = new MockMutationObserver(callback);\n\n    const target = document.createElement('div');\n    // Target not connected to DOM\n    expect(target.isConnected).toBe(false);\n\n    // Should still be able to observe (MutationObserver allows this)\n    observer.observe(target, { attributes: true });\n\n    // Callback should check isConnected before processing\n    if (mockObserverCallback) {\n      // Simulate mutation on disconnected element\n      mockObserverCallback([], observer as unknown as MutationObserver);\n    }\n\n    // In real implementation, the callback should guard against disconnected elements\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/selection-engine.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 Selection Engine.\n *\n * These tests focus on deterministic scoring and selection behavior.\n * jsdom has no real layout engine, so we mock:\n * - document.elementsFromPoint / document.elementFromPoint\n * - element.getBoundingClientRect()\n * - window.getComputedStyle()\n */\n\nimport { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport {\n  createSelectionEngine,\n  type Modifiers,\n  type SelectionCandidate,\n  type SelectionEngine,\n} from '@/entrypoints/web-editor-v2/selection/selection-engine';\n\nimport type { RestoreFn, StyleOverrides } from './test-utils/dom';\nimport {\n  createMockEvent,\n  installDomMocks,\n  mockBoundingClientRect,\n  mockViewport,\n} from './test-utils/dom';\n\n// =============================================================================\n// Test Utilities\n// =============================================================================\n\nconst NO_MODIFIERS: Modifiers = { alt: false, shift: false, ctrl: false, meta: false };\n\n/**\n * Check if an element is part of the editor overlay.\n * In tests, elements with data-overlay=\"true\" are considered overlay elements.\n */\nfunction isOverlayElement(node: unknown): boolean {\n  return node instanceof Element && node.getAttribute('data-overlay') === 'true';\n}\n\n/**\n * Find a candidate by element in the candidates array.\n */\nfunction getCandidate(\n  candidates: SelectionCandidate[],\n  element: Element,\n): SelectionCandidate | undefined {\n  return candidates.find((c) => c.element === element);\n}\n\n/**\n * Check if any of the candidate's reasons contain a specific substring.\n */\nfunction hasReason(candidate: SelectionCandidate | undefined, substring: string): boolean {\n  return candidate?.reasons.some((r) => r.includes(substring)) ?? false;\n}\n\n// =============================================================================\n// Test Setup\n// =============================================================================\n\nlet restores: RestoreFn[] = [];\nlet engine: SelectionEngine | null = null;\n\nbeforeEach(() => {\n  restores = [];\n  document.body.innerHTML = '';\n});\n\nafterEach(() => {\n  engine?.dispose();\n  engine = null;\n  for (let i = restores.length - 1; i >= 0; i--) {\n    restores[i]!();\n  }\n  restores = [];\n});\n\n// =============================================================================\n// getCandidatesAtPoint Tests\n// =============================================================================\n\ndescribe('selection-engine: getCandidatesAtPoint', () => {\n  it('returns empty array when no elements are hit', () => {\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [],\n        getComputedStyle: () => ({}),\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    expect(engine.getCandidatesAtPoint(10, 10)).toEqual([]);\n  });\n\n  it('skips overlay elements from hit testing', () => {\n    const overlay = document.createElement('div');\n    overlay.setAttribute('data-overlay', 'true');\n\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n\n    document.body.append(overlay, button);\n\n    restores.push(mockBoundingClientRect(overlay, { left: 0, top: 0, width: 200, height: 200 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [overlay, button],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(12, 12);\n\n    // Overlay should be excluded\n    expect(candidates.some((c) => c.element === overlay)).toBe(false);\n    // Button should be selected\n    expect(candidates.some((c) => c.element === button)).toBe(true);\n  });\n\n  it('scores interactive button element highly', () => {\n    const wrapper = document.createElement('div');\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n    wrapper.append(button);\n    document.body.append(wrapper);\n\n    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 200, height: 120 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [button, wrapper],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(12, 12);\n\n    // Button should have higher score due to interactive tag\n    const buttonCandidate = getCandidate(candidates, button);\n    expect(buttonCandidate).toBeDefined();\n    expect(hasReason(buttonCandidate, 'button') || hasReason(buttonCandidate, 'type')).toBe(true);\n  });\n\n  it('prefers elements with visual boundaries', () => {\n    const plain = document.createElement('div');\n    const bordered = document.createElement('div');\n    document.body.append(plain, bordered);\n\n    restores.push(mockBoundingClientRect(plain, { left: 0, top: 0, width: 100, height: 100 }));\n    restores.push(mockBoundingClientRect(bordered, { left: 0, top: 0, width: 100, height: 100 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([\n      [\n        bordered,\n        {\n          borderTopWidth: '1px',\n          borderRightWidth: '1px',\n          borderBottomWidth: '1px',\n          borderLeftWidth: '1px',\n          borderTopStyle: 'solid',\n          borderRightStyle: 'solid',\n          borderBottomStyle: 'solid',\n          borderLeftStyle: 'solid',\n        },\n      ],\n    ]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [plain, bordered],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(10, 10);\n\n    const borderedCandidate = getCandidate(candidates, bordered);\n    expect(borderedCandidate).toBeDefined();\n    expect(hasReason(borderedCandidate, 'border')).toBe(true);\n  });\n\n  it('penalizes tiny elements', () => {\n    const tiny = document.createElement('div');\n    const normal = document.createElement('div');\n    document.body.append(tiny, normal);\n\n    restores.push(mockBoundingClientRect(tiny, { left: 0, top: 0, width: 2, height: 2 }));\n    restores.push(mockBoundingClientRect(normal, { left: 0, top: 0, width: 100, height: 100 }));\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [tiny, normal],\n        getComputedStyle: () => ({}),\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(1, 1);\n\n    const tinyCandidate = getCandidate(candidates, tiny);\n    const normalCandidate = getCandidate(candidates, normal);\n\n    expect(tinyCandidate).toBeDefined();\n    expect(normalCandidate).toBeDefined();\n    // Tiny element should have lower score\n    expect((tinyCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true);\n  });\n\n  it('penalizes very large elements (viewport-sized)', () => {\n    const huge = document.createElement('div');\n    const normal = document.createElement('div');\n    document.body.append(huge, normal);\n\n    // Mock viewport as 800x600\n    restores.push(mockViewport(800, 600));\n    // Huge element takes 90% of viewport\n    restores.push(mockBoundingClientRect(huge, { left: 0, top: 0, width: 720, height: 540 }));\n    restores.push(mockBoundingClientRect(normal, { left: 10, top: 10, width: 100, height: 100 }));\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [normal, huge],\n        getComputedStyle: () => ({}),\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(50, 50);\n\n    const hugeCandidate = getCandidate(candidates, huge);\n    const normalCandidate = getCandidate(candidates, normal);\n    expect(hugeCandidate).toBeDefined();\n    expect(normalCandidate).toBeDefined();\n    // Large element should have lower score due to size penalty\n    expect((hugeCandidate?.score ?? 0) < (normalCandidate?.score ?? 0)).toBe(true);\n  });\n\n  it('excludes invisible elements', () => {\n    const hidden = document.createElement('div');\n    const visible = document.createElement('div');\n    document.body.append(hidden, visible);\n\n    restores.push(mockBoundingClientRect(hidden, { left: 0, top: 0, width: 100, height: 100 }));\n    restores.push(mockBoundingClientRect(visible, { left: 0, top: 0, width: 100, height: 100 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([\n      [hidden, { display: 'none' }],\n      [visible, { display: 'block' }],\n    ]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [hidden, visible],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const candidates = engine.getCandidatesAtPoint(50, 50);\n\n    // Hidden element should be excluded\n    expect(candidates.some((c) => c.element === hidden)).toBe(false);\n    expect(candidates.some((c) => c.element === visible)).toBe(true);\n  });\n});\n\n// =============================================================================\n// findBestTarget Tests\n// =============================================================================\n\ndescribe('selection-engine: findBestTarget', () => {\n  it('returns null when no elements are hit', () => {\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [],\n        getComputedStyle: () => ({}),\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBeNull();\n  });\n\n  it('returns the best scored element', () => {\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n    document.body.append(button);\n\n    restores.push(mockBoundingClientRect(button, { left: 0, top: 0, width: 120, height: 48 }));\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [button],\n        getComputedStyle: () => ({}),\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    expect(engine.findBestTarget(10, 10, NO_MODIFIERS)).toBe(button);\n  });\n\n  it('Alt modifier drills up to parent element', () => {\n    const panel = document.createElement('section');\n    panel.id = 'panel';\n\n    const wrapper = document.createElement('div');\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n\n    wrapper.append(button);\n    panel.append(wrapper);\n    document.body.append(panel);\n\n    restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 }));\n    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([\n      [panel, { paddingTop: '8px', paddingLeft: '8px' }],\n      [button, { cursor: 'pointer' }],\n    ]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [button, wrapper, panel],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const target = engine.findBestTarget(12, 12, { ...NO_MODIFIERS, alt: true });\n\n    // Should drill up past wrapper to panel (which has visual boundary via padding)\n    expect(target).toBe(panel);\n  });\n});\n\n// =============================================================================\n// findBestTargetFromEvent Tests\n// =============================================================================\n\ndescribe('selection-engine: findBestTargetFromEvent', () => {\n  it('Ctrl/Cmd selects the innermost visible element from composedPath', () => {\n    const wrapper = document.createElement('div');\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n    const inner = document.createElement('span');\n    inner.textContent = 'Inner';\n\n    button.append(inner);\n    wrapper.append(button);\n    document.body.append(wrapper);\n\n    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n    restores.push(mockBoundingClientRect(inner, { left: 14, top: 14, width: 50, height: 20 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([[button, { cursor: 'pointer' }]]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [inner, button, wrapper],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n\n    const event = createMockEvent({\n      clientX: 16,\n      clientY: 16,\n      path: [inner, button, wrapper, document.body, document],\n    });\n\n    const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, ctrl: true });\n    // Ctrl should select innermost visible element\n    expect(target).toBe(inner);\n  });\n\n  it('Alt in event-based selection drills up from best target', () => {\n    const panel = document.createElement('section');\n    panel.id = 'panel';\n\n    const wrapper = document.createElement('div');\n    const button = document.createElement('button');\n    button.tabIndex = 0;\n\n    wrapper.append(button);\n    panel.append(wrapper);\n    document.body.append(panel);\n\n    restores.push(mockBoundingClientRect(panel, { left: 0, top: 0, width: 400, height: 300 }));\n    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n\n    const styleByEl = new Map<Element, StyleOverrides>([\n      [panel, { paddingTop: '8px', paddingLeft: '8px' }],\n      [button, { cursor: 'pointer' }],\n    ]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [button, wrapper, panel],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n\n    const event = createMockEvent({\n      clientX: 12,\n      clientY: 12,\n      path: [button, wrapper, panel, document.body, document],\n    });\n\n    const target = engine.findBestTargetFromEvent(event, { ...NO_MODIFIERS, alt: true });\n    expect(target).toBe(panel);\n  });\n});\n\n// =============================================================================\n// getParentCandidate Tests\n// =============================================================================\n\ndescribe('selection-engine: getParentCandidate', () => {\n  it('returns null for body element', () => {\n    engine = createSelectionEngine({ isOverlayElement });\n    expect(engine.getParentCandidate(document.body)).toBeNull();\n  });\n\n  it('returns first non-wrapper ancestor', () => {\n    const section = document.createElement('section');\n    section.id = 'section';\n    const wrapper = document.createElement('div');\n    const button = document.createElement('button');\n\n    wrapper.append(button);\n    section.append(wrapper);\n    document.body.append(section);\n\n    restores.push(mockBoundingClientRect(section, { left: 0, top: 0, width: 400, height: 300 }));\n    restores.push(mockBoundingClientRect(wrapper, { left: 0, top: 0, width: 240, height: 160 }));\n    restores.push(mockBoundingClientRect(button, { left: 10, top: 10, width: 120, height: 48 }));\n\n    // Section has visual boundary\n    const styleByEl = new Map<Element, StyleOverrides>([\n      [section, { paddingTop: '8px', paddingLeft: '8px' }],\n    ]);\n\n    restores.push(\n      installDomMocks({\n        elementsFromPoint: () => [],\n        getComputedStyle: (el) => styleByEl.get(el) ?? {},\n      }),\n    );\n\n    engine = createSelectionEngine({ isOverlayElement });\n    const parent = engine.getParentCandidate(button);\n\n    // Should skip wrapper and return section\n    expect(parent).toBe(section);\n  });\n});\n\n// =============================================================================\n// dispose Tests\n// =============================================================================\n\ndescribe('selection-engine: dispose', () => {\n  it('can be called multiple times safely', () => {\n    engine = createSelectionEngine({ isOverlayElement });\n    expect(() => {\n      engine!.dispose();\n      engine!.dispose();\n    }).not.toThrow();\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/snap-engine.test.ts",
    "content": "/**\n * Unit tests for Web Editor V2 Snap Engine\n *\n * Tests cover:\n * - mergeAnchors: Anchor collection merging\n * - computeResizeSnap: Snap computation during resize\n * - computeDistanceLabels: Distance label generation\n *\n * All functions tested here are pure functions with no DOM dependencies,\n * making them ideal for unit testing.\n */\n\nimport { describe, expect, it } from 'vitest';\n\nimport {\n  computeDistanceLabels,\n  computeResizeSnap,\n  mergeAnchors,\n  type ComputeDistanceLabelsParams,\n  type ComputeResizeSnapParams,\n  type SnapAnchors,\n  type SnapLockX,\n  type SnapLockY,\n} from '@/entrypoints/web-editor-v2/core/snap-engine';\nimport type { ViewportRect } from '@/entrypoints/web-editor-v2/overlay/canvas-overlay';\n\n// =============================================================================\n// Test Utilities\n// =============================================================================\n\n/**\n * Creates a ViewportRect from coordinates and dimensions.\n */\nfunction rect(left: number, top: number, width: number, height: number): ViewportRect {\n  return { left, top, width, height };\n}\n\n/**\n * Default viewport dimensions for tests.\n */\nconst VIEWPORT = { width: 800, height: 600 };\n\n/**\n * Creates default params for computeResizeSnap tests.\n */\nfunction createSnapParams(overrides: Partial<ComputeResizeSnapParams>): ComputeResizeSnapParams {\n  return {\n    rect: rect(100, 100, 200, 150),\n    resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: false },\n    anchors: { x: [], y: [] },\n    thresholdPx: 6,\n    hysteresisPx: 2,\n    minSizePx: 10,\n    lockX: null,\n    lockY: null,\n    viewport: VIEWPORT,\n    ...overrides,\n  };\n}\n\n/**\n * Creates default params for computeDistanceLabels tests.\n */\nfunction createLabelParams(\n  overrides: Partial<ComputeDistanceLabelsParams>,\n): ComputeDistanceLabelsParams {\n  return {\n    rect: rect(100, 100, 200, 150),\n    lockX: null,\n    lockY: null,\n    viewport: VIEWPORT,\n    minGapPx: 1,\n    ...overrides,\n  };\n}\n\n// =============================================================================\n// mergeAnchors Tests\n// =============================================================================\n\ndescribe('snap-engine: mergeAnchors', () => {\n  it('returns empty anchors when called with no arguments', () => {\n    const result = mergeAnchors();\n    expect(result).toEqual({ x: [], y: [] });\n  });\n\n  it('returns the same anchors when called with single collection', () => {\n    const anchors: SnapAnchors = {\n      x: [{ type: 'left', value: 0, source: 'viewport' }],\n      y: [{ type: 'top', value: 0, source: 'viewport' }],\n    };\n\n    const result = mergeAnchors(anchors);\n\n    expect(result.x).toHaveLength(1);\n    expect(result.y).toHaveLength(1);\n  });\n\n  it('concatenates anchors from multiple collections in order', () => {\n    const collection1: SnapAnchors = {\n      x: [{ type: 'left', value: 0, source: 'viewport' }],\n      y: [{ type: 'top', value: 0, source: 'viewport' }],\n    };\n\n    const collection2: SnapAnchors = {\n      x: [{ type: 'center', value: 50, source: 'sibling', sourceRect: rect(40, 0, 20, 20) }],\n      y: [],\n    };\n\n    const collection3: SnapAnchors = {\n      x: [{ type: 'right', value: 100, source: 'sibling', sourceRect: rect(80, 0, 20, 20) }],\n      y: [{ type: 'bottom', value: 100, source: 'viewport' }],\n    };\n\n    const result = mergeAnchors(collection1, collection2, collection3);\n\n    expect(result.x).toHaveLength(3);\n    expect(result.y).toHaveLength(2);\n\n    // Verify order is preserved\n    expect(result.x[0]).toMatchObject({ type: 'left', value: 0 });\n    expect(result.x[1]).toMatchObject({ type: 'center', value: 50 });\n    expect(result.x[2]).toMatchObject({ type: 'right', value: 100 });\n  });\n\n  it('handles empty collections gracefully', () => {\n    const empty: SnapAnchors = { x: [], y: [] };\n    const nonEmpty: SnapAnchors = {\n      x: [{ type: 'left', value: 10, source: 'viewport' }],\n      y: [],\n    };\n\n    const result = mergeAnchors(empty, nonEmpty, empty);\n\n    expect(result.x).toHaveLength(1);\n    expect(result.y).toHaveLength(0);\n  });\n});\n\n// =============================================================================\n// computeResizeSnap Tests\n// =============================================================================\n\ndescribe('snap-engine: computeResizeSnap', () => {\n  describe('basic snapping', () => {\n    it('snaps west edge within threshold and emits vertical guide line', () => {\n      const params = createSnapParams({\n        rect: rect(103, 100, 197, 150), // left edge at 103\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 100, source: 'viewport' }], // anchor at 100\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Should snap left edge from 103 to 100 (distance 3 < threshold 6)\n      expect(result.snappedRect.left).toBe(100);\n      expect(result.snappedRect.width).toBe(200); // width adjusted\n      expect(result.lockX).toMatchObject({ type: 'left', value: 100, source: 'viewport' });\n      expect(result.lockY).toBeNull();\n      expect(result.guideLines).toHaveLength(1);\n      expect(result.guideLines[0]).toEqual({ x1: 100, y1: 0, x2: 100, y2: VIEWPORT.height });\n    });\n\n    it('snaps east edge within threshold', () => {\n      const params = createSnapParams({\n        rect: rect(100, 100, 197, 150), // right edge at 297\n        resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'right', value: 300, source: 'viewport' }], // anchor at 300\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Should snap right edge from 297 to 300\n      expect(result.snappedRect.left).toBe(100); // left unchanged\n      expect(result.snappedRect.width).toBe(200);\n      expect(result.lockX).toMatchObject({ type: 'right', value: 300 });\n    });\n\n    it('snaps north edge within threshold and emits horizontal guide line', () => {\n      const params = createSnapParams({\n        rect: rect(100, 104, 200, 146), // top edge at 104\n        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false },\n        anchors: {\n          x: [],\n          y: [{ type: 'top', value: 100, source: 'viewport' }],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect.top).toBe(100);\n      expect(result.snappedRect.height).toBe(150);\n      expect(result.lockY).toMatchObject({ type: 'top', value: 100 });\n      expect(result.guideLines).toHaveLength(1);\n      expect(result.guideLines[0]).toEqual({ x1: 0, y1: 100, x2: VIEWPORT.width, y2: 100 });\n    });\n\n    it('snaps south edge within threshold', () => {\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 147), // bottom edge at 247\n        resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true },\n        anchors: {\n          x: [],\n          y: [{ type: 'bottom', value: 250, source: 'viewport' }],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect.top).toBe(100);\n      expect(result.snappedRect.height).toBe(150);\n      expect(result.lockY).toMatchObject({ type: 'bottom', value: 250 });\n    });\n  });\n\n  describe('threshold behavior', () => {\n    it('does not snap when distance exceeds threshold', () => {\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 150),\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 90, source: 'viewport' }], // distance 10 > threshold 6\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect).toEqual(params.rect);\n      expect(result.lockX).toBeNull();\n      expect(result.guideLines).toEqual([]);\n    });\n\n    it('snaps at exactly the threshold distance', () => {\n      const params = createSnapParams({\n        rect: rect(106, 100, 194, 150),\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 100, source: 'viewport' }], // distance exactly 6\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect.left).toBe(100);\n      expect(result.lockX).not.toBeNull();\n    });\n  });\n\n  describe('anchor priority', () => {\n    it('prefers sibling anchors over viewport anchors at equal distance', () => {\n      // Both anchors at same value (100), same type (left), same distance from rect.left (103)\n      // When hasWest: true, we're moving the left edge, so 'left' type anchors are allowed\n      const siblingRect = rect(50, 0, 50, 50);\n      const params = createSnapParams({\n        rect: rect(103, 100, 197, 150), // left edge at 103\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [\n            { type: 'left', value: 100, source: 'viewport' }, // distance 3, left type\n            { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type\n          ],\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Sibling should be preferred over viewport at equal distance\n      expect(result.lockX?.source).toBe('sibling');\n      expect(result.snappedRect.left).toBe(100);\n    });\n\n    it('chooses closest anchor regardless of source when distances differ', () => {\n      // Both anchors have 'left' type (allowed for hasWest resize), but different distances\n      const siblingRect = rect(50, 0, 50, 50);\n      const params = createSnapParams({\n        rect: rect(103, 100, 197, 150), // left edge at 103\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [\n            { type: 'left', value: 102, source: 'viewport' }, // distance 1, left type\n            { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type\n          ],\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Closer anchor (viewport at 102) should be chosen despite sibling having priority at equal distance\n      expect(result.lockX?.source).toBe('viewport');\n      expect(result.snappedRect.left).toBe(102);\n    });\n  });\n\n  describe('hysteresis (lock stability)', () => {\n    it('maintains existing lock within threshold + hysteresis', () => {\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createSnapParams({\n        rect: rect(107, 100, 193, 150), // distance 7 from lock (threshold 6 + hysteresis 2 = 8)\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: { x: [], y: [] },\n        lockX,\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.lockX).toMatchObject({ type: 'left', value: 100 });\n      expect(result.snappedRect.left).toBe(100); // still snapped\n    });\n\n    it('releases lock when distance exceeds threshold + hysteresis', () => {\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createSnapParams({\n        rect: rect(109, 100, 191, 150), // distance 9 > threshold 6 + hysteresis 2\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: { x: [], y: [] },\n        lockX,\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.lockX).toBeNull();\n      expect(result.snappedRect.left).toBe(109); // no snap\n    });\n  });\n\n  describe('minimum size constraint', () => {\n    it('rejects snap that would violate minimum width', () => {\n      const params = createSnapParams({\n        rect: rect(100, 100, 20, 150),\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 115, source: 'viewport' }], // would make width = 5\n          y: [],\n        },\n        minSizePx: 10,\n        thresholdPx: 20, // large threshold to ensure snap would trigger\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.lockX).toBeNull();\n      expect(result.snappedRect).toEqual(params.rect);\n    });\n\n    it('rejects snap that would violate minimum height', () => {\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 15),\n        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false },\n        anchors: {\n          x: [],\n          y: [{ type: 'top', value: 110, source: 'viewport' }], // would make height = 5\n        },\n        minSizePx: 10,\n        thresholdPx: 20,\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.lockY).toBeNull();\n    });\n  });\n\n  describe('invalid rect handling', () => {\n    it('returns unchanged rect and clears locks for zero-width rect', () => {\n      const lockX: SnapLockX = { type: 'left', value: 0, source: 'viewport', sourceRect: null };\n      const params = createSnapParams({\n        rect: rect(0, 0, 0, 100),\n        lockX,\n        lockY: null,\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect).toEqual(params.rect);\n      expect(result.lockX).toBeNull();\n      expect(result.lockY).toBeNull();\n      expect(result.guideLines).toEqual([]);\n    });\n\n    it('returns unchanged rect for zero-height rect', () => {\n      const params = createSnapParams({\n        rect: rect(0, 0, 100, 0),\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect).toEqual(params.rect);\n    });\n  });\n\n  describe('multi-direction resize', () => {\n    it('snaps both X and Y axes simultaneously', () => {\n      const params = createSnapParams({\n        rect: rect(103, 97, 197, 153),\n        resize: { hasWest: true, hasEast: false, hasNorth: true, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 100, source: 'viewport' }],\n          y: [{ type: 'top', value: 100, source: 'viewport' }],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.snappedRect.left).toBe(100);\n      expect(result.snappedRect.top).toBe(100);\n      expect(result.lockX).not.toBeNull();\n      expect(result.lockY).not.toBeNull();\n      expect(result.guideLines).toHaveLength(2);\n    });\n  });\n\n  describe('center/middle anchor snapping', () => {\n    it('snaps to center anchor when resizing from west (left is fixed)', () => {\n      // When hasEast: true, fixedEdgeX = 'left', allowedTypes = ['right', 'center']\n      const params = createSnapParams({\n        rect: rect(100, 100, 198, 150), // center at 199, right at 298\n        resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'center', value: 200, source: 'viewport' }], // distance 1\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Center snapped to 200, so width = (200 - 100) * 2 = 200\n      expect(result.snappedRect.left).toBe(100); // left unchanged\n      expect(result.snappedRect.width).toBe(200);\n      expect(result.lockX).toMatchObject({ type: 'center', value: 200 });\n    });\n\n    it('snaps to middle anchor when resizing from south', () => {\n      // When hasSouth: true, fixedEdgeY = 'top', allowedTypes = ['bottom', 'middle']\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 148), // middle at 174, bottom at 248\n        resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true },\n        anchors: {\n          x: [],\n          y: [{ type: 'middle', value: 175, source: 'viewport' }], // distance 1\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Middle snapped to 175, so height = (175 - 100) * 2 = 150\n      expect(result.snappedRect.top).toBe(100);\n      expect(result.snappedRect.height).toBe(150);\n      expect(result.lockY).toMatchObject({ type: 'middle', value: 175 });\n    });\n  });\n\n  describe('lock invalidation', () => {\n    it('clears lock when its type is not allowed for current resize direction', () => {\n      // Lock was on 'right' but now we're resizing from east (which allows right/center)\n      // When hasWest: true, only 'left' and 'center' are allowed\n      const lockX: SnapLockX = {\n        type: 'right',\n        value: 300,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 150),\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: { x: [], y: [] },\n        lockX,\n      });\n\n      const result = computeResizeSnap(params);\n\n      // Lock should be cleared because 'right' is not in allowed types for west resize\n      expect(result.lockX).toBeNull();\n    });\n\n    it('clears lock when axis is not being resized', () => {\n      // X-axis lock but no X resize is happening\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createSnapParams({\n        rect: rect(100, 100, 200, 150),\n        resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false }, // only Y resize\n        anchors: { x: [], y: [] },\n        lockX,\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.lockX).toBeNull();\n    });\n  });\n\n  describe('sibling guide line extent', () => {\n    it('generates guide line spanning from source to target element for sibling snap', () => {\n      const siblingRect = rect(50, 20, 50, 60); // right edge at 100, bottom at 80\n      const params = createSnapParams({\n        rect: rect(103, 100, 197, 150), // top at 100, bottom at 250\n        resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false },\n        anchors: {\n          x: [{ type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }],\n          y: [],\n        },\n      });\n\n      const result = computeResizeSnap(params);\n\n      expect(result.guideLines).toHaveLength(1);\n      // Guide line should span from sibling's vertical extent to target's vertical extent\n      // min(sibling.top, target.top) to max(sibling.bottom, target.bottom)\n      expect(result.guideLines[0]).toEqual({\n        x1: 100,\n        y1: Math.min(siblingRect.top, 100), // 20\n        x2: 100,\n        y2: Math.max(80, 250), // 250\n      });\n    });\n  });\n});\n\n// =============================================================================\n// computeDistanceLabels Tests\n// =============================================================================\n\ndescribe('snap-engine: computeDistanceLabels', () => {\n  describe('sibling gap labels', () => {\n    it('computes vertical gap from X-axis sibling lock', () => {\n      const sourceRect = rect(100, 30, 50, 50); // bottom at 80\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 200, 150), // top at 100, gap = 20\n        lockX,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toHaveLength(1);\n      expect(labels[0]).toMatchObject({\n        kind: 'sibling',\n        axis: 'y',\n        value: 20,\n        text: '20px',\n      });\n      expect(labels[0]?.line).toEqual({ x1: 100, y1: 80, x2: 100, y2: 100 });\n    });\n\n    it('computes horizontal gap from Y-axis sibling lock', () => {\n      const sourceRect = rect(30, 100, 50, 50); // right at 80\n      const lockY: SnapLockY = {\n        type: 'top',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 200, 150), // left at 100, gap = 20\n        lockY,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toHaveLength(1);\n      expect(labels[0]).toMatchObject({\n        kind: 'sibling',\n        axis: 'x',\n        value: 20,\n        text: '20px',\n      });\n      expect(labels[0]?.line).toEqual({ x1: 80, y1: 100, x2: 100, y2: 100 });\n    });\n\n    it('hides labels for gaps below minGapPx', () => {\n      const sourceRect = rect(100, 99.5, 50, 0.3); // bottom at 99.8\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 200, 150), // gap = 0.2\n        lockX,\n        minGapPx: 1,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toEqual([]);\n    });\n\n    it('hides labels for zero gap (touching elements)', () => {\n      const sourceRect = rect(100, 50, 50, 50); // bottom at 100\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 200, 150), // top at 100, gap = 0\n        lockX,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toEqual([]);\n    });\n\n    it('computes vertical gap when target is above source (reverse direction)', () => {\n      const sourceRect = rect(100, 150, 50, 50); // top at 150\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 50, 200, 80), // bottom at 130, gap = 20\n        lockX,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toHaveLength(1);\n      expect(labels[0]).toMatchObject({\n        kind: 'sibling',\n        axis: 'y',\n        value: 20,\n        text: '20px',\n      });\n    });\n\n    it('computes horizontal gap when target is left of source (reverse direction)', () => {\n      const sourceRect = rect(250, 100, 50, 50); // left at 250\n      const lockY: SnapLockY = {\n        type: 'top',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 100, 150), // right at 200, gap = 50\n        lockY,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toHaveLength(1);\n      expect(labels[0]).toMatchObject({\n        kind: 'sibling',\n        axis: 'x',\n        value: 50,\n        text: '50px',\n      });\n    });\n\n    it('hides labels for overlapping elements (negative gap)', () => {\n      const sourceRect = rect(100, 80, 50, 50); // bottom at 130\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 100,\n        source: 'sibling',\n        sourceRect,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 100, 200, 150), // top at 100, overlaps with source\n        lockX,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      // Negative gap (overlap) should not produce labels\n      expect(labels).toEqual([]);\n    });\n  });\n\n  describe('viewport margin labels', () => {\n    it('shows viewport margin for X-axis viewport lock (left align)', () => {\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 50,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createLabelParams({\n        rect: rect(50, 100, 200, 150), // left=50, right=250, center at y=175\n        lockX,\n        viewport: { width: 800, height: 600 },\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels.length).toBeGreaterThanOrEqual(1);\n      const viewportLabel = labels.find((l) => l.kind === 'viewport');\n      expect(viewportLabel).toBeDefined();\n      expect(viewportLabel).toMatchObject({\n        kind: 'viewport',\n        axis: 'x',\n        value: 50, // left margin\n        text: '50px',\n      });\n      // Line should be horizontal from left edge of viewport to left edge of rect\n      expect(viewportLabel?.line).toEqual({ x1: 0, y1: 175, x2: 50, y2: 175 });\n    });\n\n    it('shows opposite margin when aligned margin is 0', () => {\n      const lockX: SnapLockX = {\n        type: 'left',\n        value: 0,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createLabelParams({\n        rect: rect(0, 100, 200, 150), // left margin = 0, right margin = 600\n        lockX,\n        viewport: { width: 800, height: 600 },\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      const viewportLabel = labels.find((l) => l.kind === 'viewport');\n      expect(viewportLabel).toBeDefined();\n      // Should show the right margin since left is 0\n      expect(viewportLabel?.value).toBe(600);\n    });\n\n    it('shows viewport margin for Y-axis viewport lock (top align)', () => {\n      const lockY: SnapLockY = {\n        type: 'top',\n        value: 50,\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 50, 200, 150), // top=50, bottom=200, center at x=200\n        lockY,\n        viewport: { width: 800, height: 600 },\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels.length).toBeGreaterThanOrEqual(1);\n      const viewportLabel = labels.find((l) => l.kind === 'viewport');\n      expect(viewportLabel).toBeDefined();\n      expect(viewportLabel).toMatchObject({\n        kind: 'viewport',\n        axis: 'y',\n        value: 50, // top margin\n        text: '50px',\n      });\n    });\n\n    it('shows both margins for center lock (X-axis)', () => {\n      const lockX: SnapLockX = {\n        type: 'center',\n        value: 400, // viewport center\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createLabelParams({\n        rect: rect(300, 100, 200, 150), // left=300, right=500, center=400\n        lockX,\n        viewport: { width: 800, height: 600 },\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      // Center lock should produce 2 viewport labels (left and right margins)\n      const viewportLabels = labels.filter((l) => l.kind === 'viewport');\n      expect(viewportLabels).toHaveLength(2);\n      expect(viewportLabels.map((l) => l.value).sort()).toEqual([300, 300]);\n    });\n\n    it('shows both margins for middle lock (Y-axis)', () => {\n      const lockY: SnapLockY = {\n        type: 'middle',\n        value: 300, // viewport middle\n        source: 'viewport',\n        sourceRect: null,\n      };\n\n      const params = createLabelParams({\n        rect: rect(100, 225, 200, 150), // top=225, bottom=375, middle=300\n        lockY,\n        viewport: { width: 800, height: 600 },\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      // Middle lock should produce 2 viewport labels (top and bottom margins)\n      const viewportLabels = labels.filter((l) => l.kind === 'viewport');\n      expect(viewportLabels).toHaveLength(2);\n      expect(viewportLabels.map((l) => l.value).sort()).toEqual([225, 225]);\n    });\n  });\n\n  describe('invalid rect handling', () => {\n    it('returns empty labels for zero-width rect', () => {\n      const params = createLabelParams({\n        rect: rect(0, 0, 0, 100),\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toEqual([]);\n    });\n\n    it('returns empty labels for zero-height rect', () => {\n      const params = createLabelParams({\n        rect: rect(0, 0, 100, 0),\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toEqual([]);\n    });\n  });\n\n  describe('no lock state', () => {\n    it('returns empty labels when no locks are active', () => {\n      const params = createLabelParams({\n        lockX: null,\n        lockY: null,\n      });\n\n      const labels = computeDistanceLabels(params);\n\n      expect(labels).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "app/chrome-extension/tests/web-editor-v2/test-utils/dom.ts",
    "content": "/**\n * DOM Mocking Utilities for Web Editor V2 Unit Tests\n *\n * These helpers patch DOM APIs that are missing or non-deterministic in jsdom\n * (e.g. elementsFromPoint, layout-dependent getBoundingClientRect).\n *\n * Usage:\n *   const restore = mockElementsFromPoint((x, y) => [element1, element2]);\n *   // run test\n *   restore();\n *\n * Or use installDomMocks() for batch installation with automatic cleanup.\n */\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Function to restore original state */\nexport type RestoreFn = () => void;\n\n/** Initialization data for a DOMRect */\nexport interface RectInit {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\n/** Handler for elementsFromPoint mock */\nexport type ElementsFromPointHandler = (x: number, y: number) => Element[];\n\n/** CSS property overrides for computed style mock */\nexport type StyleOverrides = Record<string, string | undefined>;\n\n/** Handler for getComputedStyle mock */\nexport type ComputedStyleHandler = (element: Element) => StyleOverrides | CSSStyleDeclaration;\n\n/** Options for creating a mock event with composedPath */\nexport interface MockEventOptions {\n  clientX?: number;\n  clientY?: number;\n  path: EventTarget[];\n}\n\n/** Batch mock configuration */\nexport interface DomMocks {\n  elementsFromPoint?: ElementsFromPointHandler;\n  getComputedStyle?: ComputedStyleHandler;\n}\n\n// =============================================================================\n// Default Style Values\n// =============================================================================\n\n/**\n * Default computed style values that match browser defaults.\n * These cover properties commonly accessed by SelectionEngine and PositionTracker.\n */\nconst DEFAULT_STYLE: Record<string, string> = {\n  // Display & visibility\n  display: 'block',\n  visibility: 'visible',\n  opacity: '1',\n  contentVisibility: 'visible',\n\n  // Background\n  backgroundColor: 'transparent',\n  backgroundImage: 'none',\n\n  // Border\n  borderTopWidth: '0px',\n  borderRightWidth: '0px',\n  borderBottomWidth: '0px',\n  borderLeftWidth: '0px',\n  borderTopStyle: 'none',\n  borderRightStyle: 'none',\n  borderBottomStyle: 'none',\n  borderLeftStyle: 'none',\n\n  // Effects\n  boxShadow: 'none',\n  outlineStyle: 'none',\n  outlineWidth: '0px',\n\n  // Spacing\n  paddingTop: '0px',\n  paddingRight: '0px',\n  paddingBottom: '0px',\n  paddingLeft: '0px',\n\n  // Cursor & position\n  cursor: 'auto',\n  position: 'static',\n\n  // Flex\n  flexDirection: 'row',\n};\n\n// =============================================================================\n// Internal Utilities\n// =============================================================================\n\n/**\n * Creates a DOMRectReadOnly-like object from init data.\n */\nfunction createRect(init: RectInit): DOMRectReadOnly {\n  const { left, top, width, height } = init;\n  const right = left + width;\n  const bottom = top + height;\n\n  return {\n    left,\n    top,\n    width,\n    height,\n    right,\n    bottom,\n    x: left,\n    y: top,\n    toJSON() {\n      return { left, top, width, height, right, bottom, x: left, y: top };\n    },\n  } as DOMRectReadOnly;\n}\n\n/**\n * Patches a property on an object and returns a restore function.\n */\nfunction patchProperty(target: object, key: string, value: unknown): RestoreFn {\n  const descriptor = Object.getOwnPropertyDescriptor(target, key);\n\n  Object.defineProperty(target, key, {\n    value,\n    configurable: true,\n    writable: true,\n  });\n\n  return () => {\n    if (descriptor) {\n      Object.defineProperty(target, key, descriptor);\n    } else {\n      delete (target as Record<string, unknown>)[key];\n    }\n  };\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Creates a CSSStyleDeclaration-like object with the given overrides.\n */\nexport function createComputedStyle(overrides: StyleOverrides = {}): CSSStyleDeclaration {\n  const values: Record<string, string> = { ...DEFAULT_STYLE };\n\n  for (const [key, value] of Object.entries(overrides)) {\n    if (typeof value === 'string') {\n      values[key] = value;\n    }\n  }\n\n  const style = {\n    ...values,\n    getPropertyValue(prop: string): string {\n      return values[prop] ?? '';\n    },\n    // Add commonly accessed methods to prevent errors\n    getPropertyPriority(): string {\n      return '';\n    },\n    length: 0,\n    item(): string {\n      return '';\n    },\n  };\n\n  return style as unknown as CSSStyleDeclaration;\n}\n\n/**\n * Patches an element's getBoundingClientRect() to return a fixed rect.\n *\n * @example\n * const restore = mockBoundingClientRect(element, { left: 10, top: 20, width: 100, height: 50 });\n * expect(element.getBoundingClientRect().left).toBe(10);\n * restore();\n */\nexport function mockBoundingClientRect(element: Element, rect: RectInit): RestoreFn {\n  const domRect = createRect(rect);\n  return patchProperty(element, 'getBoundingClientRect', () => domRect);\n}\n\n/**\n * Patches document.elementsFromPoint and document.elementFromPoint.\n *\n * SelectionEngine prefers elementsFromPoint when available, so both must be mocked.\n *\n * @example\n * const restore = mockElementsFromPoint((x, y) => {\n *   if (x < 100) return [elementA, elementB];\n *   return [elementC];\n * });\n */\nexport function mockElementsFromPoint(handler: ElementsFromPointHandler): RestoreFn {\n  const restoreElements = patchProperty(document, 'elementsFromPoint', (x: number, y: number) =>\n    handler(x, y),\n  );\n\n  const restoreElement = patchProperty(document, 'elementFromPoint', (x: number, y: number) => {\n    const elements = handler(x, y);\n    return elements[0] ?? null;\n  });\n\n  return () => {\n    restoreElement();\n    restoreElements();\n  };\n}\n\n/**\n * Patches window.getComputedStyle.\n *\n * The handler can return either StyleOverrides (merged with defaults) or a full CSSStyleDeclaration.\n *\n * @example\n * const restore = mockGetComputedStyle((el) => ({\n *   display: 'flex',\n *   backgroundColor: 'rgb(255, 0, 0)',\n * }));\n */\nexport function mockGetComputedStyle(handler: ComputedStyleHandler): RestoreFn {\n  return patchProperty(window, 'getComputedStyle', (element: Element) => {\n    const result = handler(element);\n\n    // If handler returned a full CSSStyleDeclaration, use it directly\n    if (result && typeof (result as CSSStyleDeclaration).getPropertyValue === 'function') {\n      return result as CSSStyleDeclaration;\n    }\n\n    // Otherwise, merge with defaults\n    return createComputedStyle(result as StyleOverrides);\n  });\n}\n\n/**\n * Creates a minimal Event-like object with composedPath() support.\n *\n * Useful for testing findBestTargetFromEvent() which relies on composedPath()\n * to access Shadow DOM internals.\n *\n * @example\n * const event = createMockEvent({ clientX: 100, clientY: 200, path: [button, div, document] });\n */\nexport function createMockEvent(options: MockEventOptions): Event {\n  const { clientX = 0, clientY = 0, path } = options;\n\n  return {\n    clientX,\n    clientY,\n    composedPath: () => path,\n    // Add common event properties to prevent errors\n    type: 'click',\n    target: path[0] ?? null,\n    currentTarget: null,\n    bubbles: true,\n    cancelable: true,\n    defaultPrevented: false,\n    eventPhase: 0,\n    isTrusted: false,\n    timeStamp: Date.now(),\n    preventDefault: () => {},\n    stopPropagation: () => {},\n    stopImmediatePropagation: () => {},\n  } as unknown as Event;\n}\n\n/**\n * Installs multiple DOM mocks at once and returns a single restore function.\n *\n * Restores are called in reverse order to handle dependencies correctly.\n *\n * @example\n * const restore = installDomMocks({\n *   elementsFromPoint: (x, y) => [element],\n *   getComputedStyle: (el) => ({ display: 'block' }),\n * });\n *\n * // In afterEach:\n * restore();\n */\nexport function installDomMocks(mocks: DomMocks): RestoreFn {\n  const restores: RestoreFn[] = [];\n\n  if (mocks.elementsFromPoint) {\n    restores.push(mockElementsFromPoint(mocks.elementsFromPoint));\n  }\n\n  if (mocks.getComputedStyle) {\n    restores.push(mockGetComputedStyle(mocks.getComputedStyle));\n  }\n\n  return () => {\n    // Restore in reverse order\n    for (let i = restores.length - 1; i >= 0; i--) {\n      restores[i]!();\n    }\n  };\n}\n\n/**\n * Sets up mock viewport dimensions.\n *\n * Useful for snap-engine tests that rely on window.innerWidth/innerHeight.\n */\nexport function mockViewport(width: number, height: number): RestoreFn {\n  const restoreWidth = patchProperty(window, 'innerWidth', width);\n  const restoreHeight = patchProperty(window, 'innerHeight', height);\n\n  return () => {\n    restoreHeight();\n    restoreWidth();\n  };\n}\n"
  },
  {
    "path": "app/chrome-extension/tsconfig.json",
    "content": "{\n  \"extends\": \"./.wxt/tsconfig.json\"\n}\n"
  },
  {
    "path": "app/chrome-extension/types/gifenc.d.ts",
    "content": "/**\n * Type declarations for gifenc library\n * @see https://github.com/mattdesl/gifenc\n */\n\ndeclare module 'gifenc' {\n  export interface GIFEncoderOptions {\n    auto?: boolean;\n  }\n\n  export interface WriteFrameOptions {\n    palette: number[];\n    delay?: number;\n    transparent?: boolean;\n    transparentIndex?: number;\n    dispose?: number;\n  }\n\n  export interface GIFEncoder {\n    writeFrame(\n      index: Uint8Array | Uint8ClampedArray,\n      width: number,\n      height: number,\n      options: WriteFrameOptions,\n    ): void;\n    finish(): void;\n    bytes(): Uint8Array;\n    bytesView(): Uint8Array;\n    reset(): void;\n  }\n\n  export function GIFEncoder(options?: GIFEncoderOptions): GIFEncoder;\n\n  export interface QuantizeOptions {\n    format?: 'rgb565' | 'rgba4444' | 'rgb444';\n    oneBitAlpha?: boolean | number;\n    clearAlpha?: boolean;\n    clearAlphaColor?: number;\n    clearAlphaThreshold?: number;\n  }\n\n  export function quantize(\n    rgba: Uint8Array | Uint8ClampedArray,\n    maxColors: number,\n    options?: QuantizeOptions,\n  ): number[];\n\n  export function applyPalette(\n    rgba: Uint8Array | Uint8ClampedArray,\n    palette: number[],\n    format?: 'rgb565' | 'rgba4444' | 'rgb444',\n  ): Uint8Array;\n\n  export function nearestColorIndex(palette: number[], pixel: number[]): number;\n\n  export function nearestColorIndexWithDistance(\n    palette: number[],\n    pixel: number[],\n  ): [number, number];\n\n  export function snapColorsToPalette(\n    palette: number[],\n    knownColors: number[][],\n    threshold?: number,\n  ): void;\n\n  export function prequantize(\n    rgba: Uint8Array | Uint8ClampedArray,\n    options?: { roundRGB?: number; roundAlpha?: number; oneBitAlpha?: boolean | number },\n  ): void;\n}\n"
  },
  {
    "path": "app/chrome-extension/types/icons.d.ts",
    "content": "// Type shim for unplugin-icons virtual modules used as Vue components\n// Keeps TS happy in IDE and during type-check without generating code.\ndeclare module '~icons/*' {\n  import type { DefineComponent } from 'vue';\n  // Use explicit, non-empty object types to satisfy eslint rule\n  const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>;\n  export default component;\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/cdp-session-manager.ts",
    "content": "import { TOOL_NAMES } from 'chrome-mcp-shared';\n\ntype OwnerTag = string;\n\ninterface TabSessionState {\n  refCount: number;\n  owners: Set<OwnerTag>;\n  attachedByUs: boolean;\n}\n\nconst DEBUGGER_PROTOCOL_VERSION = '1.3';\n\nclass CDPSessionManager {\n  private sessions = new Map<number, TabSessionState>();\n\n  private getState(tabId: number): TabSessionState | undefined {\n    return this.sessions.get(tabId);\n  }\n\n  private setState(tabId: number, state: TabSessionState) {\n    this.sessions.set(tabId, state);\n  }\n\n  async attach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {\n    const state = this.getState(tabId);\n    if (state && state.attachedByUs) {\n      state.refCount += 1;\n      state.owners.add(owner);\n      return;\n    }\n\n    // Check existing attachments\n    const targets = await chrome.debugger.getTargets();\n    const existing = targets.find((t) => t.tabId === tabId && t.attached);\n    if (existing) {\n      if (existing.extensionId === chrome.runtime.id) {\n        // Already attached by us (e.g., previous tool). Adopt and refcount.\n        this.setState(tabId, {\n          refCount: state ? state.refCount + 1 : 1,\n          owners: new Set([...(state?.owners || []), owner]),\n          attachedByUs: true,\n        });\n        return;\n      }\n      // Another client (DevTools/other extension) is attached\n      throw new Error(\n        `Debugger is already attached to tab ${tabId} by another client (e.g., DevTools/extension)`,\n      );\n    }\n\n    // Attach freshly\n    await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);\n    this.setState(tabId, { refCount: 1, owners: new Set([owner]), attachedByUs: true });\n  }\n\n  async detach(tabId: number, owner: OwnerTag = 'unknown'): Promise<void> {\n    const state = this.getState(tabId);\n    if (!state) return; // Nothing to do\n\n    // Update ownership/refcount\n    if (state.owners.has(owner)) state.owners.delete(owner);\n    state.refCount = Math.max(0, state.refCount - 1);\n\n    if (state.refCount > 0) {\n      // Still in use by other owners\n      return;\n    }\n\n    // We are the last owner\n    try {\n      if (state.attachedByUs) {\n        await chrome.debugger.detach({ tabId });\n      }\n    } catch (e) {\n      // Best-effort detach; ignore\n    } finally {\n      this.sessions.delete(tabId);\n    }\n  }\n\n  /**\n   * Convenience wrapper: ensures attach before fn, and balanced detach after.\n   */\n  async withSession<T>(tabId: number, owner: OwnerTag, fn: () => Promise<T>): Promise<T> {\n    await this.attach(tabId, owner);\n    try {\n      return await fn();\n    } finally {\n      await this.detach(tabId, owner);\n    }\n  }\n\n  /**\n   * Send a CDP command. Requires that this manager has attached to the tab.\n   * If not attached by us, will attempt a one-shot attach around the call.\n   */\n  async sendCommand<T = any>(tabId: number, method: string, params?: object): Promise<T> {\n    const state = this.getState(tabId);\n    if (state && state.attachedByUs) {\n      return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;\n    }\n    // Fallback: temporary session\n    return await this.withSession<T>(tabId, `send:${method}`, async () => {\n      return (await chrome.debugger.sendCommand({ tabId }, method, params)) as T;\n    });\n  }\n}\n\nexport const cdpSessionManager = new CDPSessionManager();\n"
  },
  {
    "path": "app/chrome-extension/utils/content-indexer.ts",
    "content": "/**\n * Content index manager\n * Responsible for automatically extracting, chunking and indexing tab content\n */\n\nimport { TextChunker } from './text-chunker';\nimport { VectorDatabase, getGlobalVectorDatabase } from './vector-database';\nimport {\n  SemanticSimilarityEngine,\n  SemanticSimilarityEngineProxy,\n  PREDEFINED_MODELS,\n  type ModelPreset,\n} from './semantic-similarity-engine';\nimport { TOOL_MESSAGE_TYPES } from '@/common/message-types';\n\nexport interface IndexingOptions {\n  autoIndex?: boolean;\n  maxChunksPerPage?: number;\n  skipDuplicates?: boolean;\n}\n\nexport class ContentIndexer {\n  private textChunker: TextChunker;\n  private vectorDatabase!: VectorDatabase;\n  private semanticEngine!: SemanticSimilarityEngine | SemanticSimilarityEngineProxy;\n  private isInitialized = false;\n  private isInitializing = false;\n  private initPromise: Promise<void> | null = null;\n  private indexedPages = new Set<string>();\n  private readonly options: Required<IndexingOptions>;\n\n  constructor(options?: IndexingOptions) {\n    this.options = {\n      autoIndex: true,\n      maxChunksPerPage: 50,\n      skipDuplicates: true,\n      ...options,\n    };\n\n    this.textChunker = new TextChunker();\n  }\n\n  /**\n   * Get current selected model configuration\n   */\n  private async getCurrentModelConfig() {\n    try {\n      const result = await chrome.storage.local.get(['selectedModel', 'selectedVersion']);\n      const selectedModel = (result.selectedModel as ModelPreset) || 'multilingual-e5-small';\n      const selectedVersion =\n        (result.selectedVersion as 'full' | 'quantized' | 'compressed') || 'quantized';\n\n      const modelInfo = PREDEFINED_MODELS[selectedModel];\n\n      return {\n        modelPreset: selectedModel,\n        modelIdentifier: modelInfo.modelIdentifier,\n        dimension: modelInfo.dimension,\n        modelVersion: selectedVersion,\n        useLocalFiles: false,\n        maxLength: 256,\n        cacheSize: 1000,\n        forceOffscreen: true,\n      };\n    } catch (error) {\n      console.error('ContentIndexer: Failed to get current model config, using default:', error);\n      return {\n        modelPreset: 'multilingual-e5-small' as const,\n        modelIdentifier: 'Xenova/multilingual-e5-small',\n        dimension: 384,\n        modelVersion: 'quantized' as const,\n        useLocalFiles: false,\n        maxLength: 256,\n        cacheSize: 1000,\n        forceOffscreen: true,\n      };\n    }\n  }\n\n  /**\n   * Initialize content indexer\n   */\n  public async initialize(): Promise<void> {\n    if (this.isInitialized) return;\n    if (this.isInitializing && this.initPromise) return this.initPromise;\n\n    this.isInitializing = true;\n    this.initPromise = this._doInitialize().finally(() => {\n      this.isInitializing = false;\n    });\n\n    return this.initPromise;\n  }\n\n  private async _doInitialize(): Promise<void> {\n    try {\n      // Get current selected model configuration\n      const engineConfig = await this.getCurrentModelConfig();\n\n      // Use proxy class to reuse engine instance in offscreen\n      this.semanticEngine = new SemanticSimilarityEngineProxy(engineConfig);\n      await this.semanticEngine.initialize();\n\n      this.vectorDatabase = await getGlobalVectorDatabase({\n        dimension: engineConfig.dimension,\n        efSearch: 50,\n      });\n      await this.vectorDatabase.initialize();\n\n      this.setupTabEventListeners();\n\n      this.isInitialized = true;\n    } catch (error) {\n      console.error('ContentIndexer: Initialization failed:', error);\n      this.isInitialized = false;\n      throw error;\n    }\n  }\n\n  /**\n   * Index content of specified tab\n   */\n  public async indexTabContent(tabId: number): Promise<void> {\n    // Check if semantic engine is ready before attempting to index\n    if (!this.isSemanticEngineReady() && !this.isSemanticEngineInitializing()) {\n      console.log(\n        `ContentIndexer: Skipping tab ${tabId} - semantic engine not ready and not initializing`,\n      );\n      return;\n    }\n\n    if (!this.isInitialized) {\n      // Only initialize if semantic engine is already ready\n      if (!this.isSemanticEngineReady()) {\n        console.log(\n          `ContentIndexer: Skipping tab ${tabId} - ContentIndexer not initialized and semantic engine not ready`,\n        );\n        return;\n      }\n      await this.initialize();\n    }\n\n    try {\n      const tab = await chrome.tabs.get(tabId);\n      if (!tab.url || !this.shouldIndexUrl(tab.url)) {\n        console.log(`ContentIndexer: Skipping tab ${tabId} - URL not indexable`);\n        return;\n      }\n\n      const pageKey = `${tab.url}_${tab.title}`;\n      if (this.options.skipDuplicates && this.indexedPages.has(pageKey)) {\n        console.log(`ContentIndexer: Skipping tab ${tabId} - already indexed`);\n        return;\n      }\n\n      console.log(`ContentIndexer: Starting to index tab ${tabId}: ${tab.title}`);\n\n      const content = await this.extractTabContent(tabId);\n      if (!content) {\n        console.log(`ContentIndexer: No content extracted from tab ${tabId}`);\n        return;\n      }\n\n      const chunks = this.textChunker.chunkText(content.textContent, content.title);\n      console.log(`ContentIndexer: Generated ${chunks.length} chunks for tab ${tabId}`);\n\n      const chunksToIndex = chunks.slice(0, this.options.maxChunksPerPage);\n      if (chunks.length > this.options.maxChunksPerPage) {\n        console.log(\n          `ContentIndexer: Limited chunks from ${chunks.length} to ${this.options.maxChunksPerPage}`,\n        );\n      }\n\n      for (const chunk of chunksToIndex) {\n        try {\n          const embedding = await this.semanticEngine.getEmbedding(chunk.text);\n          const label = await this.vectorDatabase.addDocument(\n            tabId,\n            tab.url!,\n            tab.title || '',\n            chunk,\n            embedding,\n          );\n          console.log(`ContentIndexer: Indexed chunk ${chunk.index} with label ${label}`);\n        } catch (error) {\n          console.error(`ContentIndexer: Failed to index chunk ${chunk.index}:`, error);\n        }\n      }\n\n      this.indexedPages.add(pageKey);\n\n      console.log(\n        `ContentIndexer: Successfully indexed ${chunksToIndex.length} chunks for tab ${tabId}`,\n      );\n    } catch (error) {\n      console.error(`ContentIndexer: Failed to index tab ${tabId}:`, error);\n    }\n  }\n\n  /**\n   * Search content\n   */\n  public async searchContent(query: string, topK: number = 10) {\n    // Check if semantic engine is ready before attempting to search\n    if (!this.isSemanticEngineReady() && !this.isSemanticEngineInitializing()) {\n      throw new Error(\n        'Semantic engine is not ready yet. Please initialize the semantic engine first.',\n      );\n    }\n\n    if (!this.isInitialized) {\n      // Only initialize if semantic engine is already ready\n      if (!this.isSemanticEngineReady()) {\n        throw new Error(\n          'ContentIndexer not initialized and semantic engine not ready. Please initialize the semantic engine first.',\n        );\n      }\n      await this.initialize();\n    }\n\n    try {\n      const queryEmbedding = await this.semanticEngine.getEmbedding(query);\n      const results = await this.vectorDatabase.search(queryEmbedding, topK);\n\n      console.log(`ContentIndexer: Found ${results.length} results for query: \"${query}\"`);\n      return results;\n    } catch (error) {\n      console.error('ContentIndexer: Search failed:', error);\n\n      if (error instanceof Error && error.message.includes('not initialized')) {\n        console.log(\n          'ContentIndexer: Attempting to reinitialize semantic engine and retry search...',\n        );\n        try {\n          await this.semanticEngine.initialize();\n          const queryEmbedding = await this.semanticEngine.getEmbedding(query);\n          const results = await this.vectorDatabase.search(queryEmbedding, topK);\n\n          console.log(\n            `ContentIndexer: Retry successful, found ${results.length} results for query: \"${query}\"`,\n          );\n          return results;\n        } catch (retryError) {\n          console.error('ContentIndexer: Retry after reinitialization also failed:', retryError);\n          throw retryError;\n        }\n      }\n\n      throw error;\n    }\n  }\n\n  /**\n   * Remove tab index\n   */\n  public async removeTabIndex(tabId: number): Promise<void> {\n    if (!this.isInitialized) {\n      return;\n    }\n\n    try {\n      await this.vectorDatabase.removeTabDocuments(tabId);\n\n      for (const pageKey of this.indexedPages) {\n        if (pageKey.includes(`tab_${tabId}_`)) {\n          this.indexedPages.delete(pageKey);\n        }\n      }\n\n      console.log(`ContentIndexer: Removed index for tab ${tabId}`);\n    } catch (error) {\n      console.error(`ContentIndexer: Failed to remove index for tab ${tabId}:`, error);\n    }\n  }\n\n  /**\n   * Check if semantic engine is ready (checks both local and global state)\n   */\n  public isSemanticEngineReady(): boolean {\n    return this.semanticEngine && this.semanticEngine.isInitialized;\n  }\n\n  /**\n   * Check if global semantic engine is ready (in background/offscreen)\n   */\n  public async isGlobalSemanticEngineReady(): Promise<boolean> {\n    try {\n      // Since ContentIndexer runs in background script, directly call the function instead of sending message\n      const { handleGetModelStatus } = await import('@/entrypoints/background/semantic-similarity');\n      const response = await handleGetModelStatus();\n      return (\n        response &&\n        response.success &&\n        response.status &&\n        response.status.initializationStatus === 'ready'\n      );\n    } catch (error) {\n      console.error('ContentIndexer: Failed to check global semantic engine status:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Check if semantic engine is initializing\n   */\n  public isSemanticEngineInitializing(): boolean {\n    return (\n      this.isInitializing || (this.semanticEngine && (this.semanticEngine as any).isInitializing)\n    );\n  }\n\n  /**\n   * Reinitialize content indexer (for model switching)\n   */\n  public async reinitialize(): Promise<void> {\n    console.log('ContentIndexer: Reinitializing for model switch...');\n\n    this.isInitialized = false;\n    this.isInitializing = false;\n    this.initPromise = null;\n\n    await this.performCompleteDataCleanupForModelSwitch();\n\n    this.indexedPages.clear();\n    console.log('ContentIndexer: Cleared indexed pages cache');\n\n    try {\n      console.log('ContentIndexer: Creating new semantic engine proxy...');\n      const newEngineConfig = await this.getCurrentModelConfig();\n      console.log('ContentIndexer: New engine config:', newEngineConfig);\n\n      this.semanticEngine = new SemanticSimilarityEngineProxy(newEngineConfig);\n      console.log('ContentIndexer: New semantic engine proxy created');\n\n      await this.semanticEngine.initialize();\n      console.log('ContentIndexer: Semantic engine proxy initialization completed');\n    } catch (error) {\n      console.error('ContentIndexer: Failed to create new semantic engine proxy:', error);\n      throw error;\n    }\n\n    console.log(\n      'ContentIndexer: New semantic engine proxy is ready, proceeding with initialization',\n    );\n\n    await this.initialize();\n\n    console.log('ContentIndexer: Reinitialization completed successfully');\n  }\n\n  /**\n   * Perform complete data cleanup for model switching\n   */\n  private async performCompleteDataCleanupForModelSwitch(): Promise<void> {\n    console.log('ContentIndexer: Starting complete data cleanup for model switch...');\n\n    try {\n      // Clear existing vector database instance\n      if (this.vectorDatabase) {\n        try {\n          console.log('ContentIndexer: Clearing existing vector database instance...');\n          await this.vectorDatabase.clear();\n          console.log('ContentIndexer: Vector database instance cleared successfully');\n        } catch (error) {\n          console.warn('ContentIndexer: Failed to clear vector database instance:', error);\n        }\n      }\n\n      try {\n        const { clearAllVectorData } = await import('./vector-database');\n        await clearAllVectorData();\n        console.log('ContentIndexer: Cleared all vector data for model switch');\n      } catch (error) {\n        console.warn('ContentIndexer: Failed to clear vector data:', error);\n      }\n\n      try {\n        const keysToRemove = [\n          'hnswlib_document_mappings_tab_content_index.dat',\n          'hnswlib_document_mappings_content_index.dat',\n          'hnswlib_document_mappings_vector_index.dat',\n          'vectorDatabaseStats',\n          'lastCleanupTime',\n        ];\n        await chrome.storage.local.remove(keysToRemove);\n        console.log('ContentIndexer: Cleared chrome.storage model-related data');\n      } catch (error) {\n        console.warn('ContentIndexer: Failed to clear chrome.storage data:', error);\n      }\n\n      try {\n        const deleteVectorDB = indexedDB.deleteDatabase('VectorDatabaseStorage');\n        await new Promise<void>((resolve) => {\n          deleteVectorDB.onsuccess = () => {\n            console.log('ContentIndexer: VectorDatabaseStorage database deleted');\n            resolve();\n          };\n          deleteVectorDB.onerror = () => {\n            console.warn('ContentIndexer: Failed to delete VectorDatabaseStorage database');\n            resolve(); // Don't block the process\n          };\n          deleteVectorDB.onblocked = () => {\n            console.warn('ContentIndexer: VectorDatabaseStorage database deletion blocked');\n            resolve(); // Don't block the process\n          };\n        });\n\n        // Clean up hnswlib-index database\n        const deleteHnswDB = indexedDB.deleteDatabase('/hnswlib-index');\n        await new Promise<void>((resolve) => {\n          deleteHnswDB.onsuccess = () => {\n            console.log('ContentIndexer: /hnswlib-index database deleted');\n            resolve();\n          };\n          deleteHnswDB.onerror = () => {\n            console.warn('ContentIndexer: Failed to delete /hnswlib-index database');\n            resolve(); // Don't block the process\n          };\n          deleteHnswDB.onblocked = () => {\n            console.warn('ContentIndexer: /hnswlib-index database deletion blocked');\n            resolve(); // Don't block the process\n          };\n        });\n\n        console.log('ContentIndexer: All IndexedDB databases cleared for model switch');\n      } catch (error) {\n        console.warn('ContentIndexer: Failed to clear IndexedDB databases:', error);\n      }\n\n      console.log('ContentIndexer: Complete data cleanup for model switch finished successfully');\n    } catch (error) {\n      console.error('ContentIndexer: Complete data cleanup for model switch failed:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Manually trigger semantic engine initialization (async, don't wait for completion)\n   * Note: This should only be called after the semantic engine is already initialized\n   */\n  public startSemanticEngineInitialization(): void {\n    if (!this.isInitialized && !this.isInitializing) {\n      console.log('ContentIndexer: Checking if semantic engine is ready...');\n\n      // Check if global semantic engine is ready before initializing ContentIndexer\n      this.isGlobalSemanticEngineReady()\n        .then((isReady) => {\n          if (isReady) {\n            console.log('ContentIndexer: Starting initialization (semantic engine ready)...');\n            this.initialize().catch((error) => {\n              console.error('ContentIndexer: Background initialization failed:', error);\n            });\n          } else {\n            console.log('ContentIndexer: Semantic engine not ready, skipping initialization');\n          }\n        })\n        .catch((error) => {\n          console.error('ContentIndexer: Failed to check semantic engine status:', error);\n        });\n    }\n  }\n\n  /**\n   * Get indexing statistics\n   */\n  public getStats() {\n    const vectorStats = this.vectorDatabase\n      ? this.vectorDatabase.getStats()\n      : {\n          totalDocuments: 0,\n          totalTabs: 0,\n          indexSize: 0,\n        };\n\n    return {\n      ...vectorStats,\n      indexedPages: this.indexedPages.size,\n      isInitialized: this.isInitialized,\n      semanticEngineReady: this.isSemanticEngineReady(),\n      semanticEngineInitializing: this.isSemanticEngineInitializing(),\n    };\n  }\n\n  /**\n   * Clear all indexes\n   */\n  public async clearAllIndexes(): Promise<void> {\n    if (!this.isInitialized) {\n      return;\n    }\n\n    try {\n      await this.vectorDatabase.clear();\n      this.indexedPages.clear();\n      console.log('ContentIndexer: All indexes cleared');\n    } catch (error) {\n      console.error('ContentIndexer: Failed to clear indexes:', error);\n    }\n  }\n  private setupTabEventListeners(): void {\n    chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {\n      if (this.options.autoIndex && changeInfo.status === 'complete' && tab.url) {\n        setTimeout(() => {\n          if (!this.isSemanticEngineReady() && !this.isSemanticEngineInitializing()) {\n            console.log(\n              `ContentIndexer: Skipping auto-index for tab ${tabId} - semantic engine not ready`,\n            );\n            return;\n          }\n\n          this.indexTabContent(tabId).catch((error) => {\n            console.error(`ContentIndexer: Auto-indexing failed for tab ${tabId}:`, error);\n          });\n        }, 2000);\n      }\n    });\n\n    chrome.tabs.onRemoved.addListener(async (tabId) => {\n      await this.removeTabIndex(tabId);\n    });\n\n    if (chrome.webNavigation) {\n      chrome.webNavigation.onCommitted.addListener(async (details) => {\n        if (details.frameId === 0) {\n          await this.removeTabIndex(details.tabId);\n        }\n      });\n    }\n  }\n\n  private shouldIndexUrl(url: string): boolean {\n    const excludePatterns = [\n      /^chrome:\\/\\//,\n      /^chrome-extension:\\/\\//,\n      /^edge:\\/\\//,\n      /^about:/,\n      /^moz-extension:\\/\\//,\n      /^file:\\/\\//,\n    ];\n\n    return !excludePatterns.some((pattern) => pattern.test(url));\n  }\n\n  private async extractTabContent(\n    tabId: number,\n  ): Promise<{ textContent: string; title: string } | null> {\n    try {\n      await chrome.scripting.executeScript({\n        target: { tabId },\n        files: ['inject-scripts/web-fetcher-helper.js'],\n      });\n\n      const response = await chrome.tabs.sendMessage(tabId, {\n        action: TOOL_MESSAGE_TYPES.WEB_FETCHER_GET_TEXT_CONTENT,\n      });\n\n      if (response.success && response.textContent) {\n        return {\n          textContent: response.textContent,\n          title: response.title || '',\n        };\n      } else {\n        console.error(\n          `ContentIndexer: Failed to extract content from tab ${tabId}:`,\n          response.error,\n        );\n        return null;\n      }\n    } catch (error) {\n      console.error(`ContentIndexer: Error extracting content from tab ${tabId}:`, error);\n      return null;\n    }\n  }\n}\n\nlet globalContentIndexer: ContentIndexer | null = null;\n\n/**\n * Get global ContentIndexer instance\n */\nexport function getGlobalContentIndexer(): ContentIndexer {\n  if (!globalContentIndexer) {\n    globalContentIndexer = new ContentIndexer();\n  }\n  return globalContentIndexer;\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/i18n.ts",
    "content": "/**\n * Chrome Extension i18n utility\n * Provides safe access to chrome.i18n.getMessage with fallbacks\n */\n\n// Fallback messages for when Chrome APIs aren't available (English)\nconst fallbackMessages: Record<string, string> = {\n  // Extension metadata\n  extensionName: 'chrome-mcp-server',\n  extensionDescription: 'Exposes browser capabilities with your own chrome',\n\n  // Section headers\n  nativeServerConfigLabel: 'Native Server Configuration',\n  semanticEngineLabel: 'Semantic Engine',\n  embeddingModelLabel: 'Embedding Model',\n  indexDataManagementLabel: 'Index Data Management',\n  modelCacheManagementLabel: 'Model Cache Management',\n\n  // Status labels\n  statusLabel: 'Status',\n  runningStatusLabel: 'Running Status',\n  connectionStatusLabel: 'Connection Status',\n  lastUpdatedLabel: 'Last Updated:',\n\n  // Connection states\n  connectButton: 'Connect',\n  disconnectButton: 'Disconnect',\n  connectingStatus: 'Connecting...',\n  connectedStatus: 'Connected',\n  disconnectedStatus: 'Disconnected',\n  detectingStatus: 'Detecting...',\n\n  // Server states\n  serviceRunningStatus: 'Service Running (Port: {0})',\n  serviceNotConnectedStatus: 'Service Not Connected',\n  connectedServiceNotStartedStatus: 'Connected, Service Not Started',\n\n  // Configuration labels\n  mcpServerConfigLabel: 'MCP Server Configuration',\n  connectionPortLabel: 'Connection Port',\n  refreshStatusButton: 'Refresh Status',\n  copyConfigButton: 'Copy Configuration',\n\n  // Action buttons\n  retryButton: 'Retry',\n  cancelButton: 'Cancel',\n  confirmButton: 'Confirm',\n  saveButton: 'Save',\n  closeButton: 'Close',\n  resetButton: 'Reset',\n\n  // Progress states\n  initializingStatus: 'Initializing...',\n  processingStatus: 'Processing...',\n  loadingStatus: 'Loading...',\n  clearingStatus: 'Clearing...',\n  cleaningStatus: 'Cleaning...',\n  downloadingStatus: 'Downloading...',\n\n  // Semantic engine states\n  semanticEngineReadyStatus: 'Semantic Engine Ready',\n  semanticEngineInitializingStatus: 'Semantic Engine Initializing...',\n  semanticEngineInitFailedStatus: 'Semantic Engine Initialization Failed',\n  semanticEngineNotInitStatus: 'Semantic Engine Not Initialized',\n  initSemanticEngineButton: 'Initialize Semantic Engine',\n  reinitializeButton: 'Reinitialize',\n\n  // Model states\n  downloadingModelStatus: 'Downloading Model... {0}%',\n  switchingModelStatus: 'Switching Model...',\n  modelLoadedStatus: 'Model Loaded',\n  modelFailedStatus: 'Model Failed to Load',\n\n  // Model descriptions\n  lightweightModelDescription: 'Lightweight Multilingual Model',\n  betterThanSmallDescription: 'Slightly larger than e5-small, but better performance',\n  multilingualModelDescription: 'Multilingual Semantic Model',\n\n  // Performance levels\n  fastPerformance: 'Fast',\n  balancedPerformance: 'Balanced',\n  accuratePerformance: 'Accurate',\n\n  // Error messages\n  networkErrorMessage: 'Network connection error, please check network and retry',\n  modelCorruptedErrorMessage: 'Model file corrupted or incomplete, please retry download',\n  unknownErrorMessage: 'Unknown error, please check if your network can access HuggingFace',\n  permissionDeniedErrorMessage: 'Permission denied',\n  timeoutErrorMessage: 'Operation timed out',\n\n  // Data statistics\n  indexedPagesLabel: 'Indexed Pages',\n  indexSizeLabel: 'Index Size',\n  activeTabsLabel: 'Active Tabs',\n  vectorDocumentsLabel: 'Vector Documents',\n  cacheSizeLabel: 'Cache Size',\n  cacheEntriesLabel: 'Cache Entries',\n\n  // Data management\n  clearAllDataButton: 'Clear All Data',\n  clearAllCacheButton: 'Clear All Cache',\n  cleanExpiredCacheButton: 'Clean Expired Cache',\n  exportDataButton: 'Export Data',\n  importDataButton: 'Import Data',\n\n  // Dialog titles\n  confirmClearDataTitle: 'Confirm Clear Data',\n  settingsTitle: 'Settings',\n  aboutTitle: 'About',\n  helpTitle: 'Help',\n\n  // Dialog messages\n  clearDataWarningMessage:\n    'This operation will clear all indexed webpage content and vector data, including:',\n  clearDataList1: 'All webpage text content index',\n  clearDataList2: 'Vector embedding data',\n  clearDataList3: 'Search history and cache',\n  clearDataIrreversibleWarning:\n    'This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.',\n  confirmClearButton: 'Confirm Clear',\n\n  // Cache states\n  cacheDetailsLabel: 'Cache Details',\n  noCacheDataMessage: 'No cache data',\n  loadingCacheInfoStatus: 'Loading cache information...',\n  processingCacheStatus: 'Processing cache...',\n  expiredLabel: 'Expired',\n\n  // Browser integration\n  bookmarksBarLabel: 'Bookmarks Bar',\n  newTabLabel: 'New Tab',\n  currentPageLabel: 'Current Page',\n\n  // Accessibility\n  menuLabel: 'Menu',\n  navigationLabel: 'Navigation',\n  mainContentLabel: 'Main Content',\n\n  // Future features\n  languageSelectorLabel: 'Language',\n  themeLabel: 'Theme',\n  lightTheme: 'Light',\n  darkTheme: 'Dark',\n  autoTheme: 'Auto',\n  advancedSettingsLabel: 'Advanced Settings',\n  debugModeLabel: 'Debug Mode',\n  verboseLoggingLabel: 'Verbose Logging',\n\n  // Notifications\n  successNotification: 'Operation completed successfully',\n  warningNotification: 'Warning: Please review before proceeding',\n  infoNotification: 'Information',\n  configCopiedNotification: 'Configuration copied to clipboard',\n  dataClearedNotification: 'Data cleared successfully',\n\n  // Units\n  bytesUnit: 'bytes',\n  kilobytesUnit: 'KB',\n  megabytesUnit: 'MB',\n  gigabytesUnit: 'GB',\n  itemsUnit: 'items',\n  pagesUnit: 'pages',\n\n  // Legacy keys for backwards compatibility\n  nativeServerConfig: 'Native Server Configuration',\n  runningStatus: 'Running Status',\n  refreshStatus: 'Refresh Status',\n  lastUpdated: 'Last Updated:',\n  mcpServerConfig: 'MCP Server Configuration',\n  connectionPort: 'Connection Port',\n  connecting: 'Connecting...',\n  disconnect: 'Disconnect',\n  connect: 'Connect',\n  semanticEngine: 'Semantic Engine',\n  embeddingModel: 'Embedding Model',\n  retry: 'Retry',\n  indexDataManagement: 'Index Data Management',\n  clearing: 'Clearing...',\n  clearAllData: 'Clear All Data',\n  copyConfig: 'Copy Configuration',\n  serviceRunning: 'Service Running (Port: {0})',\n  connectedServiceNotStarted: 'Connected, Service Not Started',\n  serviceNotConnected: 'Service Not Connected',\n  detecting: 'Detecting...',\n  lightweightModel: 'Lightweight Multilingual Model',\n  betterThanSmall: 'Slightly larger than e5-small, but better performance',\n  multilingualModel: 'Multilingual Semantic Model',\n  fast: 'Fast',\n  balanced: 'Balanced',\n  accurate: 'Accurate',\n  semanticEngineReady: 'Semantic Engine Ready',\n  semanticEngineInitializing: 'Semantic Engine Initializing...',\n  semanticEngineInitFailed: 'Semantic Engine Initialization Failed',\n  semanticEngineNotInit: 'Semantic Engine Not Initialized',\n  downloadingModel: 'Downloading Model... {0}%',\n  switchingModel: 'Switching Model...',\n  networkError: 'Network connection error, please check network and retry',\n  modelCorrupted: 'Model file corrupted or incomplete, please retry download',\n  unknownError: 'Unknown error, please check if your network can access HuggingFace',\n  reinitialize: 'Reinitialize',\n  initializing: 'Initializing...',\n  initSemanticEngine: 'Initialize Semantic Engine',\n  indexedPages: 'Indexed Pages',\n  indexSize: 'Index Size',\n  activeTabs: 'Active Tabs',\n  vectorDocuments: 'Vector Documents',\n  confirmClearData: 'Confirm Clear Data',\n  clearDataWarning:\n    'This operation will clear all indexed webpage content and vector data, including:',\n  clearDataIrreversible:\n    'This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.',\n  confirmClear: 'Confirm Clear',\n  cancel: 'Cancel',\n  confirm: 'Confirm',\n  processing: 'Processing...',\n  modelCacheManagement: 'Model Cache Management',\n  cacheSize: 'Cache Size',\n  cacheEntries: 'Cache Entries',\n  cacheDetails: 'Cache Details',\n  noCacheData: 'No cache data',\n  loadingCacheInfo: 'Loading cache information...',\n  processingCache: 'Processing cache...',\n  cleaning: 'Cleaning...',\n  cleanExpiredCache: 'Clean Expired Cache',\n  clearAllCache: 'Clear All Cache',\n  expired: 'Expired',\n  bookmarksBar: 'Bookmarks Bar',\n};\n\n/**\n * Safe i18n message getter with fallback support\n * @param key Message key\n * @param substitutions Optional substitution values\n * @returns Localized message or fallback\n */\nexport function getMessage(key: string, substitutions?: string[]): string {\n  try {\n    // Check if Chrome extension APIs are available\n    if (typeof chrome !== 'undefined' && chrome.i18n && chrome.i18n.getMessage) {\n      const message = chrome.i18n.getMessage(key, substitutions);\n      if (message) {\n        return message;\n      }\n    }\n  } catch (error) {\n    console.warn(`Failed to get i18n message for key \"${key}\":`, error);\n  }\n\n  // Fallback to English messages\n  let fallback = fallbackMessages[key] || key;\n\n  // Handle substitutions in fallback messages\n  if (substitutions && substitutions.length > 0) {\n    substitutions.forEach((value, index) => {\n      fallback = fallback.replace(`{${index}}`, value);\n    });\n  }\n\n  return fallback;\n}\n\n/**\n * Check if Chrome extension i18n APIs are available\n */\nexport function isI18nAvailable(): boolean {\n  try {\n    return (\n      typeof chrome !== 'undefined' && chrome.i18n && typeof chrome.i18n.getMessage === 'function'\n    );\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/image-utils.ts",
    "content": "/**\n * Image processing utility functions\n */\n\n/**\n * Create ImageBitmap from data URL (for OffscreenCanvas)\n * @param dataUrl Image data URL\n * @returns Created ImageBitmap object\n */\nexport async function createImageBitmapFromUrl(dataUrl: string): Promise<ImageBitmap> {\n  const response = await fetch(dataUrl);\n  const blob = await response.blob();\n  return await createImageBitmap(blob);\n}\n\n/**\n * Stitch multiple image parts (dataURL) onto a single canvas\n * @param parts Array of image parts, each containing dataUrl and y coordinate\n * @param totalWidthPx Total width (pixels)\n * @param totalHeightPx Total height (pixels)\n * @returns Stitched canvas\n */\nexport async function stitchImages(\n  parts: { dataUrl: string; y: number }[],\n  totalWidthPx: number,\n  totalHeightPx: number,\n): Promise<OffscreenCanvas> {\n  const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx);\n  const ctx = canvas.getContext('2d');\n\n  if (!ctx) {\n    throw new Error('Unable to get canvas context');\n  }\n\n  ctx.fillStyle = '#FFFFFF';\n  ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n  for (const part of parts) {\n    try {\n      const img = await createImageBitmapFromUrl(part.dataUrl);\n      const sx = 0;\n      const sy = 0;\n      const sWidth = img.width;\n      let sHeight = img.height;\n      const dy = part.y;\n\n      if (dy + sHeight > totalHeightPx) {\n        sHeight = totalHeightPx - dy;\n      }\n\n      if (sHeight <= 0) continue;\n\n      ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight);\n    } catch (error) {\n      console.error('Error stitching image part:', error, part);\n    }\n  }\n  return canvas;\n}\n\n/**\n * Crop image (from dataURL) to specified rectangle and resize\n * @param originalDataUrl Original image data URL\n * @param cropRectPx Crop rectangle (physical pixels)\n * @param dpr Device pixel ratio\n * @param targetWidthOpt Optional target output width (CSS pixels)\n * @param targetHeightOpt Optional target output height (CSS pixels)\n * @returns Cropped canvas\n */\nexport async function cropAndResizeImage(\n  originalDataUrl: string,\n  cropRectPx: { x: number; y: number; width: number; height: number },\n  dpr: number = 1,\n  targetWidthOpt?: number,\n  targetHeightOpt?: number,\n): Promise<OffscreenCanvas> {\n  const img = await createImageBitmapFromUrl(originalDataUrl);\n\n  let sx = cropRectPx.x;\n  let sy = cropRectPx.y;\n  let sWidth = cropRectPx.width;\n  let sHeight = cropRectPx.height;\n\n  // Ensure crop area is within image boundaries\n  if (sx < 0) {\n    sWidth += sx;\n    sx = 0;\n  }\n  if (sy < 0) {\n    sHeight += sy;\n    sy = 0;\n  }\n  if (sx + sWidth > img.width) {\n    sWidth = img.width - sx;\n  }\n  if (sy + sHeight > img.height) {\n    sHeight = img.height - sy;\n  }\n\n  if (sWidth <= 0 || sHeight <= 0) {\n    throw new Error(\n      'Invalid calculated crop size (<=0). Element may not be visible or fully captured.',\n    );\n  }\n\n  const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth;\n  const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight;\n\n  const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx);\n  const ctx = canvas.getContext('2d');\n\n  if (!ctx) {\n    throw new Error('Unable to get canvas context');\n  }\n\n  ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx);\n\n  return canvas;\n}\n\n/**\n * Convert canvas to data URL\n * @param canvas Canvas\n * @param format Image format\n * @param quality JPEG quality (0-1)\n * @returns Data URL\n */\nexport async function canvasToDataURL(\n  canvas: OffscreenCanvas,\n  format: string = 'image/png',\n  quality?: number,\n): Promise<string> {\n  const blob = await canvas.convertToBlob({\n    type: format,\n    quality: format === 'image/jpeg' ? quality : undefined,\n  });\n\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    reader.onloadend = () => resolve(reader.result as string);\n    reader.onerror = reject;\n    reader.readAsDataURL(blob);\n  });\n}\n\n/**\n * Compresses an image by scaling it and converting it to a target format with a specific quality.\n * This is the most effective way to reduce image data size for transport or storage.\n *\n * @param {string} imageDataUrl - The original image data URL (e.g., from captureVisibleTab).\n * @param {object} options - Compression options.\n * @param {number} [options.scale=1.0] - The scaling factor for dimensions (e.g., 0.7 for 70%).\n * @param {number} [options.quality=0.8] - The quality for lossy formats like JPEG (0.0 to 1.0).\n * @param {string} [options.format='image/jpeg'] - The target image format.\n * @returns {Promise<{dataUrl: string, mimeType: string}>} A promise that resolves to the compressed image data URL and its MIME type.\n */\nexport async function compressImage(\n  imageDataUrl: string,\n  options: { scale?: number; quality?: number; format?: 'image/jpeg' | 'image/webp' },\n): Promise<{ dataUrl: string; mimeType: string }> {\n  const { scale = 1.0, quality = 0.8, format = 'image/jpeg' } = options;\n\n  // 1. Create an ImageBitmap from the original data URL for efficient drawing.\n  const imageBitmap = await createImageBitmapFromUrl(imageDataUrl);\n\n  // 2. Calculate the new dimensions based on the scale factor.\n  const newWidth = Math.round(imageBitmap.width * scale);\n  const newHeight = Math.round(imageBitmap.height * scale);\n\n  // 3. Use OffscreenCanvas for performance, as it doesn't need to be in the DOM.\n  const canvas = new OffscreenCanvas(newWidth, newHeight);\n  const ctx = canvas.getContext('2d');\n\n  if (!ctx) {\n    throw new Error('Failed to get 2D context from OffscreenCanvas');\n  }\n\n  // 4. Draw the original image onto the smaller canvas, effectively resizing it.\n  ctx.drawImage(imageBitmap, 0, 0, newWidth, newHeight);\n\n  // 5. Export the canvas content to the target format with the specified quality.\n  // This is the step that performs the data compression.\n  const compressedDataUrl = await canvas.convertToBlob({ type: format, quality: quality });\n\n  // A helper to convert blob to data URL since OffscreenCanvas.toDataURL is not standard yet\n  // on all execution contexts (like service workers).\n  const dataUrl = await new Promise<string>((resolve) => {\n    const reader = new FileReader();\n    reader.onloadend = () => resolve(reader.result as string);\n    reader.readAsDataURL(compressedDataUrl);\n  });\n\n  return { dataUrl, mimeType: format };\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/indexeddb-client.ts",
    "content": "// indexeddb-client.ts\n// Generic IndexedDB client with robust transaction handling and small helpers.\n\nexport type UpgradeHandler = (\n  db: IDBDatabase,\n  oldVersion: number,\n  tx: IDBTransaction | null,\n) => void;\n\nexport class IndexedDbClient {\n  private dbPromise: Promise<IDBDatabase> | null = null;\n\n  constructor(\n    private name: string,\n    private version: number,\n    private onUpgrade: UpgradeHandler,\n  ) {}\n\n  async openDb(): Promise<IDBDatabase> {\n    if (this.dbPromise) return this.dbPromise;\n    this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {\n      const req = indexedDB.open(this.name, this.version);\n      req.onupgradeneeded = (event) => {\n        const db = req.result;\n        const oldVersion = (event as IDBVersionChangeEvent).oldVersion || 0;\n        const tx = req.transaction as IDBTransaction | null;\n        try {\n          this.onUpgrade(db, oldVersion, tx);\n        } catch (e) {\n          console.error('IndexedDbClient upgrade failed:', e);\n        }\n      };\n      req.onsuccess = () => resolve(req.result);\n      req.onerror = () =>\n        reject(new Error(`IndexedDB open failed: ${req.error?.message || req.error}`));\n    });\n    return this.dbPromise;\n  }\n\n  async tx<T>(\n    storeName: string,\n    mode: IDBTransactionMode,\n    op: (store: IDBObjectStore, txn: IDBTransaction) => T | Promise<T>,\n  ): Promise<T> {\n    const db = await this.openDb();\n    return new Promise<T>((resolve, reject) => {\n      const transaction = db.transaction(storeName, mode);\n      const st = transaction.objectStore(storeName);\n      let opResult: T | undefined;\n      let opError: any;\n      transaction.oncomplete = () => resolve(opResult as T);\n      transaction.onerror = () =>\n        reject(\n          new Error(\n            `IDB transaction error on ${storeName}: ${transaction.error?.message || transaction.error}`,\n          ),\n        );\n      transaction.onabort = () =>\n        reject(\n          new Error(\n            `IDB transaction aborted on ${storeName}: ${transaction.error?.message || opError || 'unknown'}`,\n          ),\n        );\n      Promise.resolve()\n        .then(() => op(st, transaction))\n        .then((res) => {\n          opResult = res as T;\n        })\n        .catch((err) => {\n          opError = err;\n          try {\n            transaction.abort();\n          } catch {}\n        });\n    });\n  }\n\n  async getAll<T>(store: string): Promise<T[]> {\n    return this.tx<T[]>(store, 'readonly', (st) =>\n      this.promisifyRequest<any[]>(st.getAll(), store, 'getAll').then((res) => (res as T[]) || []),\n    );\n  }\n\n  async get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {\n    return this.tx<T | undefined>(store, 'readonly', (st) =>\n      this.promisifyRequest<T | undefined>(st.get(key), store, `get(${String(key)})`).then(\n        (res) => res as any,\n      ),\n    );\n  }\n\n  async put<T>(store: string, value: T): Promise<void> {\n    return this.tx<void>(store, 'readwrite', (st) =>\n      this.promisifyRequest<any>(st.put(value as any), store, 'put').then(() => undefined),\n    );\n  }\n\n  async delete(store: string, key: IDBValidKey): Promise<void> {\n    return this.tx<void>(store, 'readwrite', (st) =>\n      this.promisifyRequest<any>(st.delete(key), store, `delete(${String(key)})`).then(\n        () => undefined,\n      ),\n    );\n  }\n\n  async clear(store: string): Promise<void> {\n    return this.tx<void>(store, 'readwrite', (st) =>\n      this.promisifyRequest<any>(st.clear(), store, 'clear').then(() => undefined),\n    );\n  }\n\n  async putMany<T>(store: string, values: T[]): Promise<void> {\n    return this.tx<void>(store, 'readwrite', async (st) => {\n      for (const v of values) st.put(v as any);\n      return;\n    });\n  }\n\n  // Expose helper for advanced callers if needed\n  promisifyRequest<R>(req: IDBRequest<R>, store: string, action: string): Promise<R> {\n    return new Promise<R>((resolve, reject) => {\n      req.onsuccess = () => resolve(req.result as R);\n      req.onerror = () =>\n        reject(\n          new Error(\n            `IDB ${action} error on ${store}: ${(req.error as any)?.message || (req.error as any)}`,\n          ),\n        );\n    });\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/lru-cache.ts",
    "content": "class LRUNode<K, V> {\n  constructor(\n    public key: K,\n    public value: V,\n    public prev: LRUNode<K, V> | null = null,\n    public next: LRUNode<K, V> | null = null,\n    public frequency: number = 1,\n    public lastAccessed: number = Date.now(),\n  ) {}\n}\n\nclass LRUCache<K = string, V = any> {\n  private capacity: number;\n  private cache: Map<K, LRUNode<K, V>>;\n  private head: LRUNode<K, V>;\n  private tail: LRUNode<K, V>;\n\n  constructor(capacity: number) {\n    this.capacity = capacity > 0 ? capacity : 100;\n    this.cache = new Map<K, LRUNode<K, V>>();\n\n    this.head = new LRUNode<K, V>(null as any, null as any);\n    this.tail = new LRUNode<K, V>(null as any, null as any);\n    this.head.next = this.tail;\n    this.tail.prev = this.head;\n  }\n\n  private addToHead(node: LRUNode<K, V>): void {\n    node.prev = this.head;\n    node.next = this.head.next;\n    this.head.next!.prev = node;\n    this.head.next = node;\n  }\n\n  private removeNode(node: LRUNode<K, V>): void {\n    node.prev!.next = node.next;\n    node.next!.prev = node.prev;\n  }\n\n  private moveToHead(node: LRUNode<K, V>): void {\n    this.removeNode(node);\n    this.addToHead(node);\n  }\n\n  private findVictimNode(): LRUNode<K, V> {\n    let victim = this.tail.prev!;\n    let minScore = this.calculateEvictionScore(victim);\n\n    let current = this.tail.prev;\n    let count = 0;\n    const maxCheck = Math.min(5, this.cache.size);\n\n    while (current && current !== this.head && count < maxCheck) {\n      const score = this.calculateEvictionScore(current);\n      if (score < minScore) {\n        minScore = score;\n        victim = current;\n      }\n      current = current.prev;\n      count++;\n    }\n\n    return victim;\n  }\n\n  private calculateEvictionScore(node: LRUNode<K, V>): number {\n    const now = Date.now();\n    const timeSinceAccess = now - node.lastAccessed;\n    const timeWeight = 1 / (1 + timeSinceAccess / (1000 * 60));\n    const frequencyWeight = Math.log(node.frequency + 1);\n\n    return frequencyWeight * timeWeight;\n  }\n\n  get(key: K): V | null {\n    const node = this.cache.get(key);\n    if (node) {\n      node.frequency++;\n      node.lastAccessed = Date.now();\n      this.moveToHead(node);\n      return node.value;\n    }\n    return null;\n  }\n\n  set(key: K, value: V): void {\n    const existingNode = this.cache.get(key);\n\n    if (existingNode) {\n      existingNode.value = value;\n      this.moveToHead(existingNode);\n    } else {\n      const newNode = new LRUNode(key, value);\n\n      if (this.cache.size >= this.capacity) {\n        const victimNode = this.findVictimNode();\n        this.removeNode(victimNode);\n        this.cache.delete(victimNode.key);\n      }\n\n      this.cache.set(key, newNode);\n      this.addToHead(newNode);\n    }\n  }\n\n  has(key: K): boolean {\n    return this.cache.has(key);\n  }\n\n  clear(): void {\n    this.cache.clear();\n    this.head.next = this.tail;\n    this.tail.prev = this.head;\n  }\n\n  get size(): number {\n    return this.cache.size;\n  }\n\n  /**\n   * Get cache statistics\n   */\n  getStats(): { size: number; capacity: number; usage: number } {\n    return {\n      size: this.cache.size,\n      capacity: this.capacity,\n      usage: this.cache.size / this.capacity,\n    };\n  }\n}\n\nexport default LRUCache;\n"
  },
  {
    "path": "app/chrome-extension/utils/model-cache-manager.ts",
    "content": "/**\n * Model Cache Manager\n */\n\nconst CACHE_NAME = 'onnx-model-cache-v1';\nconst CACHE_EXPIRY_DAYS = 30;\nconst MAX_CACHE_SIZE_MB = 500;\n\nexport interface CacheMetadata {\n  timestamp: number;\n  modelUrl: string;\n  size: number;\n  version: string;\n}\n\nexport interface CacheEntry {\n  url: string;\n  size: number;\n  sizeMB: number;\n  timestamp: number;\n  age: string;\n  expired: boolean;\n}\n\nexport interface CacheStats {\n  totalSize: number;\n  totalSizeMB: number;\n  entryCount: number;\n  entries: CacheEntry[];\n}\n\ninterface CacheEntryDetails {\n  url: string;\n  timestamp: number;\n  size: number;\n}\n\nexport class ModelCacheManager {\n  private static instance: ModelCacheManager | null = null;\n\n  public static getInstance(): ModelCacheManager {\n    if (!ModelCacheManager.instance) {\n      ModelCacheManager.instance = new ModelCacheManager();\n    }\n    return ModelCacheManager.instance;\n  }\n\n  private constructor() {}\n\n  private getCacheMetadataKey(modelUrl: string): string {\n    const encodedUrl = encodeURIComponent(modelUrl);\n    return `https://cache-metadata.local/${encodedUrl}`;\n  }\n\n  private isCacheExpired(metadata: CacheMetadata): boolean {\n    const now = Date.now();\n    const expiryTime = metadata.timestamp + CACHE_EXPIRY_DAYS * 24 * 60 * 60 * 1000;\n    return now > expiryTime;\n  }\n\n  private isMetadataUrl(url: string): boolean {\n    return url.startsWith('https://cache-metadata.local/');\n  }\n\n  private async collectCacheEntries(): Promise<{\n    entries: CacheEntryDetails[];\n    totalSize: number;\n    entryCount: number;\n  }> {\n    const cache = await caches.open(CACHE_NAME);\n    const keys = await cache.keys();\n    const entries: CacheEntryDetails[] = [];\n    let totalSize = 0;\n    let entryCount = 0;\n\n    for (const request of keys) {\n      if (this.isMetadataUrl(request.url)) continue;\n\n      const response = await cache.match(request);\n      if (response) {\n        const blob = await response.blob();\n        const size = blob.size;\n        totalSize += size;\n        entryCount++;\n\n        const metadataResponse = await cache.match(this.getCacheMetadataKey(request.url));\n        let timestamp = 0;\n\n        if (metadataResponse) {\n          try {\n            const metadata: CacheMetadata = await metadataResponse.json();\n            timestamp = metadata.timestamp;\n          } catch (error) {\n            console.warn('Failed to parse cache metadata:', error);\n          }\n        }\n\n        entries.push({\n          url: request.url,\n          timestamp,\n          size,\n        });\n      }\n    }\n\n    return { entries, totalSize, entryCount };\n  }\n\n  public async cleanupCacheOnDemand(newDataSize: number = 0): Promise<void> {\n    const cache = await caches.open(CACHE_NAME);\n    const { entries, totalSize } = await this.collectCacheEntries();\n    const maxSizeBytes = MAX_CACHE_SIZE_MB * 1024 * 1024;\n    const projectedSize = totalSize + newDataSize;\n\n    if (projectedSize <= maxSizeBytes) {\n      return;\n    }\n\n    console.log(\n      `Cache size (${(totalSize / 1024 / 1024).toFixed(2)}MB) + new data (${(newDataSize / 1024 / 1024).toFixed(2)}MB) exceeds limit (${MAX_CACHE_SIZE_MB}MB), cleaning up...`,\n    );\n\n    const expiredEntries: CacheEntryDetails[] = [];\n    const validEntries: CacheEntryDetails[] = [];\n\n    for (const entry of entries) {\n      const metadataResponse = await cache.match(this.getCacheMetadataKey(entry.url));\n      let isExpired = false;\n\n      if (metadataResponse) {\n        try {\n          const metadata: CacheMetadata = await metadataResponse.json();\n          isExpired = this.isCacheExpired(metadata);\n        } catch (error) {\n          isExpired = true;\n        }\n      } else {\n        isExpired = true;\n      }\n\n      if (isExpired) {\n        expiredEntries.push(entry);\n      } else {\n        validEntries.push(entry);\n      }\n    }\n\n    let currentSize = totalSize;\n    for (const entry of expiredEntries) {\n      await cache.delete(entry.url);\n      await cache.delete(this.getCacheMetadataKey(entry.url));\n      currentSize -= entry.size;\n      console.log(\n        `Cleaned up expired cache entry: ${entry.url} (${(entry.size / 1024 / 1024).toFixed(2)}MB)`,\n      );\n    }\n\n    if (currentSize + newDataSize > maxSizeBytes) {\n      validEntries.sort((a, b) => a.timestamp - b.timestamp);\n\n      for (const entry of validEntries) {\n        if (currentSize + newDataSize <= maxSizeBytes) break;\n\n        await cache.delete(entry.url);\n        await cache.delete(this.getCacheMetadataKey(entry.url));\n        currentSize -= entry.size;\n        console.log(\n          `Cleaned up old cache entry: ${entry.url} (${(entry.size / 1024 / 1024).toFixed(2)}MB)`,\n        );\n      }\n    }\n\n    console.log(`Cache cleanup complete. New size: ${(currentSize / 1024 / 1024).toFixed(2)}MB`);\n  }\n\n  public async storeCacheMetadata(modelUrl: string, size: number): Promise<void> {\n    const cache = await caches.open(CACHE_NAME);\n    const metadata: CacheMetadata = {\n      timestamp: Date.now(),\n      modelUrl,\n      size,\n      version: CACHE_NAME,\n    };\n\n    const metadataResponse = new Response(JSON.stringify(metadata), {\n      headers: { 'Content-Type': 'application/json' },\n    });\n\n    await cache.put(this.getCacheMetadataKey(modelUrl), metadataResponse);\n  }\n\n  public async getCachedModelData(modelUrl: string): Promise<ArrayBuffer | null> {\n    const cache = await caches.open(CACHE_NAME);\n    const cachedResponse = await cache.match(modelUrl);\n\n    if (!cachedResponse) {\n      return null;\n    }\n\n    const metadataResponse = await cache.match(this.getCacheMetadataKey(modelUrl));\n    if (metadataResponse) {\n      try {\n        const metadata: CacheMetadata = await metadataResponse.json();\n        if (!this.isCacheExpired(metadata)) {\n          console.log('Model found in cache and not expired. Loading from cache.');\n          return cachedResponse.arrayBuffer();\n        } else {\n          console.log('Cached model is expired, removing...');\n          await this.deleteCacheEntry(modelUrl);\n          return null;\n        }\n      } catch (error) {\n        console.warn('Failed to parse cache metadata, treating as expired:', error);\n        await this.deleteCacheEntry(modelUrl);\n        return null;\n      }\n    } else {\n      console.log('Cached model has no metadata, treating as expired...');\n      await this.deleteCacheEntry(modelUrl);\n      return null;\n    }\n  }\n\n  public async storeModelData(modelUrl: string, data: ArrayBuffer): Promise<void> {\n    await this.cleanupCacheOnDemand(data.byteLength);\n\n    const cache = await caches.open(CACHE_NAME);\n    const response = new Response(data);\n\n    await cache.put(modelUrl, response);\n    await this.storeCacheMetadata(modelUrl, data.byteLength);\n\n    console.log(\n      `Model cached successfully (${(data.byteLength / 1024 / 1024).toFixed(2)}MB): ${modelUrl}`,\n    );\n  }\n\n  public async deleteCacheEntry(modelUrl: string): Promise<void> {\n    const cache = await caches.open(CACHE_NAME);\n    await cache.delete(modelUrl);\n    await cache.delete(this.getCacheMetadataKey(modelUrl));\n  }\n\n  public async clearAllCache(): Promise<void> {\n    const cache = await caches.open(CACHE_NAME);\n    const keys = await cache.keys();\n\n    for (const request of keys) {\n      await cache.delete(request);\n    }\n\n    console.log('All model cache entries cleared');\n  }\n\n  public async getCacheStats(): Promise<CacheStats> {\n    const { entries, totalSize, entryCount } = await this.collectCacheEntries();\n    const cache = await caches.open(CACHE_NAME);\n\n    const cacheEntries: CacheEntry[] = [];\n\n    for (const entry of entries) {\n      const metadataResponse = await cache.match(this.getCacheMetadataKey(entry.url));\n      let expired = false;\n\n      if (metadataResponse) {\n        try {\n          const metadata: CacheMetadata = await metadataResponse.json();\n          expired = this.isCacheExpired(metadata);\n        } catch (error) {\n          expired = true;\n        }\n      } else {\n        expired = true;\n      }\n\n      const age =\n        entry.timestamp > 0\n          ? `${Math.round((Date.now() - entry.timestamp) / (1000 * 60 * 60 * 24))} days`\n          : 'unknown';\n\n      cacheEntries.push({\n        url: entry.url,\n        size: entry.size,\n        sizeMB: Number((entry.size / 1024 / 1024).toFixed(2)),\n        timestamp: entry.timestamp,\n        age,\n        expired,\n      });\n    }\n\n    return {\n      totalSize,\n      totalSizeMB: Number((totalSize / 1024 / 1024).toFixed(2)),\n      entryCount,\n      entries: cacheEntries.sort((a, b) => b.timestamp - a.timestamp),\n    };\n  }\n\n  public async manualCleanup(): Promise<void> {\n    await this.cleanupCacheOnDemand(0);\n    console.log('Manual cache cleanup completed');\n  }\n\n  /**\n   * Check if a specific model is cached and not expired\n   * @param modelUrl The model URL to check\n   * @returns Promise<boolean> True if model is cached and valid\n   */\n  public async isModelCached(modelUrl: string): Promise<boolean> {\n    try {\n      const cache = await caches.open(CACHE_NAME);\n      const cachedResponse = await cache.match(modelUrl);\n\n      if (!cachedResponse) {\n        return false;\n      }\n\n      const metadataResponse = await cache.match(this.getCacheMetadataKey(modelUrl));\n      if (metadataResponse) {\n        try {\n          const metadata: CacheMetadata = await metadataResponse.json();\n          return !this.isCacheExpired(metadata);\n        } catch (error) {\n          console.warn('Failed to parse cache metadata for cache check:', error);\n          return false;\n        }\n      } else {\n        // No metadata means expired\n        return false;\n      }\n    } catch (error) {\n      console.error('Error checking model cache:', error);\n      return false;\n    }\n  }\n\n  /**\n   * Check if any valid (non-expired) model cache exists\n   * @returns Promise<boolean> True if at least one valid model cache exists\n   */\n  public async hasAnyValidCache(): Promise<boolean> {\n    try {\n      const cache = await caches.open(CACHE_NAME);\n      const keys = await cache.keys();\n\n      for (const request of keys) {\n        if (this.isMetadataUrl(request.url)) continue;\n\n        const metadataResponse = await cache.match(this.getCacheMetadataKey(request.url));\n        if (metadataResponse) {\n          try {\n            const metadata: CacheMetadata = await metadataResponse.json();\n            if (!this.isCacheExpired(metadata)) {\n              return true; // Found at least one valid cache\n            }\n          } catch (error) {\n            // Skip invalid metadata\n            continue;\n          }\n        }\n      }\n\n      return false;\n    } catch (error) {\n      console.error('Error checking for valid cache:', error);\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/offscreen-manager.ts",
    "content": "/**\n * Offscreen Document manager\n * Ensures only one offscreen document is created across the entire extension to avoid conflicts\n */\n\nexport class OffscreenManager {\n  private static instance: OffscreenManager | null = null;\n  private isCreated = false;\n  private isCreating = false;\n  private createPromise: Promise<void> | null = null;\n\n  private constructor() {}\n\n  /**\n   * Get singleton instance\n   */\n  public static getInstance(): OffscreenManager {\n    if (!OffscreenManager.instance) {\n      OffscreenManager.instance = new OffscreenManager();\n    }\n    return OffscreenManager.instance;\n  }\n\n  /**\n   * Ensure offscreen document exists\n   */\n  public async ensureOffscreenDocument(): Promise<void> {\n    if (this.isCreated) {\n      return;\n    }\n\n    if (this.isCreating && this.createPromise) {\n      return this.createPromise;\n    }\n\n    this.isCreating = true;\n    this.createPromise = this._doCreateOffscreenDocument().finally(() => {\n      this.isCreating = false;\n    });\n\n    return this.createPromise;\n  }\n\n  private async _doCreateOffscreenDocument(): Promise<void> {\n    try {\n      if (!chrome.offscreen) {\n        throw new Error('Offscreen API not available. Chrome 109+ required.');\n      }\n\n      const existingContexts = await (chrome.runtime as any).getContexts({\n        contextTypes: ['OFFSCREEN_DOCUMENT'],\n      });\n\n      if (existingContexts && existingContexts.length > 0) {\n        console.log('OffscreenManager: Offscreen document already exists');\n        this.isCreated = true;\n        return;\n      }\n\n      await chrome.offscreen.createDocument({\n        url: 'offscreen.html',\n        reasons: ['WORKERS'],\n        justification: 'Need to run semantic similarity engine with workers',\n      });\n\n      this.isCreated = true;\n      console.log('OffscreenManager: Offscreen document created successfully');\n    } catch (error) {\n      console.error('OffscreenManager: Failed to create offscreen document:', error);\n      this.isCreated = false;\n      throw error;\n    }\n  }\n\n  /**\n   * Check if offscreen document is created\n   */\n  public isOffscreenDocumentCreated(): boolean {\n    return this.isCreated;\n  }\n\n  /**\n   * Close offscreen document\n   */\n  public async closeOffscreenDocument(): Promise<void> {\n    try {\n      if (chrome.offscreen && this.isCreated) {\n        await chrome.offscreen.closeDocument();\n        this.isCreated = false;\n        console.log('OffscreenManager: Offscreen document closed');\n      }\n    } catch (error) {\n      console.error('OffscreenManager: Failed to close offscreen document:', error);\n    }\n  }\n\n  /**\n   * Reset state (for testing)\n   */\n  public reset(): void {\n    this.isCreated = false;\n    this.isCreating = false;\n    this.createPromise = null;\n  }\n}\n\n\nexport const offscreenManager = OffscreenManager.getInstance();\n"
  },
  {
    "path": "app/chrome-extension/utils/output-sanitizer.ts",
    "content": "/**\n * Output Sanitizer - 输出脱敏和限长工具\n *\n * 提供对 JavaScript 执行结果的安全处理：\n * 1. 敏感信息脱敏（cookie/token/password 等）\n * 2. 输出长度限制（默认 50KB）\n * 3. 深度对象序列化\n */\n\nexport const DEFAULT_MAX_OUTPUT_BYTES = 50 * 1024;\n\nexport interface OutputSanitizerOptions {\n  maxBytes?: number;\n  maxDepth?: number;\n  maxArrayLength?: number;\n  maxObjectKeys?: number;\n  maxStringLength?: number;\n}\n\nexport interface SanitizedOutput {\n  text: string;\n  truncated: boolean;\n  redacted: boolean;\n  originalBytes: number;\n}\n\nconst DEFAULT_MAX_DEPTH = 6;\nconst DEFAULT_MAX_ARRAY_LENGTH = 200;\nconst DEFAULT_MAX_OBJECT_KEYS = 200;\nconst DEFAULT_MAX_STRING_LENGTH = 10_000;\n\n// 敏感 key 标识符（会被脱敏）\n// 参考 mcp-tools.js 的敏感 key 列表\nconst SENSITIVE_KEY_MARKERS = [\n  'cookie',\n  'setcookie',\n  'authorization',\n  'proxyauthorization',\n  'bearer',\n  'token',\n  'accesstoken',\n  'refreshtoken',\n  'idtoken',\n  'password',\n  'passwd',\n  'pwd',\n  'secret',\n  'clientsecret',\n  'apikey',\n  'session',\n  'sessionid',\n  'sid',\n  'csrf',\n  'xsrf',\n  // 补充 mcp-tools.js 中的敏感 key\n  'credential',\n  'privatekey',\n  'accesskey',\n  'auth',\n  'oauth',\n] as const;\n\n/**\n * 对任意值进行脱敏和限长处理\n */\nexport function sanitizeAndLimitOutput(\n  value: unknown,\n  options: OutputSanitizerOptions = {},\n): SanitizedOutput {\n  const maxBytes = normalizePositiveInt(options.maxBytes, DEFAULT_MAX_OUTPUT_BYTES);\n  const maxDepth = normalizePositiveInt(options.maxDepth, DEFAULT_MAX_DEPTH);\n  const maxArrayLength = normalizePositiveInt(options.maxArrayLength, DEFAULT_MAX_ARRAY_LENGTH);\n  const maxObjectKeys = normalizePositiveInt(options.maxObjectKeys, DEFAULT_MAX_OBJECT_KEYS);\n  const maxStringLength = normalizePositiveInt(options.maxStringLength, DEFAULT_MAX_STRING_LENGTH);\n\n  const { value: sanitizedValue, redacted } = sanitizeValue(value, {\n    maxDepth,\n    maxArrayLength,\n    maxObjectKeys,\n    maxStringLength,\n  });\n\n  const formatted = formatValueForOutput(sanitizedValue);\n  const truncated = truncateTextBytes(formatted, maxBytes);\n\n  return {\n    text: truncated.text,\n    truncated: truncated.truncated,\n    redacted,\n    originalBytes: truncated.originalBytes,\n  };\n}\n\n/**\n * 对字符串进行敏感信息脱敏\n * 参考 mcp-tools.js 的脱敏逻辑，增加 Base64/Hex/cookie-query 识别\n */\nexport function sanitizeText(text: string): { text: string; redacted: boolean } {\n  let out = text;\n  let redacted = false;\n\n  const replace = (\n    re: RegExp,\n    replacement: string | ((substring: string, ...args: string[]) => string),\n  ) => {\n    const next = out.replace(re, replacement as Parameters<typeof String.prototype.replace>[1]);\n    if (next !== out) {\n      out = next;\n      redacted = true;\n    }\n  };\n\n  // 1. 整体字符串检测（mcp-tools.js 风格）\n  // Cookie/query string 形态检测（包含 = 和 ; 或 &）\n  if (out.includes('=') && (out.includes(';') || out.includes('&'))) {\n    // 检测 cookie 字符串\n    if (looksLikeCookieString(out)) {\n      return { text: '[BLOCKED: Cookie/query string data]', redacted: true };\n    }\n    // 检测 query string (key=value&key2=value2 形态)\n    if (looksLikeQueryString(out)) {\n      return { text: '[BLOCKED: Cookie/query string data]', redacted: true };\n    }\n  }\n\n  // Base64 编码数据检测（20+ 字符的 Base64 字符串）\n  if (/^[A-Za-z0-9+/]{20,}={0,2}$/.test(out)) {\n    return { text: '[BLOCKED: Base64 encoded data]', redacted: true };\n  }\n\n  // Hex credential 检测（32+ 字符的纯十六进制）\n  if (/^[a-f0-9]{32,}$/i.test(out)) {\n    return { text: '[BLOCKED: Hex credential]', redacted: true };\n  }\n\n  // 2. Bearer token\n  replace(/\\bBearer\\s+([A-Za-z0-9._~+/=-]+)\\b/gi, 'Bearer <redacted>');\n\n  // 3. JWT (三段式)\n  replace(/\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b/g, '<redacted_jwt>');\n\n  // 4. URL query 参数中的敏感值\n  replace(\n    /(^|[?&])(access_token|refresh_token|id_token|token|api_key|apikey|password|passwd|pwd|secret|session|sid|credential|auth|oauth)=([^&#\\s]+)/gi,\n    (_m, p1, p2) => `${p1}${p2}=<redacted>`,\n  );\n\n  // 5. Header-like 键值对\n  replace(\n    /\\b(authorization|cookie|set-cookie|x-api-key|api_key|apikey|password|passwd|pwd|secret|token|access_token|refresh_token|id_token|session|sid|credential|private_key|oauth)\\b\\s*[:=]\\s*([^\\s,;\"']+)/gi,\n    (_m, key) => `${key}=<redacted>`,\n  );\n\n  // 6. 内嵌的 Base64 数据（在混合内容中）\n  replace(/\\b[A-Za-z0-9+/]{40,}={0,2}\\b/g, '<redacted_base64>');\n\n  // 7. 内嵌的长 Hex 字符串（可能是 API key、hash 等）\n  replace(/\\b[a-f0-9]{40,}\\b/gi, '<redacted_hex>');\n\n  return { text: out, redacted };\n}\n\n/**\n * 检测字符串是否像 query string (key=value&key2=value2)\n */\nfunction looksLikeQueryString(text: string): boolean {\n  const s = (text || '').trim();\n  if (!s || !s.includes('=') || !s.includes('&')) return false;\n\n  const parts = s.split('&');\n  if (parts.length < 2) return false;\n\n  let pairs = 0;\n  for (const part of parts) {\n    const idx = part.indexOf('=');\n    if (idx > 0) pairs += 1;\n  }\n  return pairs >= 2;\n}\n\nfunction sanitizeValue(\n  value: unknown,\n  limits: {\n    maxDepth: number;\n    maxArrayLength: number;\n    maxObjectKeys: number;\n    maxStringLength: number;\n  },\n): { value: unknown; redacted: boolean } {\n  const { maxDepth, maxArrayLength, maxObjectKeys, maxStringLength } = limits;\n  const seen = new WeakMap<object, unknown>();\n  let redacted = false;\n\n  const walk = (v: unknown, depth: number): unknown => {\n    if (depth < 0) return '[MaxDepth]';\n\n    if (typeof v === 'string') {\n      const sanitized = sanitizeText(v);\n      if (sanitized.redacted) redacted = true;\n      let s = sanitized.text;\n      if (s.length > maxStringLength) {\n        s = `${s.slice(0, maxStringLength)}... [truncated ${s.length - maxStringLength} chars]`;\n      }\n      return s;\n    }\n\n    if (\n      v === null ||\n      typeof v === 'number' ||\n      typeof v === 'boolean' ||\n      typeof v === 'bigint' ||\n      typeof v === 'undefined'\n    ) {\n      return v;\n    }\n\n    if (typeof v === 'symbol') return v.toString();\n    if (typeof v === 'function') return `[Function${v.name ? `: ${v.name}` : ''}]`;\n\n    if (typeof v !== 'object') return String(v);\n\n    const obj = v as Record<string, unknown>;\n\n    if (seen.has(obj)) return '[Circular]';\n\n    if (Array.isArray(obj)) {\n      const out: unknown[] = [];\n      seen.set(obj, out);\n      const len = Math.min(obj.length, maxArrayLength);\n      for (let i = 0; i < len; i++) {\n        out.push(walk(obj[i], depth - 1));\n      }\n      if (obj.length > maxArrayLength) out.push('[...truncated]');\n      return out;\n    }\n\n    const out: Record<string, unknown> = {};\n    seen.set(obj, out);\n\n    const keys = Object.keys(obj);\n    const len = Math.min(keys.length, maxObjectKeys);\n    for (let i = 0; i < len; i++) {\n      const key = keys[i];\n      if (isSensitiveKey(key)) {\n        out[key] = '<redacted>';\n        redacted = true;\n        continue;\n      }\n      out[key] = walk(obj[key], depth - 1);\n    }\n    if (keys.length > maxObjectKeys) out.__truncated__ = true;\n\n    return out;\n  };\n\n  return { value: walk(value, maxDepth), redacted };\n}\n\nfunction isSensitiveKey(key: string): boolean {\n  const normalized = normalizeKey(key);\n  return SENSITIVE_KEY_MARKERS.some((marker) => normalized.includes(marker));\n}\n\nfunction normalizeKey(key: string): string {\n  return (key || '').toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\n/**\n * 检测字符串是否像 cookie 字符串 (key=value; key2=value2)\n */\nfunction looksLikeCookieString(text: string): boolean {\n  const s = (text || '').trim();\n  if (!s) return false;\n  if (!s.includes('=') || !s.includes(';')) return false;\n\n  const parts = s.split(';');\n  if (parts.length < 2) return false;\n\n  let pairs = 0;\n  for (const part of parts) {\n    const idx = part.indexOf('=');\n    if (idx > 0) pairs += 1;\n  }\n  return pairs >= 2;\n}\n\nfunction formatValueForOutput(value: unknown): string {\n  if (typeof value === 'string') return value;\n  if (typeof value === 'undefined') return 'undefined';\n\n  try {\n    return safeJsonStringify(value);\n  } catch {\n    return String(value);\n  }\n}\n\nfunction safeJsonStringify(value: unknown): string {\n  const seen = new WeakSet<object>();\n  return JSON.stringify(value, (_key, val) => {\n    if (typeof val === 'bigint') return `${val.toString()}n`;\n    if (typeof val === 'symbol') return val.toString();\n    if (typeof val === 'function') return `[Function${val.name ? `: ${val.name}` : ''}]`;\n    if (val && typeof val === 'object') {\n      if (seen.has(val)) return '[Circular]';\n      seen.add(val);\n    }\n    return val;\n  });\n}\n\nfunction truncateTextBytes(\n  text: string,\n  maxBytes: number,\n): { text: string; truncated: boolean; originalBytes: number } {\n  const originalBytes = byteLength(text);\n  if (originalBytes <= maxBytes) {\n    return { text, truncated: false, originalBytes };\n  }\n\n  const suffix = `\\n... [truncated to ${maxBytes} bytes; original ${originalBytes} bytes]`;\n  const suffixBytes = byteLength(suffix);\n  const budget = Math.max(0, maxBytes - suffixBytes);\n\n  // 二分查找合适的截断点\n  let lo = 0;\n  let hi = text.length;\n\n  while (lo < hi) {\n    const mid = Math.ceil((lo + hi) / 2);\n    const candidate = text.slice(0, mid);\n    if (byteLength(candidate) <= budget) {\n      lo = mid;\n    } else {\n      hi = mid - 1;\n    }\n  }\n\n  const prefix = text.slice(0, lo);\n  return { text: prefix + suffix, truncated: true, originalBytes };\n}\n\nfunction byteLength(text: string): number {\n  try {\n    return new TextEncoder().encode(text).length;\n  } catch {\n    return text.length;\n  }\n}\n\nfunction normalizePositiveInt(value: unknown, fallback: number): number {\n  const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;\n  return Math.max(1, n);\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/screenshot-context.ts",
    "content": "// Simple in-memory screenshot context manager per tab\n// Used to scale coordinates from screenshot space to viewport space\n\nexport interface ScreenshotContext {\n  // Final screenshot dimensions (in CSS pixels after any scaling)\n  screenshotWidth: number;\n  screenshotHeight: number;\n  // Viewport dimensions (CSS pixels)\n  viewportWidth: number;\n  viewportHeight: number;\n  // Device pixel ratio at capture time (optional, for reference)\n  devicePixelRatio?: number;\n  // Hostname of the page when the screenshot was taken (used for domain safety checks)\n  hostname?: string;\n  // Timestamp\n  timestamp: number;\n}\n\nconst TTL_MS = 5 * 60 * 1000; // 5 minutes\n\nconst contexts = new Map<number, ScreenshotContext>();\n\nexport const screenshotContextManager = {\n  setContext(tabId: number, ctx: Omit<ScreenshotContext, 'timestamp'>) {\n    contexts.set(tabId, { ...ctx, timestamp: Date.now() });\n  },\n  getContext(tabId: number): ScreenshotContext | undefined {\n    const ctx = contexts.get(tabId);\n    if (!ctx) return undefined;\n    if (Date.now() - ctx.timestamp > TTL_MS) {\n      contexts.delete(tabId);\n      return undefined;\n    }\n    return ctx;\n  },\n  clear(tabId: number) {\n    contexts.delete(tabId);\n  },\n};\n\n// Scale screenshot-space coordinates (x,y) to viewport CSS pixels\nexport function scaleCoordinates(\n  x: number,\n  y: number,\n  ctx: ScreenshotContext,\n): { x: number; y: number } {\n  if (!ctx.screenshotWidth || !ctx.screenshotHeight || !ctx.viewportWidth || !ctx.viewportHeight) {\n    return { x, y };\n  }\n  const sx = (x / ctx.screenshotWidth) * ctx.viewportWidth;\n  const sy = (y / ctx.screenshotHeight) * ctx.viewportHeight;\n  return { x: Math.round(sx), y: Math.round(sy) };\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/semantic-similarity-engine.ts",
    "content": "import { AutoTokenizer, env as TransformersEnv } from '@xenova/transformers';\nimport type { Tensor as TransformersTensor, PreTrainedTokenizer } from '@xenova/transformers';\nimport LRUCache from './lru-cache';\nimport { SIMDMathEngine } from './simd-math-engine';\nimport { OffscreenManager } from './offscreen-manager';\nimport { STORAGE_KEYS } from '@/common/constants';\nimport { OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';\n\nimport { ModelCacheManager } from './model-cache-manager';\n\n/**\n * Get cached model data, prioritizing cache reads and handling redirected URLs.\n * @param {string} modelUrl Stable, permanent URL of the model\n * @returns {Promise<ArrayBuffer>} Model data as ArrayBuffer\n */\nasync function getCachedModelData(modelUrl: string): Promise<ArrayBuffer> {\n  const cacheManager = ModelCacheManager.getInstance();\n\n  // 1. 尝试从缓存获取数据\n  const cachedData = await cacheManager.getCachedModelData(modelUrl);\n  if (cachedData) {\n    return cachedData;\n  }\n\n  console.log('Model not found in cache or expired. Fetching from network...');\n\n  try {\n    // 2. 从网络获取数据\n    const response = await fetch(modelUrl);\n\n    if (!response.ok) {\n      throw new Error(`Failed to fetch model: ${response.status} ${response.statusText}`);\n    }\n\n    // 3. 获取数据并存储到缓存\n    const arrayBuffer = await response.arrayBuffer();\n    await cacheManager.storeModelData(modelUrl, arrayBuffer);\n\n    console.log(\n      `Model fetched from network and successfully cached (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)}MB).`,\n    );\n\n    return arrayBuffer;\n  } catch (error) {\n    console.error(`Error fetching or caching model:`, error);\n    // 如果获取失败，清理可能不完整的缓存条目\n    await cacheManager.deleteCacheEntry(modelUrl);\n    throw error;\n  }\n}\n\n/**\n * Clear all model cache entries\n */\nexport async function clearModelCache(): Promise<void> {\n  try {\n    const cacheManager = ModelCacheManager.getInstance();\n    await cacheManager.clearAllCache();\n  } catch (error) {\n    console.error('Failed to clear model cache:', error);\n    throw error;\n  }\n}\n\n/**\n * Get cache statistics\n */\nexport async function getCacheStats(): Promise<{\n  totalSize: number;\n  totalSizeMB: number;\n  entryCount: number;\n  entries: Array<{\n    url: string;\n    size: number;\n    sizeMB: number;\n    timestamp: number;\n    age: string;\n    expired: boolean;\n  }>;\n}> {\n  try {\n    const cacheManager = ModelCacheManager.getInstance();\n    return await cacheManager.getCacheStats();\n  } catch (error) {\n    console.error('Failed to get cache stats:', error);\n    throw error;\n  }\n}\n\n/**\n * Manually trigger cache cleanup\n */\nexport async function cleanupModelCache(): Promise<void> {\n  try {\n    const cacheManager = ModelCacheManager.getInstance();\n    await cacheManager.manualCleanup();\n  } catch (error) {\n    console.error('Failed to cleanup cache:', error);\n    throw error;\n  }\n}\n\n/**\n * Check if the default model is cached and available\n * @returns Promise<boolean> True if default model is cached and valid\n */\nexport async function isDefaultModelCached(): Promise<boolean> {\n  try {\n    // Get the default model configuration\n    const result = await chrome.storage.local.get([STORAGE_KEYS.SEMANTIC_MODEL]);\n    const defaultModel =\n      (result[STORAGE_KEYS.SEMANTIC_MODEL] as ModelPreset) || 'multilingual-e5-small';\n\n    // Build the model URL\n    const modelInfo = PREDEFINED_MODELS[defaultModel];\n    const modelIdentifier = modelInfo.modelIdentifier;\n    const onnxModelFile = 'model.onnx'; // Default ONNX file name\n\n    const modelIdParts = modelIdentifier.split('/');\n    const modelNameForUrl = modelIdParts.length > 1 ? modelIdentifier : `Xenova/${modelIdentifier}`;\n    const onnxModelUrl = `https://huggingface.co/${modelNameForUrl}/resolve/main/onnx/${onnxModelFile}`;\n\n    // Check if this model is cached\n    const cacheManager = ModelCacheManager.getInstance();\n    return await cacheManager.isModelCached(onnxModelUrl);\n  } catch (error) {\n    console.error('Error checking if default model is cached:', error);\n    return false;\n  }\n}\n\n/**\n * Check if any model cache exists (for conditional initialization)\n * @returns Promise<boolean> True if any valid model cache exists\n */\nexport async function hasAnyModelCache(): Promise<boolean> {\n  try {\n    const cacheManager = ModelCacheManager.getInstance();\n    return await cacheManager.hasAnyValidCache();\n  } catch (error) {\n    console.error('Error checking for any model cache:', error);\n    return false;\n  }\n}\n\n// Predefined model configurations - 2025 curated recommended models, using quantized versions to reduce file size\nexport const PREDEFINED_MODELS = {\n  // Multilingual model - default recommendation\n  'multilingual-e5-small': {\n    modelIdentifier: 'Xenova/multilingual-e5-small',\n    dimension: 384,\n    description: 'Multilingual E5 Small - Lightweight multilingual model supporting 100+ languages',\n    language: 'multilingual',\n    performance: 'excellent',\n    size: '116MB', // Quantized version\n    latency: '20ms',\n    multilingualFeatures: {\n      languageSupport: '100+',\n      crossLanguageRetrieval: 'good',\n      chineseEnglishMixed: 'good',\n    },\n    modelSpecificConfig: {\n      requiresTokenTypeIds: false, // E5 model doesn't require token_type_ids\n    },\n  },\n  'multilingual-e5-base': {\n    modelIdentifier: 'Xenova/multilingual-e5-base',\n    dimension: 768,\n    description: 'Multilingual E5 base - Medium-scale multilingual model supporting 100+ languages',\n    language: 'multilingual',\n    performance: 'excellent',\n    size: '279MB', // Quantized version\n    latency: '30ms',\n    multilingualFeatures: {\n      languageSupport: '100+',\n      crossLanguageRetrieval: 'excellent',\n      chineseEnglishMixed: 'excellent',\n    },\n    modelSpecificConfig: {\n      requiresTokenTypeIds: false, // E5 model doesn't require token_type_ids\n    },\n  },\n} as const;\n\nexport type ModelPreset = keyof typeof PREDEFINED_MODELS;\n\n/**\n * Get model information\n */\nexport function getModelInfo(preset: ModelPreset) {\n  return PREDEFINED_MODELS[preset];\n}\n\n/**\n * List all available models\n */\nexport function listAvailableModels() {\n  return Object.entries(PREDEFINED_MODELS).map(([key, value]) => ({\n    preset: key as ModelPreset,\n    ...value,\n  }));\n}\n\n/**\n * Recommend model based on language - only uses multilingual-e5 series models\n */\nexport function recommendModelForLanguage(\n  _language: 'en' | 'zh' | 'multilingual' = 'multilingual',\n  scenario: 'speed' | 'balanced' | 'quality' = 'balanced',\n): ModelPreset {\n  // All languages use multilingual models\n  if (scenario === 'quality') {\n    return 'multilingual-e5-base'; // High quality choice\n  }\n  return 'multilingual-e5-small'; // Default lightweight choice\n}\n\n/**\n * Intelligently recommend model based on device performance and usage scenario - only uses multilingual-e5 series models\n */\nexport function recommendModelForDevice(\n  _language: 'en' | 'zh' | 'multilingual' = 'multilingual',\n  deviceMemory: number = 4, // GB\n  networkSpeed: 'slow' | 'fast' = 'fast',\n  prioritizeSpeed: boolean = false,\n): ModelPreset {\n  // Low memory devices or slow network, prioritize small models\n  if (deviceMemory < 4 || networkSpeed === 'slow' || prioritizeSpeed) {\n    return 'multilingual-e5-small'; // Lightweight choice\n  }\n\n  // High performance devices can use better models\n  if (deviceMemory >= 8 && !prioritizeSpeed) {\n    return 'multilingual-e5-base'; // High performance choice\n  }\n\n  // Default balanced choice\n  return 'multilingual-e5-small';\n}\n\n/**\n * Get model size information (only supports quantized version)\n */\nexport function getModelSizeInfo(\n  preset: ModelPreset,\n  _version: 'full' | 'quantized' | 'compressed' = 'quantized',\n) {\n  const model = PREDEFINED_MODELS[preset];\n\n  return {\n    size: model.size,\n    recommended: 'quantized',\n    description: `${model.description} (Size: ${model.size})`,\n  };\n}\n\n/**\n * Compare performance and size of multiple models\n */\nexport function compareModels(presets: ModelPreset[]) {\n  return presets.map((preset) => {\n    const model = PREDEFINED_MODELS[preset];\n\n    return {\n      preset,\n      name: model.description.split(' - ')[0],\n      language: model.language,\n      performance: model.performance,\n      dimension: model.dimension,\n      latency: model.latency,\n      size: model.size,\n      features: (model as any).multilingualFeatures || {},\n      maxLength: (model as any).maxLength || 512,\n      recommendedFor: getRecommendationContext(preset),\n    };\n  });\n}\n\n/**\n * Get recommended use cases for model\n */\nfunction getRecommendationContext(preset: ModelPreset): string[] {\n  const contexts: string[] = [];\n  const model = PREDEFINED_MODELS[preset];\n\n  // All models are multilingual\n  contexts.push('Multilingual document processing');\n\n  if (model.performance === 'excellent') contexts.push('High accuracy requirements');\n  if (model.latency.includes('20ms')) contexts.push('Fast response');\n\n  // Add scenarios based on model size\n  const sizeInMB = parseInt(model.size.replace('MB', ''));\n  if (sizeInMB < 300) {\n    contexts.push('Mobile devices');\n    contexts.push('Lightweight deployment');\n  }\n\n  if (preset === 'multilingual-e5-small') {\n    contexts.push('Lightweight deployment');\n  } else if (preset === 'multilingual-e5-base') {\n    contexts.push('High accuracy requirements');\n  }\n\n  return contexts;\n}\n\n/**\n * Get ONNX model filename (only supports quantized version)\n */\nexport function getOnnxFileNameForVersion(\n  _version: 'full' | 'quantized' | 'compressed' = 'quantized',\n): string {\n  // Only return quantized version filename\n  return 'model_quantized.onnx';\n}\n\n/**\n * Get model identifier (only supports quantized version)\n */\nexport function getModelIdentifierWithVersion(\n  preset: ModelPreset,\n  _version: 'full' | 'quantized' | 'compressed' = 'quantized',\n): string {\n  const model = PREDEFINED_MODELS[preset];\n  return model.modelIdentifier;\n}\n\n/**\n * Get size comparison of all available models\n */\nexport function getAllModelSizes() {\n  const models = Object.entries(PREDEFINED_MODELS).map(([preset, config]) => {\n    return {\n      preset: preset as ModelPreset,\n      name: config.description.split(' - ')[0],\n      language: config.language,\n      size: config.size,\n      performance: config.performance,\n      latency: config.latency,\n    };\n  });\n\n  // Sort by size\n  return models.sort((a, b) => {\n    const sizeA = parseInt(a.size.replace('MB', ''));\n    const sizeB = parseInt(b.size.replace('MB', ''));\n    return sizeA - sizeB;\n  });\n}\n\n// Define necessary types\ninterface ModelConfig {\n  modelIdentifier: string;\n  localModelPathPrefix?: string; // Base path for local models (relative to public)\n  onnxModelFile?: string; // ONNX model filename\n  maxLength?: number;\n  cacheSize?: number;\n  numThreads?: number;\n  executionProviders?: string[];\n  useLocalFiles?: boolean;\n  workerPath?: string; // Worker script path (relative to extension root)\n  concurrentLimit?: number; // Worker task concurrency limit\n  forceOffscreen?: boolean; // Force offscreen mode (for testing)\n  modelPreset?: ModelPreset; // Predefined model selection\n  dimension?: number; // Vector dimension (auto-obtained from preset model)\n  modelVersion?: 'full' | 'quantized' | 'compressed'; // Model version selection\n  requiresTokenTypeIds?: boolean; // Whether model requires token_type_ids input\n}\n\ninterface WorkerMessagePayload {\n  modelPath?: string;\n  modelData?: ArrayBuffer;\n  numThreads?: number;\n  executionProviders?: string[];\n  input_ids?: number[];\n  attention_mask?: number[];\n  token_type_ids?: number[];\n  dims?: {\n    input_ids: number[];\n    attention_mask: number[];\n    token_type_ids?: number[];\n  };\n}\n\ninterface WorkerResponsePayload {\n  data?: Float32Array | number[]; // Tensor data as Float32Array or number array\n  dims?: number[]; // Tensor dimensions\n  message?: string; // For error or status messages\n}\n\ninterface WorkerStats {\n  inferenceTime?: number;\n  totalInferences?: number;\n  averageInferenceTime?: number;\n  memoryAllocations?: number;\n  batchSize?: number;\n}\n\n// Memory pool manager\nclass EmbeddingMemoryPool {\n  private pools: Map<number, Float32Array[]> = new Map();\n  private maxPoolSize: number = 10;\n  private stats = { allocated: 0, reused: 0, released: 0 };\n\n  getEmbedding(size: number): Float32Array {\n    const pool = this.pools.get(size);\n    if (pool && pool.length > 0) {\n      this.stats.reused++;\n      return pool.pop()!;\n    }\n\n    this.stats.allocated++;\n    return new Float32Array(size);\n  }\n\n  releaseEmbedding(embedding: Float32Array): void {\n    const size = embedding.length;\n    if (!this.pools.has(size)) {\n      this.pools.set(size, []);\n    }\n\n    const pool = this.pools.get(size)!;\n    if (pool.length < this.maxPoolSize) {\n      // Clear array for reuse\n      embedding.fill(0);\n      pool.push(embedding);\n      this.stats.released++;\n    }\n  }\n\n  getStats() {\n    return { ...this.stats };\n  }\n\n  clear(): void {\n    this.pools.clear();\n    this.stats = { allocated: 0, reused: 0, released: 0 };\n  }\n}\n\ninterface PendingMessage {\n  resolve: (value: WorkerResponsePayload | PromiseLike<WorkerResponsePayload>) => void;\n  reject: (reason?: any) => void;\n  type: string;\n}\n\ninterface TokenizedOutput {\n  // Simulates part of transformers.js tokenizer output\n  input_ids: TransformersTensor;\n  attention_mask: TransformersTensor;\n  token_type_ids?: TransformersTensor;\n}\n\n/**\n * SemanticSimilarityEngine proxy class\n * Used by ContentIndexer and other components to reuse engine instance in offscreen, avoiding duplicate model downloads\n */\nexport class SemanticSimilarityEngineProxy {\n  private _isInitialized = false;\n  private config: Partial<ModelConfig>;\n  private offscreenManager: OffscreenManager;\n  private _isEnsuring = false; // Flag to prevent concurrent ensureOffscreenEngineInitialized calls\n\n  constructor(config: Partial<ModelConfig> = {}) {\n    this.config = config;\n    this.offscreenManager = OffscreenManager.getInstance();\n    console.log('SemanticSimilarityEngineProxy: Proxy created with config:', {\n      modelPreset: config.modelPreset,\n      modelVersion: config.modelVersion,\n      dimension: config.dimension,\n    });\n  }\n\n  async initialize(): Promise<void> {\n    try {\n      console.log('SemanticSimilarityEngineProxy: Starting proxy initialization...');\n\n      // Ensure offscreen document exists\n      console.log('SemanticSimilarityEngineProxy: Ensuring offscreen document exists...');\n      await this.offscreenManager.ensureOffscreenDocument();\n      console.log('SemanticSimilarityEngineProxy: Offscreen document ready');\n\n      // Ensure engine in offscreen is initialized\n      console.log('SemanticSimilarityEngineProxy: Ensuring offscreen engine is initialized...');\n      await this.ensureOffscreenEngineInitialized();\n\n      this._isInitialized = true;\n      console.log(\n        'SemanticSimilarityEngineProxy: Proxy initialized, delegating to offscreen engine',\n      );\n    } catch (error) {\n      console.error('SemanticSimilarityEngineProxy: Initialization failed:', error);\n      throw new Error(\n        `Failed to initialize proxy: ${error instanceof Error ? error.message : 'Unknown error'}`,\n      );\n    }\n  }\n\n  /**\n   * Check engine status in offscreen\n   */\n  private async checkOffscreenEngineStatus(): Promise<{\n    isInitialized: boolean;\n    currentConfig: any;\n  }> {\n    try {\n      const response = await chrome.runtime.sendMessage({\n        target: 'offscreen',\n        type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_STATUS,\n      });\n\n      if (response && response.success) {\n        return {\n          isInitialized: response.isInitialized || false,\n          currentConfig: response.currentConfig || null,\n        };\n      }\n    } catch (error) {\n      console.warn('SemanticSimilarityEngineProxy: Failed to check engine status:', error);\n    }\n\n    return { isInitialized: false, currentConfig: null };\n  }\n\n  /**\n   * Ensure engine in offscreen is initialized (with concurrency protection)\n   */\n  private async ensureOffscreenEngineInitialized(): Promise<void> {\n    // Prevent concurrent initialization attempts\n    if (this._isEnsuring) {\n      console.log('SemanticSimilarityEngineProxy: Already ensuring initialization, waiting...');\n      // Wait a bit and check again\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      return;\n    }\n\n    try {\n      this._isEnsuring = true;\n      const status = await this.checkOffscreenEngineStatus();\n\n      if (!status.isInitialized) {\n        console.log(\n          'SemanticSimilarityEngineProxy: Engine not initialized in offscreen, initializing...',\n        );\n\n        // Reinitialize engine\n        const response = await chrome.runtime.sendMessage({\n          target: 'offscreen',\n          type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,\n          config: this.config,\n        });\n\n        if (!response || !response.success) {\n          throw new Error(response?.error || 'Failed to initialize engine in offscreen document');\n        }\n\n        console.log('SemanticSimilarityEngineProxy: Engine reinitialized successfully');\n      }\n    } finally {\n      this._isEnsuring = false;\n    }\n  }\n\n  /**\n   * Send message to offscreen document with retry mechanism and auto-reinitialization\n   */\n  private async sendMessageToOffscreen(message: any, maxRetries: number = 3): Promise<any> {\n    // 确保offscreen document存在\n    await this.offscreenManager.ensureOffscreenDocument();\n\n    let lastError: Error | null = null;\n\n    for (let attempt = 1; attempt <= maxRetries; attempt++) {\n      try {\n        console.log(\n          `SemanticSimilarityEngineProxy: Sending message (attempt ${attempt}/${maxRetries}):`,\n          message.type,\n        );\n\n        const response = await chrome.runtime.sendMessage(message);\n\n        if (!response) {\n          throw new Error('No response received from offscreen document');\n        }\n\n        // If engine not initialized error received, try to reinitialize\n        if (!response.success && response.error && response.error.includes('not initialized')) {\n          console.log(\n            'SemanticSimilarityEngineProxy: Engine not initialized, attempting to reinitialize...',\n          );\n          await this.ensureOffscreenEngineInitialized();\n\n          // Resend original message\n          const retryResponse = await chrome.runtime.sendMessage(message);\n          if (retryResponse && retryResponse.success) {\n            return retryResponse;\n          }\n        }\n\n        return response;\n      } catch (error) {\n        lastError = error as Error;\n        console.warn(\n          `SemanticSimilarityEngineProxy: Message failed (attempt ${attempt}/${maxRetries}):`,\n          error,\n        );\n\n        // If engine not initialized error, try to reinitialize\n        if (error instanceof Error && error.message.includes('not initialized')) {\n          try {\n            console.log(\n              'SemanticSimilarityEngineProxy: Attempting to reinitialize engine due to error...',\n            );\n            await this.ensureOffscreenEngineInitialized();\n\n            // Resend original message\n            const retryResponse = await chrome.runtime.sendMessage(message);\n            if (retryResponse && retryResponse.success) {\n              return retryResponse;\n            }\n          } catch (reinitError) {\n            console.warn(\n              'SemanticSimilarityEngineProxy: Failed to reinitialize engine:',\n              reinitError,\n            );\n          }\n        }\n\n        if (attempt < maxRetries) {\n          // Wait before retry\n          await new Promise((resolve) => setTimeout(resolve, 100 * attempt));\n\n          // Re-ensure offscreen document exists\n          try {\n            await this.offscreenManager.ensureOffscreenDocument();\n          } catch (offscreenError) {\n            console.warn(\n              'SemanticSimilarityEngineProxy: Failed to ensure offscreen document:',\n              offscreenError,\n            );\n          }\n        }\n      }\n    }\n\n    throw new Error(\n      `Failed to communicate with offscreen document after ${maxRetries} attempts. Last error: ${lastError?.message}`,\n    );\n  }\n\n  async getEmbedding(text: string, options: Record<string, any> = {}): Promise<Float32Array> {\n    if (!this._isInitialized) {\n      await this.initialize();\n    }\n\n    // Check and ensure engine is initialized before each call\n    await this.ensureOffscreenEngineInitialized();\n\n    const response = await this.sendMessageToOffscreen({\n      target: 'offscreen',\n      type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_COMPUTE,\n      text: text,\n      options: options,\n    });\n\n    if (!response || !response.success) {\n      throw new Error(response?.error || 'Failed to get embedding from offscreen document');\n    }\n\n    if (!response.embedding || !Array.isArray(response.embedding)) {\n      throw new Error('Invalid embedding data received from offscreen document');\n    }\n\n    return new Float32Array(response.embedding);\n  }\n\n  async getEmbeddingsBatch(\n    texts: string[],\n    options: Record<string, any> = {},\n  ): Promise<Float32Array[]> {\n    if (!this._isInitialized) {\n      await this.initialize();\n    }\n\n    if (!texts || texts.length === 0) return [];\n\n    // Check and ensure engine is initialized before each call\n    await this.ensureOffscreenEngineInitialized();\n\n    const response = await this.sendMessageToOffscreen({\n      target: 'offscreen',\n      type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE,\n      texts: texts,\n      options: options,\n    });\n\n    if (!response || !response.success) {\n      throw new Error(response?.error || 'Failed to get embeddings batch from offscreen document');\n    }\n\n    return response.embeddings.map((emb: number[]) => new Float32Array(emb));\n  }\n\n  async computeSimilarity(\n    text1: string,\n    text2: string,\n    options: Record<string, any> = {},\n  ): Promise<number> {\n    const [embedding1, embedding2] = await this.getEmbeddingsBatch([text1, text2], options);\n    return this.cosineSimilarity(embedding1, embedding2);\n  }\n\n  async computeSimilarityBatch(\n    pairs: { text1: string; text2: string }[],\n    options: Record<string, any> = {},\n  ): Promise<number[]> {\n    if (!this._isInitialized) {\n      await this.initialize();\n    }\n\n    // Check and ensure engine is initialized before each call\n    await this.ensureOffscreenEngineInitialized();\n\n    const response = await this.sendMessageToOffscreen({\n      target: 'offscreen',\n      type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE,\n      pairs: pairs,\n      options: options,\n    });\n\n    if (!response || !response.success) {\n      throw new Error(\n        response?.error || 'Failed to compute similarity batch from offscreen document',\n      );\n    }\n\n    return response.similarities;\n  }\n\n  private cosineSimilarity(a: Float32Array, b: Float32Array): number {\n    if (a.length !== b.length) {\n      throw new Error(`Vector dimensions don't match: ${a.length} vs ${b.length}`);\n    }\n\n    let dotProduct = 0;\n    let normA = 0;\n    let normB = 0;\n\n    for (let i = 0; i < a.length; i++) {\n      dotProduct += a[i] * b[i];\n      normA += a[i] * a[i];\n      normB += b[i] * b[i];\n    }\n\n    const magnitude = Math.sqrt(normA) * Math.sqrt(normB);\n    return magnitude === 0 ? 0 : dotProduct / magnitude;\n  }\n\n  get isInitialized(): boolean {\n    return this._isInitialized;\n  }\n\n  async dispose(): Promise<void> {\n    // Proxy class doesn't need to clean up resources, actual resources are managed by offscreen\n    this._isInitialized = false;\n    console.log('SemanticSimilarityEngineProxy: Proxy disposed');\n  }\n}\n\nexport class SemanticSimilarityEngine {\n  private worker: Worker | null = null;\n  private tokenizer: PreTrainedTokenizer | null = null;\n  public isInitialized = false;\n  private isInitializing = false;\n  private initPromise: Promise<void> | null = null;\n  private nextTokenId = 0;\n  private pendingMessages = new Map<number, PendingMessage>();\n  private useOffscreen = false; // Whether to use offscreen mode\n\n  public readonly config: Required<ModelConfig>;\n\n  private embeddingCache: LRUCache<string, Float32Array>;\n  // Added: tokenization cache\n  private tokenizationCache: LRUCache<string, TokenizedOutput>;\n  // Added: memory pool manager\n  private memoryPool: EmbeddingMemoryPool;\n  // Added: SIMD math engine\n  private simdMath: SIMDMathEngine | null = null;\n  private useSIMD = false;\n\n  public cacheStats = {\n    embedding: { hits: 0, misses: 0, size: 0 },\n    tokenization: { hits: 0, misses: 0, size: 0 },\n  };\n\n  public performanceStats = {\n    totalEmbeddingComputations: 0,\n    totalEmbeddingTime: 0,\n    averageEmbeddingTime: 0,\n    totalTokenizationTime: 0,\n    averageTokenizationTime: 0,\n    totalSimilarityComputations: 0,\n    totalSimilarityTime: 0,\n    averageSimilarityTime: 0,\n    workerStats: null as WorkerStats | null,\n  };\n\n  private runningWorkerTasks = 0;\n  private workerTaskQueue: (() => void)[] = [];\n\n  /**\n   * Detect if current runtime environment supports Worker\n   */\n  private isWorkerSupported(): boolean {\n    try {\n      // Check if in Service Worker environment (background script)\n      if (typeof importScripts === 'function') {\n        return false;\n      }\n\n      // Check if Worker constructor is available\n      return typeof Worker !== 'undefined';\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Detect if in offscreen document environment\n   */\n  private isInOffscreenDocument(): boolean {\n    try {\n      // In offscreen document, window.location.pathname is usually '/offscreen.html'\n      return (\n        typeof window !== 'undefined' &&\n        window.location &&\n        window.location.pathname.includes('offscreen')\n      );\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Ensure offscreen document exists\n   */\n  private async ensureOffscreenDocument(): Promise<void> {\n    return OffscreenManager.getInstance().ensureOffscreenDocument();\n  }\n\n  // Helper function to safely convert tensor data to number array\n  private convertTensorDataToNumbers(data: any): number[] {\n    if (data instanceof BigInt64Array) {\n      return Array.from(data, (val: bigint) => Number(val));\n    } else if (data instanceof Int32Array) {\n      return Array.from(data);\n    } else {\n      return Array.from(data);\n    }\n  }\n\n  constructor(options: Partial<ModelConfig> = {}) {\n    console.log('SemanticSimilarityEngine: Constructor called with options:', {\n      useLocalFiles: options.useLocalFiles,\n      modelIdentifier: options.modelIdentifier,\n      forceOffscreen: options.forceOffscreen,\n      modelPreset: options.modelPreset,\n      modelVersion: options.modelVersion,\n    });\n\n    // Handle model presets\n    let modelConfig = { ...options };\n    if (options.modelPreset && PREDEFINED_MODELS[options.modelPreset]) {\n      const preset = PREDEFINED_MODELS[options.modelPreset];\n      const modelVersion = options.modelVersion || 'quantized'; // Default to quantized version\n      const baseModelIdentifier = preset.modelIdentifier; // Use base identifier without version suffix\n      const onnxFileName = getOnnxFileNameForVersion(modelVersion); // Get ONNX filename based on version\n\n      // Get model-specific configuration\n      const modelSpecificConfig = (preset as any).modelSpecificConfig || {};\n\n      modelConfig = {\n        ...options,\n        modelIdentifier: baseModelIdentifier, // Use base identifier\n        onnxModelFile: onnxFileName, // Set corresponding version ONNX filename\n        dimension: preset.dimension,\n        modelVersion: modelVersion,\n        requiresTokenTypeIds: modelSpecificConfig.requiresTokenTypeIds !== false, // Default to true unless explicitly set to false\n      };\n      console.log(\n        `SemanticSimilarityEngine: Using model preset \"${options.modelPreset}\" with version \"${modelVersion}\":`,\n        preset,\n      );\n      console.log(`SemanticSimilarityEngine: Base model identifier: ${baseModelIdentifier}`);\n      console.log(`SemanticSimilarityEngine: ONNX file for version: ${onnxFileName}`);\n      console.log(\n        `SemanticSimilarityEngine: Requires token_type_ids: ${modelConfig.requiresTokenTypeIds}`,\n      );\n    }\n\n    // Set default configuration - using 2025 recommended default model\n    this.config = {\n      ...modelConfig,\n      modelIdentifier: modelConfig.modelIdentifier || 'Xenova/bge-small-en-v1.5',\n      localModelPathPrefix: modelConfig.localModelPathPrefix || 'models/',\n      onnxModelFile: modelConfig.onnxModelFile || 'model.onnx',\n      maxLength: modelConfig.maxLength || 256,\n      cacheSize: modelConfig.cacheSize || 500,\n      numThreads:\n        modelConfig.numThreads ||\n        (typeof navigator !== 'undefined' && navigator.hardwareConcurrency\n          ? Math.max(1, Math.floor(navigator.hardwareConcurrency / 2))\n          : 2),\n      executionProviders:\n        modelConfig.executionProviders ||\n        (typeof WebAssembly === 'object' &&\n        WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]))\n          ? ['wasm']\n          : ['webgl']),\n      useLocalFiles: (() => {\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - modelConfig.useLocalFiles:',\n          modelConfig.useLocalFiles,\n        );\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - modelConfig.useLocalFiles !== undefined:',\n          modelConfig.useLocalFiles !== undefined,\n        );\n        const result = modelConfig.useLocalFiles !== undefined ? modelConfig.useLocalFiles : true;\n        console.log('SemanticSimilarityEngine: DEBUG - final useLocalFiles value:', result);\n        return result;\n      })(),\n      workerPath: modelConfig.workerPath || 'js/similarity.worker.js', // Will be overridden by WXT's `new URL`\n      concurrentLimit:\n        modelConfig.concurrentLimit ||\n        Math.max(\n          1,\n          modelConfig.numThreads ||\n            (typeof navigator !== 'undefined' && navigator.hardwareConcurrency\n              ? Math.max(1, Math.floor(navigator.hardwareConcurrency / 2))\n              : 2),\n        ),\n      forceOffscreen: modelConfig.forceOffscreen || false,\n      modelPreset: modelConfig.modelPreset || 'bge-small-en-v1.5',\n      dimension: modelConfig.dimension || 384,\n      modelVersion: modelConfig.modelVersion || 'quantized',\n      requiresTokenTypeIds: modelConfig.requiresTokenTypeIds !== false, // Default to true\n    } as Required<ModelConfig>;\n\n    console.log('SemanticSimilarityEngine: Final config:', {\n      useLocalFiles: this.config.useLocalFiles,\n      modelIdentifier: this.config.modelIdentifier,\n      forceOffscreen: this.config.forceOffscreen,\n    });\n\n    this.embeddingCache = new LRUCache<string, Float32Array>(this.config.cacheSize);\n    this.tokenizationCache = new LRUCache<string, TokenizedOutput>(\n      Math.min(this.config.cacheSize, 200),\n    );\n    this.memoryPool = new EmbeddingMemoryPool();\n    this.simdMath = new SIMDMathEngine();\n  }\n\n  private _sendMessageToWorker(\n    type: string,\n    payload?: WorkerMessagePayload,\n    transferList?: Transferable[],\n  ): Promise<WorkerResponsePayload> {\n    return new Promise((resolve, reject) => {\n      if (!this.worker) {\n        reject(new Error('Worker is not initialized.'));\n        return;\n      }\n      const id = this.nextTokenId++;\n      this.pendingMessages.set(id, { resolve, reject, type });\n\n      // Use transferable objects if provided for zero-copy transfer\n      if (transferList && transferList.length > 0) {\n        this.worker.postMessage({ id, type, payload }, transferList);\n      } else {\n        this.worker.postMessage({ id, type, payload });\n      }\n    });\n  }\n\n  private _setupWorker(): void {\n    console.log('SemanticSimilarityEngine: Setting up worker...');\n\n    // 方式1: Chrome extension URL (推荐，生产环境最可靠)\n    try {\n      const workerUrl = chrome.runtime.getURL('workers/similarity.worker.js');\n      console.log(`SemanticSimilarityEngine: Trying chrome.runtime.getURL ${workerUrl}`);\n      this.worker = new Worker(workerUrl);\n      console.log(`SemanticSimilarityEngine: Method 1 successful with path`);\n    } catch (error) {\n      console.warn('Method (chrome.runtime.getURL) failed:', error);\n    }\n\n    if (!this.worker) {\n      throw new Error('Worker creation failed');\n    }\n\n    this.worker.onmessage = (\n      event: MessageEvent<{\n        id: number;\n        type: string;\n        status: string;\n        payload: WorkerResponsePayload;\n        stats?: WorkerStats;\n      }>,\n    ) => {\n      const { id, status, payload, stats } = event.data;\n      const promiseCallbacks = this.pendingMessages.get(id);\n      if (!promiseCallbacks) return;\n\n      this.pendingMessages.delete(id);\n\n      // 更新 Worker 统计信息\n      if (stats) {\n        this.performanceStats.workerStats = stats;\n      }\n\n      if (status === 'success') {\n        promiseCallbacks.resolve(payload);\n      } else {\n        const error = new Error(\n          payload?.message || `Worker error for task ${promiseCallbacks.type}`,\n        );\n        (error as any).name = (payload as any)?.name || 'WorkerError';\n        (error as any).stack = (payload as any)?.stack || undefined;\n        console.error(\n          `Error from worker (task ${id}, type ${promiseCallbacks.type}):`,\n          error,\n          event.data,\n        );\n        promiseCallbacks.reject(error);\n      }\n    };\n\n    this.worker.onerror = (error: ErrorEvent) => {\n      console.error('==== Unhandled error in SemanticSimilarityEngine Worker ====');\n      console.error('Event Message:', error.message);\n      console.error('Event Filename:', error.filename);\n      console.error('Event Lineno:', error.lineno);\n      console.error('Event Colno:', error.colno);\n      if (error.error) {\n        // 检查 event.error 是否存在\n        console.error('Actual Error Name:', error.error.name);\n        console.error('Actual Error Message:', error.error.message);\n        console.error('Actual Error Stack:', error.error.stack);\n      } else {\n        console.error('Actual Error object (event.error) is not available. Error details:', {\n          message: error.message,\n          filename: error.filename,\n          lineno: error.lineno,\n          colno: error.colno,\n        });\n      }\n      console.error('==========================================================');\n      this.pendingMessages.forEach((callbacks) => {\n        callbacks.reject(new Error(`Worker terminated or unhandled error: ${error.message}`));\n      });\n      this.pendingMessages.clear();\n      this.isInitialized = false;\n      this.isInitializing = false;\n    };\n  }\n\n  public async initialize(): Promise<void> {\n    if (this.isInitialized) return Promise.resolve();\n    if (this.isInitializing && this.initPromise) return this.initPromise;\n\n    this.isInitializing = true;\n    this.initPromise = this._doInitialize().finally(() => {\n      this.isInitializing = false;\n      // this.warmupModel();\n    });\n    return this.initPromise;\n  }\n\n  /**\n   * 带进度回调的初始化方法\n   */\n  public async initializeWithProgress(\n    onProgress?: (progress: { status: string; progress: number; message?: string }) => void,\n  ): Promise<void> {\n    if (this.isInitialized) return Promise.resolve();\n    if (this.isInitializing && this.initPromise) return this.initPromise;\n\n    this.isInitializing = true;\n    this.initPromise = this._doInitializeWithProgress(onProgress).finally(() => {\n      this.isInitializing = false;\n      // this.warmupModel();\n    });\n    return this.initPromise;\n  }\n\n  /**\n   * 带进度回调的内部初始化方法\n   */\n  private async _doInitializeWithProgress(\n    onProgress?: (progress: { status: string; progress: number; message?: string }) => void,\n  ): Promise<void> {\n    console.log('SemanticSimilarityEngine: Initializing with progress tracking...');\n    const startTime = performance.now();\n\n    // 进度报告辅助函数\n    const reportProgress = (status: string, progress: number, message?: string) => {\n      if (onProgress) {\n        onProgress({ status, progress, message });\n      }\n    };\n\n    try {\n      reportProgress('initializing', 5, 'Starting initialization...');\n\n      // 检测环境并决定使用哪种模式\n      const workerSupported = this.isWorkerSupported();\n      const inOffscreenDocument = this.isInOffscreenDocument();\n\n      // 🛠️ 防止死循环：如果已经在 offscreen document 中，强制使用直接 Worker 模式\n      if (inOffscreenDocument) {\n        this.useOffscreen = false;\n        console.log(\n          'SemanticSimilarityEngine: Running in offscreen document, using direct Worker mode to prevent recursion',\n        );\n      } else {\n        this.useOffscreen = this.config.forceOffscreen || !workerSupported;\n      }\n\n      console.log(\n        `SemanticSimilarityEngine: Worker supported: ${workerSupported}, In offscreen: ${inOffscreenDocument}, Using offscreen: ${this.useOffscreen}`,\n      );\n\n      reportProgress('initializing', 10, 'Environment detection complete');\n\n      if (this.useOffscreen) {\n        // 使用offscreen模式 - 委托给offscreen document，它会处理自己的进度\n        reportProgress('initializing', 15, 'Setting up offscreen document...');\n        await this.ensureOffscreenDocument();\n\n        // 发送初始化消息到offscreen document\n        console.log('SemanticSimilarityEngine: Sending config to offscreen:', {\n          useLocalFiles: this.config.useLocalFiles,\n          modelIdentifier: this.config.modelIdentifier,\n          localModelPathPrefix: this.config.localModelPathPrefix,\n        });\n\n        // 确保配置对象被正确序列化，显式设置所有属性\n        const configToSend = {\n          modelIdentifier: this.config.modelIdentifier,\n          localModelPathPrefix: this.config.localModelPathPrefix,\n          onnxModelFile: this.config.onnxModelFile,\n          maxLength: this.config.maxLength,\n          cacheSize: this.config.cacheSize,\n          numThreads: this.config.numThreads,\n          executionProviders: this.config.executionProviders,\n          useLocalFiles: Boolean(this.config.useLocalFiles), // 强制转换为布尔值\n          workerPath: this.config.workerPath,\n          concurrentLimit: this.config.concurrentLimit,\n          forceOffscreen: this.config.forceOffscreen,\n          modelPreset: this.config.modelPreset,\n          modelVersion: this.config.modelVersion,\n          dimension: this.config.dimension,\n        };\n\n        // 使用 JSON 序列化确保数据完整性\n        const serializedConfig = JSON.parse(JSON.stringify(configToSend));\n\n        reportProgress('initializing', 20, 'Delegating to offscreen document...');\n\n        const response = await chrome.runtime.sendMessage({\n          target: 'offscreen',\n          type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,\n          config: serializedConfig,\n        });\n\n        if (!response || !response.success) {\n          throw new Error(response?.error || 'Failed to initialize engine in offscreen document');\n        }\n\n        reportProgress('ready', 100, 'Initialized via offscreen document');\n        console.log('SemanticSimilarityEngine: Initialized via offscreen document');\n      } else {\n        // 使用直接Worker模式 - 这里我们可以提供真实的进度跟踪\n        await this._initializeDirectWorkerWithProgress(reportProgress);\n      }\n\n      this.isInitialized = true;\n      console.log(\n        `SemanticSimilarityEngine: Initialization complete in ${(performance.now() - startTime).toFixed(2)}ms`,\n      );\n    } catch (error) {\n      console.error('SemanticSimilarityEngine: Initialization failed.', error);\n      const errorMessage = error instanceof Error ? error.message : 'Unknown error';\n      reportProgress('error', 0, `Initialization failed: ${errorMessage}`);\n      if (this.worker) this.worker.terminate();\n      this.worker = null;\n      this.isInitialized = false;\n      this.isInitializing = false;\n      this.initPromise = null;\n\n      // 创建一个更详细的错误对象\n      const enhancedError = new Error(errorMessage);\n      enhancedError.name = 'ModelInitializationError';\n      throw enhancedError;\n    }\n  }\n\n  private async _doInitialize(): Promise<void> {\n    console.log('SemanticSimilarityEngine: Initializing...');\n    const startTime = performance.now();\n    try {\n      // 检测环境并决定使用哪种模式\n      const workerSupported = this.isWorkerSupported();\n      const inOffscreenDocument = this.isInOffscreenDocument();\n\n      // 🛠️ 防止死循环：如果已经在 offscreen document 中，强制使用直接 Worker 模式\n      if (inOffscreenDocument) {\n        this.useOffscreen = false;\n        console.log(\n          'SemanticSimilarityEngine: Running in offscreen document, using direct Worker mode to prevent recursion',\n        );\n      } else {\n        this.useOffscreen = this.config.forceOffscreen || !workerSupported;\n      }\n\n      console.log(\n        `SemanticSimilarityEngine: Worker supported: ${workerSupported}, In offscreen: ${inOffscreenDocument}, Using offscreen: ${this.useOffscreen}`,\n      );\n\n      if (this.useOffscreen) {\n        // 使用offscreen模式\n        await this.ensureOffscreenDocument();\n\n        // 发送初始化消息到offscreen document\n        console.log('SemanticSimilarityEngine: Sending config to offscreen:', {\n          useLocalFiles: this.config.useLocalFiles,\n          modelIdentifier: this.config.modelIdentifier,\n          localModelPathPrefix: this.config.localModelPathPrefix,\n        });\n\n        // 确保配置对象被正确序列化，显式设置所有属性\n        const configToSend = {\n          modelIdentifier: this.config.modelIdentifier,\n          localModelPathPrefix: this.config.localModelPathPrefix,\n          onnxModelFile: this.config.onnxModelFile,\n          maxLength: this.config.maxLength,\n          cacheSize: this.config.cacheSize,\n          numThreads: this.config.numThreads,\n          executionProviders: this.config.executionProviders,\n          useLocalFiles: Boolean(this.config.useLocalFiles), // 强制转换为布尔值\n          workerPath: this.config.workerPath,\n          concurrentLimit: this.config.concurrentLimit,\n          forceOffscreen: this.config.forceOffscreen,\n          modelPreset: this.config.modelPreset,\n          modelVersion: this.config.modelVersion,\n          dimension: this.config.dimension,\n        };\n\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - configToSend.useLocalFiles:',\n          configToSend.useLocalFiles,\n        );\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - typeof configToSend.useLocalFiles:',\n          typeof configToSend.useLocalFiles,\n        );\n\n        console.log('SemanticSimilarityEngine: Explicit config to send:', configToSend);\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - this.config.useLocalFiles value:',\n          this.config.useLocalFiles,\n        );\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - typeof this.config.useLocalFiles:',\n          typeof this.config.useLocalFiles,\n        );\n\n        // 使用 JSON 序列化确保数据完整性\n        const serializedConfig = JSON.parse(JSON.stringify(configToSend));\n        console.log(\n          'SemanticSimilarityEngine: DEBUG - serializedConfig.useLocalFiles:',\n          serializedConfig.useLocalFiles,\n        );\n\n        const response = await chrome.runtime.sendMessage({\n          target: 'offscreen',\n          type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_INIT,\n          config: serializedConfig, // 使用原始配置，不强制修改 useLocalFiles\n        });\n\n        if (!response || !response.success) {\n          throw new Error(response?.error || 'Failed to initialize engine in offscreen document');\n        }\n\n        console.log('SemanticSimilarityEngine: Initialized via offscreen document');\n      } else {\n        // 使用直接Worker模式\n        this._setupWorker();\n\n        TransformersEnv.allowRemoteModels = !this.config.useLocalFiles;\n        TransformersEnv.allowLocalModels = this.config.useLocalFiles;\n\n        console.log(`SemanticSimilarityEngine: TransformersEnv config:`, {\n          allowRemoteModels: TransformersEnv.allowRemoteModels,\n          allowLocalModels: TransformersEnv.allowLocalModels,\n          useLocalFiles: this.config.useLocalFiles,\n        });\n        if (TransformersEnv.backends?.onnx?.wasm) {\n          // 检查路径是否存在\n          TransformersEnv.backends.onnx.wasm.numThreads = this.config.numThreads;\n        }\n\n        let tokenizerIdentifier = this.config.modelIdentifier;\n        if (this.config.useLocalFiles) {\n          // 对于WXT，public目录下的资源在运行时位于根路径\n          // 直接使用模型标识符，transformers.js 会自动添加 /models/ 前缀\n          tokenizerIdentifier = this.config.modelIdentifier;\n        }\n        console.log(\n          `SemanticSimilarityEngine: Loading tokenizer from ${tokenizerIdentifier} (local_files_only: ${this.config.useLocalFiles})`,\n        );\n        const tokenizerConfig: any = {\n          quantized: false,\n          local_files_only: this.config.useLocalFiles,\n        };\n\n        // 对于不需要token_type_ids的模型，在tokenizer配置中明确设置\n        if (!this.config.requiresTokenTypeIds) {\n          tokenizerConfig.return_token_type_ids = false;\n        }\n\n        console.log(`SemanticSimilarityEngine: Full tokenizer config:`, {\n          tokenizerIdentifier,\n          localModelPathPrefix: this.config.localModelPathPrefix,\n          modelIdentifier: this.config.modelIdentifier,\n          useLocalFiles: this.config.useLocalFiles,\n          local_files_only: this.config.useLocalFiles,\n          requiresTokenTypeIds: this.config.requiresTokenTypeIds,\n          tokenizerConfig,\n        });\n        this.tokenizer = await AutoTokenizer.from_pretrained(tokenizerIdentifier, tokenizerConfig);\n        console.log('SemanticSimilarityEngine: Tokenizer loaded.');\n\n        if (this.config.useLocalFiles) {\n          // Local files mode - use URL path as before\n          const onnxModelPathForWorker = chrome.runtime.getURL(\n            `models/${this.config.modelIdentifier}/${this.config.onnxModelFile}`,\n          );\n          console.log(\n            `SemanticSimilarityEngine: Instructing worker to load local ONNX model from ${onnxModelPathForWorker}`,\n          );\n          await this._sendMessageToWorker('init', {\n            modelPath: onnxModelPathForWorker,\n            numThreads: this.config.numThreads,\n            executionProviders: this.config.executionProviders,\n          });\n        } else {\n          // Remote files mode - use cached model data\n          const modelIdParts = this.config.modelIdentifier.split('/');\n          const modelNameForUrl =\n            modelIdParts.length > 1\n              ? this.config.modelIdentifier\n              : `Xenova/${this.config.modelIdentifier}`;\n          const onnxModelUrl = `https://huggingface.co/${modelNameForUrl}/resolve/main/onnx/${this.config.onnxModelFile}`;\n\n          if (!this.config.modelIdentifier.includes('/')) {\n            console.warn(\n              `Warning: modelIdentifier \"${this.config.modelIdentifier}\" might not be a full HuggingFace path. Assuming Xenova prefix for remote URL.`,\n            );\n          }\n\n          console.log(`SemanticSimilarityEngine: Getting cached model data from ${onnxModelUrl}`);\n\n          // Get model data from cache (may download if not cached)\n          const modelData = await getCachedModelData(onnxModelUrl);\n\n          console.log(\n            `SemanticSimilarityEngine: Sending cached model data to worker (${modelData.byteLength} bytes)`,\n          );\n\n          // Send ArrayBuffer to worker with transferable objects for zero-copy\n          await this._sendMessageToWorker(\n            'init',\n            {\n              modelData: modelData,\n              numThreads: this.config.numThreads,\n              executionProviders: this.config.executionProviders,\n            },\n            [modelData],\n          );\n        }\n        console.log('SemanticSimilarityEngine: Worker reported model initialized.');\n\n        // 尝试初始化 SIMD 加速\n        try {\n          console.log('SemanticSimilarityEngine: Checking SIMD support...');\n          const simdSupported = await SIMDMathEngine.checkSIMDSupport();\n\n          if (simdSupported) {\n            console.log('SemanticSimilarityEngine: SIMD supported, initializing...');\n            await this.simdMath!.initialize();\n            this.useSIMD = true;\n            console.log('SemanticSimilarityEngine: ✅ SIMD acceleration enabled');\n          } else {\n            console.log(\n              'SemanticSimilarityEngine: ❌ SIMD not supported, using JavaScript fallback',\n            );\n            console.log('SemanticSimilarityEngine: To enable SIMD, please use:');\n            console.log('  - Chrome 91+ (May 2021)');\n            console.log('  - Firefox 89+ (June 2021)');\n            console.log('  - Safari 16.4+ (March 2023)');\n            console.log('  - Edge 91+ (May 2021)');\n            this.useSIMD = false;\n          }\n        } catch (simdError) {\n          console.warn(\n            'SemanticSimilarityEngine: SIMD initialization failed, using JavaScript fallback:',\n            simdError,\n          );\n          this.useSIMD = false;\n        }\n      }\n\n      this.isInitialized = true;\n      console.log(\n        `SemanticSimilarityEngine: Initialization complete in ${(performance.now() - startTime).toFixed(2)}ms`,\n      );\n    } catch (error) {\n      console.error('SemanticSimilarityEngine: Initialization failed.', error);\n      if (this.worker) this.worker.terminate();\n      this.worker = null;\n      this.isInitialized = false;\n      this.isInitializing = false;\n      this.initPromise = null;\n\n      // 创建一个更详细的错误对象\n      const errorMessage = error instanceof Error ? error.message : 'Unknown error';\n      const enhancedError = new Error(errorMessage);\n      enhancedError.name = 'ModelInitializationError';\n      throw enhancedError;\n    }\n  }\n\n  /**\n   * 直接Worker模式的初始化，支持进度回调\n   */\n  private async _initializeDirectWorkerWithProgress(\n    reportProgress: (status: string, progress: number, message?: string) => void,\n  ): Promise<void> {\n    // 使用直接Worker模式\n    reportProgress('initializing', 25, 'Setting up worker...');\n    this._setupWorker();\n\n    TransformersEnv.allowRemoteModels = !this.config.useLocalFiles;\n    TransformersEnv.allowLocalModels = this.config.useLocalFiles;\n\n    console.log(`SemanticSimilarityEngine: TransformersEnv config:`, {\n      allowRemoteModels: TransformersEnv.allowRemoteModels,\n      allowLocalModels: TransformersEnv.allowLocalModels,\n      useLocalFiles: this.config.useLocalFiles,\n    });\n    if (TransformersEnv.backends?.onnx?.wasm) {\n      TransformersEnv.backends.onnx.wasm.numThreads = this.config.numThreads;\n    }\n\n    let tokenizerIdentifier = this.config.modelIdentifier;\n    if (this.config.useLocalFiles) {\n      tokenizerIdentifier = this.config.modelIdentifier;\n    }\n\n    reportProgress('downloading', 40, 'Loading tokenizer...');\n    console.log(\n      `SemanticSimilarityEngine: Loading tokenizer from ${tokenizerIdentifier} (local_files_only: ${this.config.useLocalFiles})`,\n    );\n\n    // 使用 transformers.js 2.17+ 的进度回调功能\n    const tokenizerProgressCallback = (progress: any) => {\n      if (progress.status === 'downloading') {\n        const progressPercent = Math.min(40 + (progress.progress || 0) * 0.3, 70);\n        reportProgress(\n          'downloading',\n          progressPercent,\n          `Downloading tokenizer: ${progress.file || ''}`,\n        );\n      }\n    };\n\n    const tokenizerConfig: any = {\n      quantized: false,\n      local_files_only: this.config.useLocalFiles,\n    };\n\n    // 对于不需要token_type_ids的模型，在tokenizer配置中明确设置\n    if (!this.config.requiresTokenTypeIds) {\n      tokenizerConfig.return_token_type_ids = false;\n    }\n\n    try {\n      if (!this.config.useLocalFiles) {\n        tokenizerConfig.progress_callback = tokenizerProgressCallback;\n      }\n      this.tokenizer = await AutoTokenizer.from_pretrained(tokenizerIdentifier, tokenizerConfig);\n    } catch (error) {\n      // 如果进度回调不支持，回退到标准方式\n      console.log(\n        'SemanticSimilarityEngine: Progress callback not supported, using standard loading',\n      );\n      delete tokenizerConfig.progress_callback;\n      this.tokenizer = await AutoTokenizer.from_pretrained(tokenizerIdentifier, tokenizerConfig);\n    }\n\n    reportProgress('downloading', 70, 'Tokenizer loaded, setting up ONNX model...');\n    console.log('SemanticSimilarityEngine: Tokenizer loaded.');\n\n    if (this.config.useLocalFiles) {\n      // Local files mode - use URL path as before\n      const onnxModelPathForWorker = chrome.runtime.getURL(\n        `models/${this.config.modelIdentifier}/${this.config.onnxModelFile}`,\n      );\n      reportProgress('downloading', 80, 'Loading local ONNX model...');\n      console.log(\n        `SemanticSimilarityEngine: Instructing worker to load local ONNX model from ${onnxModelPathForWorker}`,\n      );\n      await this._sendMessageToWorker('init', {\n        modelPath: onnxModelPathForWorker,\n        numThreads: this.config.numThreads,\n        executionProviders: this.config.executionProviders,\n      });\n    } else {\n      // Remote files mode - use cached model data\n      const modelIdParts = this.config.modelIdentifier.split('/');\n      const modelNameForUrl =\n        modelIdParts.length > 1\n          ? this.config.modelIdentifier\n          : `Xenova/${this.config.modelIdentifier}`;\n      const onnxModelUrl = `https://huggingface.co/${modelNameForUrl}/resolve/main/onnx/${this.config.onnxModelFile}`;\n\n      if (!this.config.modelIdentifier.includes('/')) {\n        console.warn(\n          `Warning: modelIdentifier \"${this.config.modelIdentifier}\" might not be a full HuggingFace path. Assuming Xenova prefix for remote URL.`,\n        );\n      }\n\n      reportProgress('downloading', 80, 'Loading cached ONNX model...');\n      console.log(`SemanticSimilarityEngine: Getting cached model data from ${onnxModelUrl}`);\n\n      // Get model data from cache (may download if not cached)\n      const modelData = await getCachedModelData(onnxModelUrl);\n\n      console.log(\n        `SemanticSimilarityEngine: Sending cached model data to worker (${modelData.byteLength} bytes)`,\n      );\n\n      // Send ArrayBuffer to worker with transferable objects for zero-copy\n      await this._sendMessageToWorker(\n        'init',\n        {\n          modelData: modelData,\n          numThreads: this.config.numThreads,\n          executionProviders: this.config.executionProviders,\n        },\n        [modelData],\n      );\n    }\n    console.log('SemanticSimilarityEngine: Worker reported model initialized.');\n\n    reportProgress('initializing', 90, 'Setting up SIMD acceleration...');\n    // 尝试初始化 SIMD 加速\n    try {\n      console.log('SemanticSimilarityEngine: Checking SIMD support...');\n      const simdSupported = await SIMDMathEngine.checkSIMDSupport();\n\n      if (simdSupported) {\n        console.log('SemanticSimilarityEngine: SIMD supported, initializing...');\n        await this.simdMath!.initialize();\n        this.useSIMD = true;\n        console.log('SemanticSimilarityEngine: ✅ SIMD acceleration enabled');\n      } else {\n        console.log('SemanticSimilarityEngine: ❌ SIMD not supported, using JavaScript fallback');\n        this.useSIMD = false;\n      }\n    } catch (simdError) {\n      console.warn(\n        'SemanticSimilarityEngine: SIMD initialization failed, using JavaScript fallback:',\n        simdError,\n      );\n      this.useSIMD = false;\n    }\n\n    reportProgress('ready', 100, 'Initialization complete');\n  }\n\n  public async warmupModel(): Promise<void> {\n    if (!this.isInitialized && !this.isInitializing) {\n      await this.initialize();\n    } else if (this.isInitializing && this.initPromise) {\n      await this.initPromise;\n    }\n    if (!this.isInitialized) throw new Error('Engine not initialized after warmup attempt.');\n    console.log('SemanticSimilarityEngine: Warming up model...');\n\n    // 更有代表性的预热文本，包含不同长度和语言\n    const warmupTexts = [\n      // 短文本\n      'Hello',\n      '你好',\n      'Test',\n      // 中等长度文本\n      'Hello world, this is a test.',\n      '你好世界，这是一个测试。',\n      'The quick brown fox jumps over the lazy dog.',\n      // 长文本\n      'This is a longer text that contains multiple sentences. It helps warm up the model for various text lengths.',\n      '这是一个包含多个句子的较长文本。它有助于为各种文本长度预热模型。',\n    ];\n\n    try {\n      // 渐进式预热：先单个，再批量\n      console.log('SemanticSimilarityEngine: Phase 1 - Individual warmup...');\n      for (const text of warmupTexts.slice(0, 4)) {\n        await this.getEmbedding(text);\n      }\n\n      console.log('SemanticSimilarityEngine: Phase 2 - Batch warmup...');\n      await this.getEmbeddingsBatch(warmupTexts.slice(4));\n\n      // 保留预热结果，不清空缓存\n      console.log('SemanticSimilarityEngine: Model warmup complete. Cache preserved.');\n      console.log(`Embedding cache: ${this.cacheStats.embedding.size} items`);\n      console.log(`Tokenization cache: ${this.cacheStats.tokenization.size} items`);\n    } catch (error) {\n      console.warn('SemanticSimilarityEngine: Warmup failed. This might not be critical.', error);\n    }\n  }\n\n  private async _tokenizeText(text: string | string[]): Promise<TokenizedOutput> {\n    if (!this.tokenizer) throw new Error('Tokenizer not initialized.');\n\n    // 对于单个文本，尝试使用缓存\n    if (typeof text === 'string') {\n      const cacheKey = `tokenize:${text}`;\n      const cached = this.tokenizationCache.get(cacheKey);\n      if (cached) {\n        this.cacheStats.tokenization.hits++;\n        this.cacheStats.tokenization.size = this.tokenizationCache.size;\n        return cached;\n      }\n      this.cacheStats.tokenization.misses++;\n\n      const startTime = performance.now();\n      const tokenizerOptions: any = {\n        padding: true,\n        truncation: true,\n        max_length: this.config.maxLength,\n        return_tensors: 'np',\n      };\n\n      // 对于不需要token_type_ids的模型，明确设置return_token_type_ids为false\n      if (!this.config.requiresTokenTypeIds) {\n        tokenizerOptions.return_token_type_ids = false;\n      }\n\n      const result = (await this.tokenizer(text, tokenizerOptions)) as TokenizedOutput;\n\n      // 更新性能统计\n      this.performanceStats.totalTokenizationTime += performance.now() - startTime;\n      this.performanceStats.averageTokenizationTime =\n        this.performanceStats.totalTokenizationTime /\n        (this.cacheStats.tokenization.hits + this.cacheStats.tokenization.misses);\n\n      // 缓存结果\n      this.tokenizationCache.set(cacheKey, result);\n      this.cacheStats.tokenization.size = this.tokenizationCache.size;\n\n      return result;\n    }\n\n    // 对于批量文本，直接处理（批量处理通常不重复）\n    const startTime = performance.now();\n    const tokenizerOptions: any = {\n      padding: true,\n      truncation: true,\n      max_length: this.config.maxLength,\n      return_tensors: 'np',\n    };\n\n    // 对于不需要token_type_ids的模型，明确设置return_token_type_ids为false\n    if (!this.config.requiresTokenTypeIds) {\n      tokenizerOptions.return_token_type_ids = false;\n    }\n\n    const result = (await this.tokenizer(text, tokenizerOptions)) as TokenizedOutput;\n\n    this.performanceStats.totalTokenizationTime += performance.now() - startTime;\n    return result;\n  }\n\n  private _extractEmbeddingFromWorkerOutput(\n    workerOutput: WorkerResponsePayload,\n    attentionMaskArray: number[],\n  ): Float32Array {\n    if (!workerOutput.data || !workerOutput.dims)\n      throw new Error('Invalid worker output for embedding extraction.');\n\n    // 优化：直接使用 Float32Array，避免不必要的转换\n    const lastHiddenStateData =\n      workerOutput.data instanceof Float32Array\n        ? workerOutput.data\n        : new Float32Array(workerOutput.data);\n\n    const dims = workerOutput.dims;\n    const seqLength = dims[1];\n    const hiddenSize = dims[2];\n\n    // 使用内存池获取 embedding 数组\n    const embedding = this.memoryPool.getEmbedding(hiddenSize);\n    let validTokens = 0;\n\n    for (let i = 0; i < seqLength; i++) {\n      if (attentionMaskArray[i] === 1) {\n        const offset = i * hiddenSize;\n        for (let j = 0; j < hiddenSize; j++) {\n          embedding[j] += lastHiddenStateData[offset + j];\n        }\n        validTokens++;\n      }\n    }\n    if (validTokens > 0) {\n      for (let i = 0; i < hiddenSize; i++) {\n        embedding[i] /= validTokens;\n      }\n    }\n    return this.normalizeVector(embedding);\n  }\n\n  private _extractBatchEmbeddingsFromWorkerOutput(\n    workerOutput: WorkerResponsePayload,\n    attentionMasksBatch: number[][],\n  ): Float32Array[] {\n    if (!workerOutput.data || !workerOutput.dims)\n      throw new Error('Invalid worker output for batch embedding extraction.');\n\n    // 优化：直接使用 Float32Array，避免不必要的转换\n    const lastHiddenStateData =\n      workerOutput.data instanceof Float32Array\n        ? workerOutput.data\n        : new Float32Array(workerOutput.data);\n\n    const dims = workerOutput.dims;\n    const batchSize = dims[0];\n    const seqLength = dims[1];\n    const hiddenSize = dims[2];\n    const embeddings: Float32Array[] = [];\n\n    for (let b = 0; b < batchSize; b++) {\n      // 使用内存池获取 embedding 数组\n      const embedding = this.memoryPool.getEmbedding(hiddenSize);\n      let validTokens = 0;\n      const currentAttentionMask = attentionMasksBatch[b];\n      for (let i = 0; i < seqLength; i++) {\n        if (currentAttentionMask[i] === 1) {\n          const offset = (b * seqLength + i) * hiddenSize;\n          for (let j = 0; j < hiddenSize; j++) {\n            embedding[j] += lastHiddenStateData[offset + j];\n          }\n          validTokens++;\n        }\n      }\n      if (validTokens > 0) {\n        for (let i = 0; i < hiddenSize; i++) {\n          embedding[i] /= validTokens;\n        }\n      }\n      embeddings.push(this.normalizeVector(embedding));\n    }\n    return embeddings;\n  }\n\n  public async getEmbedding(\n    text: string,\n    options: Record<string, any> = {},\n  ): Promise<Float32Array> {\n    if (!this.isInitialized) await this.initialize();\n\n    const cacheKey = this.getCacheKey(text, options);\n    const cached = this.embeddingCache.get(cacheKey);\n    if (cached) {\n      this.cacheStats.embedding.hits++;\n      this.cacheStats.embedding.size = this.embeddingCache.size;\n      return cached;\n    }\n    this.cacheStats.embedding.misses++;\n\n    // 如果使用offscreen模式，委托给offscreen document\n    if (this.useOffscreen) {\n      const response = await chrome.runtime.sendMessage({\n        target: 'offscreen',\n        type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_COMPUTE,\n        text: text,\n        options: options,\n      });\n\n      if (!response || !response.success) {\n        throw new Error(response?.error || 'Failed to get embedding from offscreen document');\n      }\n\n      // 验证响应数据\n      if (!response.embedding || !Array.isArray(response.embedding)) {\n        throw new Error('Invalid embedding data received from offscreen document');\n      }\n\n      console.log('SemanticSimilarityEngine: Received embedding from offscreen:', {\n        length: response.embedding.length,\n        type: typeof response.embedding,\n        isArray: Array.isArray(response.embedding),\n        firstFewValues: response.embedding.slice(0, 5),\n      });\n\n      const embedding = new Float32Array(response.embedding);\n\n      // 验证转换后的数据\n      console.log('SemanticSimilarityEngine: Converted embedding:', {\n        length: embedding.length,\n        type: typeof embedding,\n        constructor: embedding.constructor.name,\n        isFloat32Array: embedding instanceof Float32Array,\n        firstFewValues: Array.from(embedding.slice(0, 5)),\n      });\n\n      this.embeddingCache.set(cacheKey, embedding);\n      this.cacheStats.embedding.size = this.embeddingCache.size;\n\n      // 更新性能统计\n      this.performanceStats.totalEmbeddingComputations++;\n\n      return embedding;\n    }\n\n    if (this.runningWorkerTasks >= this.config.concurrentLimit) {\n      await this.waitForWorkerSlot();\n    }\n    this.runningWorkerTasks++;\n\n    const startTime = performance.now();\n    try {\n      const tokenized = await this._tokenizeText(text);\n\n      const inputIdsData = this.convertTensorDataToNumbers(tokenized.input_ids.data);\n      const attentionMaskData = this.convertTensorDataToNumbers(tokenized.attention_mask.data);\n      const tokenTypeIdsData = tokenized.token_type_ids\n        ? this.convertTensorDataToNumbers(tokenized.token_type_ids.data)\n        : undefined;\n\n      const workerPayload: WorkerMessagePayload = {\n        input_ids: inputIdsData,\n        attention_mask: attentionMaskData,\n        token_type_ids: tokenTypeIdsData,\n        dims: {\n          input_ids: tokenized.input_ids.dims,\n          attention_mask: tokenized.attention_mask.dims,\n          token_type_ids: tokenized.token_type_ids?.dims,\n        },\n      };\n\n      const workerOutput = await this._sendMessageToWorker('infer', workerPayload);\n      const embedding = this._extractEmbeddingFromWorkerOutput(workerOutput, attentionMaskData);\n      this.embeddingCache.set(cacheKey, embedding);\n      this.cacheStats.embedding.size = this.embeddingCache.size;\n\n      this.performanceStats.totalEmbeddingComputations++;\n      this.performanceStats.totalEmbeddingTime += performance.now() - startTime;\n      this.performanceStats.averageEmbeddingTime =\n        this.performanceStats.totalEmbeddingTime / this.performanceStats.totalEmbeddingComputations;\n      return embedding;\n    } finally {\n      this.runningWorkerTasks--;\n      this.processWorkerQueue();\n    }\n  }\n\n  public async getEmbeddingsBatch(\n    texts: string[],\n    options: Record<string, any> = {},\n  ): Promise<Float32Array[]> {\n    if (!this.isInitialized) await this.initialize();\n    if (!texts || texts.length === 0) return [];\n\n    // 如果使用offscreen模式，委托给offscreen document\n    if (this.useOffscreen) {\n      // 先检查缓存\n      const results: (Float32Array | undefined)[] = new Array(texts.length).fill(undefined);\n      const uncachedTexts: string[] = [];\n      const uncachedIndices: number[] = [];\n\n      texts.forEach((text, index) => {\n        const cacheKey = this.getCacheKey(text, options);\n        const cached = this.embeddingCache.get(cacheKey);\n        if (cached) {\n          results[index] = cached;\n          this.cacheStats.embedding.hits++;\n        } else {\n          uncachedTexts.push(text);\n          uncachedIndices.push(index);\n          this.cacheStats.embedding.misses++;\n        }\n      });\n\n      // 如果所有都在缓存中，直接返回\n      if (uncachedTexts.length === 0) {\n        return results as Float32Array[];\n      }\n\n      // 只请求未缓存的文本\n      const response = await chrome.runtime.sendMessage({\n        target: 'offscreen',\n        type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE,\n        texts: uncachedTexts,\n        options: options,\n      });\n\n      if (!response || !response.success) {\n        throw new Error(\n          response?.error || 'Failed to get embeddings batch from offscreen document',\n        );\n      }\n\n      // 将结果放回对应位置并缓存\n      response.embeddings.forEach((embeddingArray: number[], batchIndex: number) => {\n        const embedding = new Float32Array(embeddingArray);\n        const originalIndex = uncachedIndices[batchIndex];\n        const originalText = uncachedTexts[batchIndex];\n\n        results[originalIndex] = embedding;\n\n        // 缓存结果\n        const cacheKey = this.getCacheKey(originalText, options);\n        this.embeddingCache.set(cacheKey, embedding);\n      });\n\n      this.cacheStats.embedding.size = this.embeddingCache.size;\n      this.performanceStats.totalEmbeddingComputations += uncachedTexts.length;\n\n      return results as Float32Array[];\n    }\n\n    const results: (Float32Array | undefined)[] = new Array(texts.length).fill(undefined);\n    const uncachedTextsMap = new Map<string, number[]>();\n    const textsToTokenize: string[] = [];\n\n    texts.forEach((text, index) => {\n      const cacheKey = this.getCacheKey(text, options);\n      const cached = this.embeddingCache.get(cacheKey);\n      if (cached) {\n        results[index] = cached;\n        this.cacheStats.embedding.hits++;\n      } else {\n        if (!uncachedTextsMap.has(text)) {\n          uncachedTextsMap.set(text, []);\n          textsToTokenize.push(text);\n        }\n        uncachedTextsMap.get(text)!.push(index);\n        this.cacheStats.embedding.misses++;\n      }\n    });\n    this.cacheStats.embedding.size = this.embeddingCache.size;\n\n    if (textsToTokenize.length === 0) return results as Float32Array[];\n\n    if (this.runningWorkerTasks >= this.config.concurrentLimit) {\n      await this.waitForWorkerSlot();\n    }\n    this.runningWorkerTasks++;\n\n    const startTime = performance.now();\n    try {\n      const tokenizedBatch = await this._tokenizeText(textsToTokenize);\n      const workerPayload: WorkerMessagePayload = {\n        input_ids: this.convertTensorDataToNumbers(tokenizedBatch.input_ids.data),\n        attention_mask: this.convertTensorDataToNumbers(tokenizedBatch.attention_mask.data),\n        token_type_ids: tokenizedBatch.token_type_ids\n          ? this.convertTensorDataToNumbers(tokenizedBatch.token_type_ids.data)\n          : undefined,\n        dims: {\n          input_ids: tokenizedBatch.input_ids.dims,\n          attention_mask: tokenizedBatch.attention_mask.dims,\n          token_type_ids: tokenizedBatch.token_type_ids?.dims,\n        },\n      };\n\n      // 使用真正的批处理推理\n      const workerOutput = await this._sendMessageToWorker('batchInfer', workerPayload);\n      const attentionMasksForBatch: number[][] = [];\n      const batchSize = tokenizedBatch.input_ids.dims[0];\n      const seqLength = tokenizedBatch.input_ids.dims[1];\n      const rawAttentionMaskData = this.convertTensorDataToNumbers(\n        tokenizedBatch.attention_mask.data,\n      );\n\n      for (let i = 0; i < batchSize; ++i) {\n        attentionMasksForBatch.push(rawAttentionMaskData.slice(i * seqLength, (i + 1) * seqLength));\n      }\n\n      const batchEmbeddings = this._extractBatchEmbeddingsFromWorkerOutput(\n        workerOutput,\n        attentionMasksForBatch,\n      );\n      batchEmbeddings.forEach((embedding, batchIdx) => {\n        const originalText = textsToTokenize[batchIdx];\n        const cacheKey = this.getCacheKey(originalText, options);\n        this.embeddingCache.set(cacheKey, embedding);\n        const originalResultIndices = uncachedTextsMap.get(originalText)!;\n        originalResultIndices.forEach((idx) => {\n          results[idx] = embedding;\n        });\n      });\n      this.cacheStats.embedding.size = this.embeddingCache.size;\n\n      this.performanceStats.totalEmbeddingComputations += textsToTokenize.length;\n      this.performanceStats.totalEmbeddingTime += performance.now() - startTime;\n      this.performanceStats.averageEmbeddingTime =\n        this.performanceStats.totalEmbeddingTime / this.performanceStats.totalEmbeddingComputations;\n      return results as Float32Array[];\n    } finally {\n      this.runningWorkerTasks--;\n      this.processWorkerQueue();\n    }\n  }\n\n  public async computeSimilarity(\n    text1: string,\n    text2: string,\n    options: Record<string, any> = {},\n  ): Promise<number> {\n    if (!this.isInitialized) await this.initialize();\n    this.validateInput(text1, text2);\n\n    const simStartTime = performance.now();\n    const [embedding1, embedding2] = await Promise.all([\n      this.getEmbedding(text1, options),\n      this.getEmbedding(text2, options),\n    ]);\n    const similarity = this.cosineSimilarity(embedding1, embedding2);\n    console.log('computeSimilarity:', similarity);\n    this.performanceStats.totalSimilarityComputations++;\n    this.performanceStats.totalSimilarityTime += performance.now() - simStartTime;\n    this.performanceStats.averageSimilarityTime =\n      this.performanceStats.totalSimilarityTime / this.performanceStats.totalSimilarityComputations;\n    return similarity;\n  }\n\n  public async computeSimilarityBatch(\n    pairs: { text1: string; text2: string }[],\n    options: Record<string, any> = {},\n  ): Promise<number[]> {\n    if (!this.isInitialized) await this.initialize();\n    if (!pairs || pairs.length === 0) return [];\n\n    // 如果使用offscreen模式，委托给offscreen document\n    if (this.useOffscreen) {\n      const response = await chrome.runtime.sendMessage({\n        target: 'offscreen',\n        type: OFFSCREEN_MESSAGE_TYPES.SIMILARITY_ENGINE_BATCH_COMPUTE,\n        pairs: pairs,\n        options: options,\n      });\n\n      if (!response || !response.success) {\n        throw new Error(response?.error || 'Failed to compute similarities in offscreen document');\n      }\n\n      return response.similarities;\n    }\n\n    // 直接模式的原有逻辑\n    const simStartTime = performance.now();\n    const uniqueTextsSet = new Set<string>();\n    pairs.forEach((pair) => {\n      this.validateInput(pair.text1, pair.text2);\n      uniqueTextsSet.add(pair.text1);\n      uniqueTextsSet.add(pair.text2);\n    });\n\n    const uniqueTextsArray = Array.from(uniqueTextsSet);\n    const embeddingsArray = await this.getEmbeddingsBatch(uniqueTextsArray, options);\n    const embeddingMap = new Map<string, Float32Array>();\n    uniqueTextsArray.forEach((text, index) => {\n      embeddingMap.set(text, embeddingsArray[index]);\n    });\n\n    const similarities = pairs.map((pair) => {\n      const emb1 = embeddingMap.get(pair.text1);\n      const emb2 = embeddingMap.get(pair.text2);\n      if (!emb1 || !emb2) {\n        console.warn('Embeddings not found for pair:', pair);\n        return 0;\n      }\n      return this.cosineSimilarity(emb1, emb2);\n    });\n    this.performanceStats.totalSimilarityComputations += pairs.length;\n    this.performanceStats.totalSimilarityTime += performance.now() - simStartTime;\n    this.performanceStats.averageSimilarityTime =\n      this.performanceStats.totalSimilarityTime / this.performanceStats.totalSimilarityComputations;\n    return similarities;\n  }\n\n  public async computeSimilarityMatrix(\n    texts1: string[],\n    texts2: string[],\n    options: Record<string, any> = {},\n  ): Promise<number[][]> {\n    if (!this.isInitialized) await this.initialize();\n    if (!texts1 || !texts2 || texts1.length === 0 || texts2.length === 0) return [];\n\n    const simStartTime = performance.now();\n    const allTextsSet = new Set<string>([...texts1, ...texts2]);\n    texts1.forEach((t) => this.validateInput(t, 'valid_dummy'));\n    texts2.forEach((t) => this.validateInput(t, 'valid_dummy'));\n\n    const allTextsArray = Array.from(allTextsSet);\n    const embeddingsArray = await this.getEmbeddingsBatch(allTextsArray, options);\n    const embeddingMap = new Map<string, Float32Array>();\n    allTextsArray.forEach((text, index) => {\n      embeddingMap.set(text, embeddingsArray[index]);\n    });\n\n    // 使用 SIMD 优化的矩阵计算（如果可用）\n    if (this.useSIMD && this.simdMath) {\n      try {\n        const embeddings1 = texts1.map((text) => embeddingMap.get(text)!).filter(Boolean);\n        const embeddings2 = texts2.map((text) => embeddingMap.get(text)!).filter(Boolean);\n\n        if (embeddings1.length === texts1.length && embeddings2.length === texts2.length) {\n          const matrix = await this.simdMath.similarityMatrix(embeddings1, embeddings2);\n\n          this.performanceStats.totalSimilarityComputations += texts1.length * texts2.length;\n          this.performanceStats.totalSimilarityTime += performance.now() - simStartTime;\n          this.performanceStats.averageSimilarityTime =\n            this.performanceStats.totalSimilarityTime /\n            this.performanceStats.totalSimilarityComputations;\n\n          return matrix;\n        }\n      } catch (error) {\n        console.warn('SIMD matrix computation failed, falling back to JavaScript:', error);\n      }\n    }\n\n    // JavaScript 回退版本\n    const matrix: number[][] = [];\n    for (const textA of texts1) {\n      const row: number[] = [];\n      const embA = embeddingMap.get(textA);\n      if (!embA) {\n        console.warn(`Embedding not found for text1: \"${textA}\"`);\n        texts2.forEach(() => row.push(0));\n        matrix.push(row);\n        continue;\n      }\n      for (const textB of texts2) {\n        const embB = embeddingMap.get(textB);\n        if (!embB) {\n          console.warn(`Embedding not found for text2: \"${textB}\"`);\n          row.push(0);\n          continue;\n        }\n        row.push(this.cosineSimilarity(embA, embB));\n      }\n      matrix.push(row);\n    }\n    this.performanceStats.totalSimilarityComputations += texts1.length * texts2.length;\n    this.performanceStats.totalSimilarityTime += performance.now() - simStartTime;\n    this.performanceStats.averageSimilarityTime =\n      this.performanceStats.totalSimilarityTime / this.performanceStats.totalSimilarityComputations;\n    return matrix;\n  }\n\n  public cosineSimilarity(vecA: Float32Array, vecB: Float32Array): number {\n    if (!vecA || !vecB || vecA.length !== vecB.length) {\n      console.warn('Cosine similarity: Invalid vectors provided.', vecA, vecB);\n      return 0;\n    }\n\n    // 使用 SIMD 优化版本（如果可用）\n    if (this.useSIMD && this.simdMath) {\n      try {\n        // SIMD 版本是异步的，但为了保持接口兼容性，我们需要同步版本\n        // 这里我们回退到 JavaScript 版本，或者可以考虑重构为异步\n        return this.cosineSimilarityJS(vecA, vecB);\n      } catch (error) {\n        console.warn('SIMD cosine similarity failed, falling back to JavaScript:', error);\n        return this.cosineSimilarityJS(vecA, vecB);\n      }\n    }\n\n    return this.cosineSimilarityJS(vecA, vecB);\n  }\n\n  private cosineSimilarityJS(vecA: Float32Array, vecB: Float32Array): number {\n    let dotProduct = 0;\n    let normA = 0;\n    let normB = 0;\n    for (let i = 0; i < vecA.length; i++) {\n      dotProduct += vecA[i] * vecB[i];\n      normA += vecA[i] * vecA[i];\n      normB += vecB[i] * vecB[i];\n    }\n    const magnitude = Math.sqrt(normA) * Math.sqrt(normB);\n    return magnitude === 0 ? 0 : dotProduct / magnitude;\n  }\n\n  // 新增：异步 SIMD 优化的余弦相似度\n  public async cosineSimilaritySIMD(vecA: Float32Array, vecB: Float32Array): Promise<number> {\n    if (!vecA || !vecB || vecA.length !== vecB.length) {\n      console.warn('Cosine similarity: Invalid vectors provided.', vecA, vecB);\n      return 0;\n    }\n\n    if (this.useSIMD && this.simdMath) {\n      try {\n        return await this.simdMath.cosineSimilarity(vecA, vecB);\n      } catch (error) {\n        console.warn('SIMD cosine similarity failed, falling back to JavaScript:', error);\n      }\n    }\n\n    return this.cosineSimilarityJS(vecA, vecB);\n  }\n\n  public normalizeVector(vector: Float32Array): Float32Array {\n    let norm = 0;\n    for (let i = 0; i < vector.length; i++) norm += vector[i] * vector[i];\n    norm = Math.sqrt(norm);\n    if (norm === 0) return vector;\n    const normalized = new Float32Array(vector.length);\n    for (let i = 0; i < vector.length; i++) normalized[i] = vector[i] / norm;\n    return normalized;\n  }\n\n  public validateInput(text1: string, text2: string | 'valid_dummy'): void {\n    if (typeof text1 !== 'string' || (text2 !== 'valid_dummy' && typeof text2 !== 'string')) {\n      throw new Error('输入必须是字符串');\n    }\n    if (text1.trim().length === 0 || (text2 !== 'valid_dummy' && text2.trim().length === 0)) {\n      throw new Error('输入文本不能为空');\n    }\n    const roughCharLimit = this.config.maxLength * 5;\n    if (\n      text1.length > roughCharLimit ||\n      (text2 !== 'valid_dummy' && text2.length > roughCharLimit)\n    ) {\n      console.warn('输入文本可能过长，将由分词器截断。');\n    }\n  }\n\n  private getCacheKey(text: string, _options: Record<string, any> = {}): string {\n    return text; // Options currently not used to vary embedding, simplify key\n  }\n\n  public getPerformanceStats(): Record<string, any> {\n    return {\n      ...this.performanceStats,\n      cacheStats: {\n        ...this.cacheStats,\n        embedding: {\n          ...this.cacheStats.embedding,\n          hitRate:\n            this.cacheStats.embedding.hits + this.cacheStats.embedding.misses > 0\n              ? this.cacheStats.embedding.hits /\n                (this.cacheStats.embedding.hits + this.cacheStats.embedding.misses)\n              : 0,\n        },\n        tokenization: {\n          ...this.cacheStats.tokenization,\n          hitRate:\n            this.cacheStats.tokenization.hits + this.cacheStats.tokenization.misses > 0\n              ? this.cacheStats.tokenization.hits /\n                (this.cacheStats.tokenization.hits + this.cacheStats.tokenization.misses)\n              : 0,\n        },\n      },\n      memoryPool: this.memoryPool.getStats(),\n      memoryUsage: this.getMemoryUsage(),\n      isInitialized: this.isInitialized,\n      isInitializing: this.isInitializing,\n      config: this.config,\n      pendingWorkerTasks: this.workerTaskQueue.length,\n      runningWorkerTasks: this.runningWorkerTasks,\n    };\n  }\n\n  private async waitForWorkerSlot(): Promise<void> {\n    return new Promise((resolve) => {\n      this.workerTaskQueue.push(resolve);\n    });\n  }\n\n  private processWorkerQueue(): void {\n    if (this.workerTaskQueue.length > 0 && this.runningWorkerTasks < this.config.concurrentLimit) {\n      const resolve = this.workerTaskQueue.shift();\n      if (resolve) resolve();\n    }\n  }\n\n  // 新增：获取 Worker 统计信息\n  public async getWorkerStats(): Promise<WorkerStats | null> {\n    if (!this.worker || !this.isInitialized) return null;\n\n    try {\n      const response = await this._sendMessageToWorker('getStats');\n      return response as WorkerStats;\n    } catch (error) {\n      console.warn('Failed to get worker stats:', error);\n      return null;\n    }\n  }\n\n  // 新增：清理 Worker 缓冲区\n  public async clearWorkerBuffers(): Promise<void> {\n    if (!this.worker || !this.isInitialized) return;\n\n    try {\n      await this._sendMessageToWorker('clearBuffers');\n      console.log('SemanticSimilarityEngine: Worker buffers cleared.');\n    } catch (error) {\n      console.warn('Failed to clear worker buffers:', error);\n    }\n  }\n\n  // 新增：清理所有缓存\n  public clearAllCaches(): void {\n    this.embeddingCache.clear();\n    this.tokenizationCache.clear();\n    this.cacheStats = {\n      embedding: { hits: 0, misses: 0, size: 0 },\n      tokenization: { hits: 0, misses: 0, size: 0 },\n    };\n    console.log('SemanticSimilarityEngine: All caches cleared.');\n  }\n\n  // 新增：获取内存使用情况\n  public getMemoryUsage(): {\n    embeddingCacheUsage: number;\n    tokenizationCacheUsage: number;\n    totalCacheUsage: number;\n  } {\n    const embeddingStats = this.embeddingCache.getStats();\n    const tokenizationStats = this.tokenizationCache.getStats();\n\n    return {\n      embeddingCacheUsage: embeddingStats.usage,\n      tokenizationCacheUsage: tokenizationStats.usage,\n      totalCacheUsage: (embeddingStats.usage + tokenizationStats.usage) / 2,\n    };\n  }\n\n  public async dispose(): Promise<void> {\n    console.log('SemanticSimilarityEngine: Disposing...');\n\n    // 清理 Worker 缓冲区\n    await this.clearWorkerBuffers();\n\n    if (this.worker) {\n      this.worker.terminate();\n      this.worker = null;\n    }\n\n    // 清理 SIMD 引擎\n    if (this.simdMath) {\n      this.simdMath.dispose();\n      this.simdMath = null;\n    }\n\n    this.tokenizer = null;\n    this.embeddingCache.clear();\n    this.tokenizationCache.clear();\n    this.memoryPool.clear();\n    this.pendingMessages.clear();\n    this.workerTaskQueue = [];\n    this.isInitialized = false;\n    this.isInitializing = false;\n    this.initPromise = null;\n    this.useSIMD = false;\n    console.log('SemanticSimilarityEngine: Disposed.');\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/simd-math-engine.ts",
    "content": "/**\n * SIMD-optimized mathematical computation engine\n * Uses WebAssembly + SIMD instructions to accelerate vector calculations\n */\n\ninterface SIMDMathWasm {\n  free(): void;\n  cosine_similarity(vec_a: Float32Array, vec_b: Float32Array): number;\n  batch_similarity(vectors: Float32Array, query: Float32Array, vector_dim: number): Float32Array;\n  similarity_matrix(\n    vectors_a: Float32Array,\n    vectors_b: Float32Array,\n    vector_dim: number,\n  ): Float32Array;\n}\n\ninterface WasmModule {\n  SIMDMath: new () => SIMDMathWasm;\n  memory: WebAssembly.Memory;\n  default: (module_or_path?: any) => Promise<any>;\n}\n\nexport class SIMDMathEngine {\n  private wasmModule: WasmModule | null = null;\n  private simdMath: SIMDMathWasm | null = null;\n  private isInitialized = false;\n  private isInitializing = false;\n  private initPromise: Promise<void> | null = null;\n\n  private alignedBufferPool: Map<number, Float32Array[]> = new Map();\n  private maxPoolSize = 5;\n\n  async initialize(): Promise<void> {\n    if (this.isInitialized) return;\n    if (this.isInitializing && this.initPromise) return this.initPromise;\n\n    this.isInitializing = true;\n    this.initPromise = this._doInitialize().finally(() => {\n      this.isInitializing = false;\n    });\n\n    return this.initPromise;\n  }\n\n  private async _doInitialize(): Promise<void> {\n    try {\n      console.log('SIMDMathEngine: Initializing WebAssembly module...');\n\n      const wasmUrl = chrome.runtime.getURL('workers/simd_math.js');\n      const wasmModule = await import(wasmUrl);\n\n      const wasmInstance = await wasmModule.default();\n\n      this.wasmModule = {\n        SIMDMath: wasmModule.SIMDMath,\n        memory: wasmInstance.memory,\n        default: wasmModule.default,\n      };\n\n      this.simdMath = new this.wasmModule.SIMDMath();\n\n      this.isInitialized = true;\n      console.log('SIMDMathEngine: WebAssembly module initialized successfully');\n    } catch (error) {\n      console.error('SIMDMathEngine: Failed to initialize WebAssembly module:', error);\n      this.isInitialized = false;\n      throw error;\n    }\n  }\n\n  /**\n   * Get aligned buffer (16-byte aligned, suitable for SIMD)\n   */\n  private getAlignedBuffer(size: number): Float32Array {\n    if (!this.alignedBufferPool.has(size)) {\n      this.alignedBufferPool.set(size, []);\n    }\n\n    const pool = this.alignedBufferPool.get(size)!;\n    if (pool.length > 0) {\n      return pool.pop()!;\n    }\n\n    // Create 16-byte aligned buffer\n    const buffer = new ArrayBuffer(size * 4 + 15);\n    const alignedOffset = (16 - (buffer.byteLength % 16)) % 16;\n    return new Float32Array(buffer, alignedOffset, size);\n  }\n\n  /**\n   * Release aligned buffer back to pool\n   */\n  private releaseAlignedBuffer(buffer: Float32Array): void {\n    const size = buffer.length;\n    const pool = this.alignedBufferPool.get(size);\n    if (pool && pool.length < this.maxPoolSize) {\n      buffer.fill(0); // Clear to zero\n      pool.push(buffer);\n    }\n  }\n\n  /**\n   * Check if vector is already aligned\n   */\n  private isAligned(array: Float32Array): boolean {\n    return array.byteOffset % 16 === 0;\n  }\n\n  /**\n   * Ensure vector alignment, create aligned copy if not aligned\n   */\n  private ensureAligned(array: Float32Array): { aligned: Float32Array; needsRelease: boolean } {\n    if (this.isAligned(array)) {\n      return { aligned: array, needsRelease: false };\n    }\n\n    const aligned = this.getAlignedBuffer(array.length);\n    aligned.set(array);\n    return { aligned, needsRelease: true };\n  }\n\n  /**\n   * SIMD-optimized cosine similarity calculation\n   */\n  async cosineSimilarity(vecA: Float32Array, vecB: Float32Array): Promise<number> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    if (!this.simdMath) {\n      throw new Error('SIMD math engine not initialized');\n    }\n\n    // Ensure vector alignment\n    const { aligned: alignedA, needsRelease: releaseA } = this.ensureAligned(vecA);\n    const { aligned: alignedB, needsRelease: releaseB } = this.ensureAligned(vecB);\n\n    try {\n      const result = this.simdMath.cosine_similarity(alignedA, alignedB);\n      return result;\n    } finally {\n      // Release temporary buffers\n      if (releaseA) this.releaseAlignedBuffer(alignedA);\n      if (releaseB) this.releaseAlignedBuffer(alignedB);\n    }\n  }\n\n  /**\n   * Batch similarity calculation\n   */\n  async batchSimilarity(vectors: Float32Array[], query: Float32Array): Promise<number[]> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    if (!this.simdMath) {\n      throw new Error('SIMD math engine not initialized');\n    }\n\n    const vectorDim = query.length;\n    const numVectors = vectors.length;\n\n    // Pack all vectors into contiguous memory layout\n    const packedVectors = this.getAlignedBuffer(numVectors * vectorDim);\n    const { aligned: alignedQuery, needsRelease: releaseQuery } = this.ensureAligned(query);\n\n    try {\n      // Copy vector data\n      let offset = 0;\n      for (const vector of vectors) {\n        packedVectors.set(vector, offset);\n        offset += vectorDim;\n      }\n\n      // Batch calculation\n      const results = this.simdMath.batch_similarity(packedVectors, alignedQuery, vectorDim);\n      return Array.from(results);\n    } finally {\n      this.releaseAlignedBuffer(packedVectors);\n      if (releaseQuery) this.releaseAlignedBuffer(alignedQuery);\n    }\n  }\n\n  /**\n   * Similarity matrix calculation\n   */\n  async similarityMatrix(vectorsA: Float32Array[], vectorsB: Float32Array[]): Promise<number[][]> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    if (!this.simdMath || vectorsA.length === 0 || vectorsB.length === 0) {\n      return [];\n    }\n\n    const vectorDim = vectorsA[0].length;\n    const numA = vectorsA.length;\n    const numB = vectorsB.length;\n\n    // Pack vectors\n    const packedA = this.getAlignedBuffer(numA * vectorDim);\n    const packedB = this.getAlignedBuffer(numB * vectorDim);\n\n    try {\n      // Copy data\n      let offsetA = 0;\n      for (const vector of vectorsA) {\n        packedA.set(vector, offsetA);\n        offsetA += vectorDim;\n      }\n\n      let offsetB = 0;\n      for (const vector of vectorsB) {\n        packedB.set(vector, offsetB);\n        offsetB += vectorDim;\n      }\n\n      // Calculate matrix\n      const flatResults = this.simdMath.similarity_matrix(packedA, packedB, vectorDim);\n\n      // Convert to 2D array\n      const matrix: number[][] = [];\n      for (let i = 0; i < numA; i++) {\n        const row: number[] = [];\n        for (let j = 0; j < numB; j++) {\n          row.push(flatResults[i * numB + j]);\n        }\n        matrix.push(row);\n      }\n\n      return matrix;\n    } finally {\n      this.releaseAlignedBuffer(packedA);\n      this.releaseAlignedBuffer(packedB);\n    }\n  }\n\n  /**\n   * Check SIMD support\n   */\n  static async checkSIMDSupport(): Promise<boolean> {\n    try {\n      console.log('SIMDMathEngine: Checking SIMD support...');\n\n      // Get browser information\n      const userAgent = navigator.userAgent;\n      const browserInfo = SIMDMathEngine.getBrowserInfo();\n      console.log('Browser info:', browserInfo);\n      console.log('User Agent:', userAgent);\n\n      // Check WebAssembly basic support\n      if (typeof WebAssembly !== 'object') {\n        console.log('WebAssembly not supported');\n        return false;\n      }\n      console.log('✅ WebAssembly basic support: OK');\n\n      // Check WebAssembly.validate method\n      if (typeof WebAssembly.validate !== 'function') {\n        console.log('❌ WebAssembly.validate not available');\n        return false;\n      }\n      console.log('✅ WebAssembly.validate: OK');\n\n      // Test basic WebAssembly module validation\n      const basicWasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);\n      const basicValid = WebAssembly.validate(basicWasm);\n      console.log('✅ Basic WASM validation:', basicValid);\n\n      // Check WebAssembly SIMD support - using correct SIMD test module\n      console.log('Testing SIMD WASM module...');\n\n      // Method 1: Use standard SIMD detection bytecode\n      let wasmSIMDSupported = false;\n      try {\n        // This is a minimal SIMD module containing v128.const instruction\n        const simdWasm = new Uint8Array([\n          0x00,\n          0x61,\n          0x73,\n          0x6d, // WASM magic\n          0x01,\n          0x00,\n          0x00,\n          0x00, // version\n          0x01,\n          0x05,\n          0x01, // type section\n          0x60,\n          0x00,\n          0x01,\n          0x7b, // function type: () -> v128\n          0x03,\n          0x02,\n          0x01,\n          0x00, // function section\n          0x0a,\n          0x0a,\n          0x01, // code section\n          0x08,\n          0x00, // function body\n          0xfd,\n          0x0c, // v128.const\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x00,\n          0x0b, // end\n        ]);\n        wasmSIMDSupported = WebAssembly.validate(simdWasm);\n        console.log('Method 1 - Standard SIMD test result:', wasmSIMDSupported);\n      } catch (error) {\n        console.log('Method 1 failed:', error);\n      }\n\n      // Method 2: If method 1 fails, try simpler SIMD instruction\n      if (!wasmSIMDSupported) {\n        try {\n          // Test using i32x4.splat instruction\n          const simpleSimdWasm = new Uint8Array([\n            0x00,\n            0x61,\n            0x73,\n            0x6d, // WASM magic\n            0x01,\n            0x00,\n            0x00,\n            0x00, // version\n            0x01,\n            0x06,\n            0x01, // type section\n            0x60,\n            0x01,\n            0x7f,\n            0x01,\n            0x7b, // function type: (i32) -> v128\n            0x03,\n            0x02,\n            0x01,\n            0x00, // function section\n            0x0a,\n            0x07,\n            0x01, // code section\n            0x05,\n            0x00, // function body\n            0x20,\n            0x00, // local.get 0\n            0xfd,\n            0x0d, // i32x4.splat\n            0x0b, // end\n          ]);\n          wasmSIMDSupported = WebAssembly.validate(simpleSimdWasm);\n          console.log('Method 2 - Simple SIMD test result:', wasmSIMDSupported);\n        } catch (error) {\n          console.log('Method 2 failed:', error);\n        }\n      }\n\n      // Method 3: If previous methods fail, try detecting specific SIMD features\n      if (!wasmSIMDSupported) {\n        try {\n          // Check if SIMD feature flags are supported\n          const featureTest = WebAssembly.validate(\n            new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]),\n          );\n\n          if (featureTest) {\n            // In Chrome, if basic WebAssembly works and version >= 91, SIMD is usually available\n            const chromeMatch = userAgent.match(/Chrome\\/(\\d+)/);\n            if (chromeMatch && parseInt(chromeMatch[1]) >= 91) {\n              console.log('Method 3 - Chrome version check: SIMD should be available');\n              wasmSIMDSupported = true;\n            }\n          }\n        } catch (error) {\n          console.log('Method 3 failed:', error);\n        }\n      }\n\n      // Output final result\n      if (!wasmSIMDSupported) {\n        console.log('❌ SIMD not supported. Browser requirements:');\n        console.log('- Chrome 91+, Firefox 89+, Safari 16.4+, Edge 91+');\n        console.log('Your browser should support SIMD. Possible issues:');\n        console.log('1. Extension context limitations');\n        console.log('2. Security policies');\n        console.log('3. Feature flags disabled');\n      } else {\n        console.log('✅ SIMD supported!');\n      }\n\n      return wasmSIMDSupported;\n    } catch (error: any) {\n      console.error('SIMD support check failed:', error);\n      if (error instanceof Error) {\n        console.error('Error details:', {\n          name: error.name,\n          message: error.message,\n          stack: error.stack,\n        });\n      }\n      return false;\n    }\n  }\n\n  /**\n   * Get browser information\n   */\n  static getBrowserInfo(): { name: string; version: string; supported: boolean } {\n    const userAgent = navigator.userAgent;\n    let browserName = 'Unknown';\n    let version = 'Unknown';\n    let supported = false;\n\n    // Chrome\n    if (userAgent.includes('Chrome/')) {\n      browserName = 'Chrome';\n      const match = userAgent.match(/Chrome\\/(\\d+)/);\n      if (match) {\n        version = match[1];\n        supported = parseInt(version) >= 91;\n      }\n    }\n    // Firefox\n    else if (userAgent.includes('Firefox/')) {\n      browserName = 'Firefox';\n      const match = userAgent.match(/Firefox\\/(\\d+)/);\n      if (match) {\n        version = match[1];\n        supported = parseInt(version) >= 89;\n      }\n    }\n    // Safari\n    else if (userAgent.includes('Safari/') && !userAgent.includes('Chrome/')) {\n      browserName = 'Safari';\n      const match = userAgent.match(/Version\\/(\\d+\\.\\d+)/);\n      if (match) {\n        version = match[1];\n        const versionNum = parseFloat(version);\n        supported = versionNum >= 16.4;\n      }\n    }\n    // Edge\n    else if (userAgent.includes('Edg/')) {\n      browserName = 'Edge';\n      const match = userAgent.match(/Edg\\/(\\d+)/);\n      if (match) {\n        version = match[1];\n        supported = parseInt(version) >= 91;\n      }\n    }\n\n    return { name: browserName, version, supported };\n  }\n\n  getStats() {\n    return {\n      isInitialized: this.isInitialized,\n      isInitializing: this.isInitializing,\n      bufferPoolStats: Array.from(this.alignedBufferPool.entries()).map(([size, buffers]) => ({\n        size,\n        pooled: buffers.length,\n        maxPoolSize: this.maxPoolSize,\n      })),\n    };\n  }\n\n  dispose(): void {\n    if (this.simdMath) {\n      try {\n        this.simdMath.free();\n      } catch (error) {\n        console.warn('Failed to free SIMD math instance:', error);\n      }\n      this.simdMath = null;\n    }\n\n    this.alignedBufferPool.clear();\n    this.wasmModule = null;\n    this.isInitialized = false;\n    this.isInitializing = false;\n    this.initPromise = null;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/text-chunker.ts",
    "content": "/**\n * Text chunking utility\n * Based on semantic chunking strategy, splits long text into small chunks suitable for vectorization\n */\n\nexport interface TextChunk {\n  text: string;\n  source: string;\n  index: number;\n  wordCount: number;\n}\n\nexport interface ChunkingOptions {\n  maxWordsPerChunk?: number;\n  overlapSentences?: number;\n  minChunkLength?: number;\n  includeTitle?: boolean;\n}\n\nexport class TextChunker {\n  private readonly defaultOptions: Required<ChunkingOptions> = {\n    maxWordsPerChunk: 80,\n    overlapSentences: 1,\n    minChunkLength: 20,\n    includeTitle: true,\n  };\n\n  public chunkText(content: string, title?: string, options?: ChunkingOptions): TextChunk[] {\n    const opts = { ...this.defaultOptions, ...options };\n    const chunks: TextChunk[] = [];\n\n    if (opts.includeTitle && title?.trim() && title.trim().length > 5) {\n      chunks.push({\n        text: title.trim(),\n        source: 'title',\n        index: 0,\n        wordCount: title.trim().split(/\\s+/).length,\n      });\n    }\n\n    const cleanContent = content.trim();\n    if (!cleanContent) {\n      return chunks;\n    }\n\n    const sentences = this.splitIntoSentences(cleanContent);\n\n    if (sentences.length === 0) {\n      return this.fallbackChunking(cleanContent, chunks, opts);\n    }\n\n    const hasLongSentences = sentences.some(\n      (s: string) => s.split(/\\s+/).length > opts.maxWordsPerChunk,\n    );\n\n    if (hasLongSentences) {\n      return this.mixedChunking(sentences, chunks, opts);\n    }\n\n    return this.groupSentencesIntoChunks(sentences, chunks, opts);\n  }\n\n  private splitIntoSentences(content: string): string[] {\n    const processedContent = content\n      .replace(/([。！？])\\s*/g, '$1\\n')\n      .replace(/([.!?])\\s+(?=[A-Z])/g, '$1\\n')\n      .replace(/([.!?][\"'])\\s+(?=[A-Z])/g, '$1\\n')\n      .replace(/([.!?])\\s*$/gm, '$1\\n')\n      .replace(/([。！？][\"\"])\\s*/g, '$1\\n')\n      .replace(/\\n\\s*\\n/g, '\\n');\n\n    const sentences = processedContent\n      .split('\\n')\n      .map((s) => s.trim())\n      .filter((s) => s.length > 15);\n\n    if (sentences.length < 3 && content.length > 500) {\n      return this.aggressiveSentenceSplitting(content);\n    }\n\n    return sentences;\n  }\n\n  private aggressiveSentenceSplitting(content: string): string[] {\n    const sentences = content\n      .replace(/([.!?。！？])/g, '$1\\n')\n      .replace(/([;；:：])/g, '$1\\n')\n      .replace(/([)）])\\s*(?=[\\u4e00-\\u9fa5A-Z])/g, '$1\\n')\n      .split('\\n')\n      .map((s) => s.trim())\n      .filter((s) => s.length > 15);\n\n    const maxWordsPerChunk = 80;\n    const finalSentences: string[] = [];\n\n    for (const sentence of sentences) {\n      const words = sentence.split(/\\s+/);\n      if (words.length <= maxWordsPerChunk) {\n        finalSentences.push(sentence);\n      } else {\n        const overlapWords = 5;\n        for (let i = 0; i < words.length; i += maxWordsPerChunk - overlapWords) {\n          const chunkWords = words.slice(i, i + maxWordsPerChunk);\n          const chunkText = chunkWords.join(' ');\n          if (chunkText.length > 15) {\n            finalSentences.push(chunkText);\n          }\n        }\n      }\n    }\n\n    return finalSentences;\n  }\n\n  /**\n   * Group sentences into chunks\n   */\n  private groupSentencesIntoChunks(\n    sentences: string[],\n    existingChunks: TextChunk[],\n    options: Required<ChunkingOptions>,\n  ): TextChunk[] {\n    const chunks = [...existingChunks];\n    let chunkIndex = chunks.length;\n\n    let i = 0;\n    while (i < sentences.length) {\n      let currentChunkText = '';\n      let currentWordCount = 0;\n      let sentencesUsed = 0;\n\n      while (i + sentencesUsed < sentences.length && currentWordCount < options.maxWordsPerChunk) {\n        const sentence = sentences[i + sentencesUsed];\n        const sentenceWords = sentence.split(/\\s+/).length;\n\n        if (currentWordCount + sentenceWords > options.maxWordsPerChunk && currentWordCount > 0) {\n          break;\n        }\n\n        currentChunkText += (currentChunkText ? ' ' : '') + sentence;\n        currentWordCount += sentenceWords;\n        sentencesUsed++;\n      }\n\n      if (currentChunkText.trim().length > options.minChunkLength) {\n        chunks.push({\n          text: currentChunkText.trim(),\n          source: `content_chunk_${chunkIndex}`,\n          index: chunkIndex,\n          wordCount: currentWordCount,\n        });\n        chunkIndex++;\n      }\n\n      i += Math.max(1, sentencesUsed - options.overlapSentences);\n    }\n    return chunks;\n  }\n\n  /**\n   * Mixed chunking method (handles long sentences)\n   */\n  private mixedChunking(\n    sentences: string[],\n    existingChunks: TextChunk[],\n    options: Required<ChunkingOptions>,\n  ): TextChunk[] {\n    const chunks = [...existingChunks];\n    let chunkIndex = chunks.length;\n\n    for (const sentence of sentences) {\n      const sentenceWords = sentence.split(/\\s+/).length;\n\n      if (sentenceWords <= options.maxWordsPerChunk) {\n        chunks.push({\n          text: sentence.trim(),\n          source: `sentence_chunk_${chunkIndex}`,\n          index: chunkIndex,\n          wordCount: sentenceWords,\n        });\n        chunkIndex++;\n      } else {\n        const words = sentence.split(/\\s+/);\n        for (let i = 0; i < words.length; i += options.maxWordsPerChunk) {\n          const chunkWords = words.slice(i, i + options.maxWordsPerChunk);\n          const chunkText = chunkWords.join(' ');\n\n          if (chunkText.length > options.minChunkLength) {\n            chunks.push({\n              text: chunkText,\n              source: `long_sentence_chunk_${chunkIndex}_part_${Math.floor(i / options.maxWordsPerChunk)}`,\n              index: chunkIndex,\n              wordCount: chunkWords.length,\n            });\n          }\n        }\n        chunkIndex++;\n      }\n    }\n\n    return chunks;\n  }\n\n  /**\n   * Fallback chunking (when sentence splitting fails)\n   */\n  private fallbackChunking(\n    content: string,\n    existingChunks: TextChunk[],\n    options: Required<ChunkingOptions>,\n  ): TextChunk[] {\n    const chunks = [...existingChunks];\n    let chunkIndex = chunks.length;\n\n    const paragraphs = content\n      .split(/\\n\\s*\\n/)\n      .filter((p) => p.trim().length > options.minChunkLength);\n\n    if (paragraphs.length > 1) {\n      paragraphs.forEach((paragraph, index) => {\n        const cleanParagraph = paragraph.trim();\n        if (cleanParagraph.length > 0) {\n          const words = cleanParagraph.split(/\\s+/);\n          const maxWordsPerChunk = 150;\n\n          for (let i = 0; i < words.length; i += maxWordsPerChunk) {\n            const chunkWords = words.slice(i, i + maxWordsPerChunk);\n            const chunkText = chunkWords.join(' ');\n\n            if (chunkText.length > options.minChunkLength) {\n              chunks.push({\n                text: chunkText,\n                source: `paragraph_${index}_chunk_${Math.floor(i / maxWordsPerChunk)}`,\n                index: chunkIndex,\n                wordCount: chunkWords.length,\n              });\n              chunkIndex++;\n            }\n          }\n        }\n      });\n    } else {\n      const words = content.trim().split(/\\s+/);\n      const maxWordsPerChunk = 150;\n\n      for (let i = 0; i < words.length; i += maxWordsPerChunk) {\n        const chunkWords = words.slice(i, i + maxWordsPerChunk);\n        const chunkText = chunkWords.join(' ');\n\n        if (chunkText.length > options.minChunkLength) {\n          chunks.push({\n            text: chunkText,\n            source: `content_chunk_${Math.floor(i / maxWordsPerChunk)}`,\n            index: chunkIndex,\n            wordCount: chunkWords.length,\n          });\n          chunkIndex++;\n        }\n      }\n    }\n\n    return chunks;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/utils/vector-database.ts",
    "content": "/**\n * Vector database manager\n * Uses hnswlib-wasm for high-performance vector similarity search\n * Implements singleton pattern to avoid duplicate WASM module initialization\n */\n\nimport { loadHnswlib } from 'hnswlib-wasm-static';\nimport type { TextChunk } from './text-chunker';\n\nexport interface VectorDocument {\n  id: string;\n  tabId: number;\n  url: string;\n  title: string;\n  chunk: TextChunk;\n  embedding: Float32Array;\n  timestamp: number;\n}\n\nexport interface SearchResult {\n  document: VectorDocument;\n  similarity: number;\n  distance: number;\n}\n\nexport interface VectorDatabaseConfig {\n  dimension: number;\n  maxElements: number;\n  efConstruction: number;\n  M: number;\n  efSearch: number;\n  indexFileName: string;\n  enableAutoCleanup?: boolean;\n  maxRetentionDays?: number;\n}\n\nlet globalHnswlib: any = null;\nlet globalHnswlibInitPromise: Promise<any> | null = null;\nlet globalHnswlibInitialized = false;\n\nlet syncInProgress = false;\nlet pendingSyncPromise: Promise<void> | null = null;\n\nconst DB_NAME = 'VectorDatabaseStorage';\nconst DB_VERSION = 1;\nconst STORE_NAME = 'documentMappings';\n\n/**\n * IndexedDB helper functions\n */\nclass IndexedDBHelper {\n  private static dbPromise: Promise<IDBDatabase> | null = null;\n\n  static async getDB(): Promise<IDBDatabase> {\n    if (!this.dbPromise) {\n      this.dbPromise = new Promise((resolve, reject) => {\n        const request = indexedDB.open(DB_NAME, DB_VERSION);\n\n        request.onerror = () => reject(request.error);\n        request.onsuccess = () => resolve(request.result);\n\n        request.onupgradeneeded = (event) => {\n          const db = (event.target as IDBOpenDBRequest).result;\n\n          if (!db.objectStoreNames.contains(STORE_NAME)) {\n            const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });\n            store.createIndex('indexFileName', 'indexFileName', { unique: false });\n          }\n        };\n      });\n    }\n    return this.dbPromise;\n  }\n\n  static async saveData(indexFileName: string, data: any): Promise<void> {\n    const db = await this.getDB();\n    const transaction = db.transaction([STORE_NAME], 'readwrite');\n    const store = transaction.objectStore(STORE_NAME);\n\n    await new Promise<void>((resolve, reject) => {\n      const request = store.put({\n        id: indexFileName,\n        indexFileName,\n        data,\n        timestamp: Date.now(),\n      });\n\n      request.onsuccess = () => resolve();\n      request.onerror = () => reject(request.error);\n    });\n  }\n\n  static async loadData(indexFileName: string): Promise<any | null> {\n    const db = await this.getDB();\n    const transaction = db.transaction([STORE_NAME], 'readonly');\n    const store = transaction.objectStore(STORE_NAME);\n\n    return new Promise<any | null>((resolve, reject) => {\n      const request = store.get(indexFileName);\n\n      request.onsuccess = () => {\n        const result = request.result;\n        resolve(result ? result.data : null);\n      };\n      request.onerror = () => reject(request.error);\n    });\n  }\n\n  static async deleteData(indexFileName: string): Promise<void> {\n    const db = await this.getDB();\n    const transaction = db.transaction([STORE_NAME], 'readwrite');\n    const store = transaction.objectStore(STORE_NAME);\n\n    await new Promise<void>((resolve, reject) => {\n      const request = store.delete(indexFileName);\n      request.onsuccess = () => resolve();\n      request.onerror = () => reject(request.error);\n    });\n  }\n\n  /**\n   * Clear all IndexedDB data (for complete cleanup during model switching)\n   */\n  static async clearAllData(): Promise<void> {\n    try {\n      const db = await this.getDB();\n      const transaction = db.transaction([STORE_NAME], 'readwrite');\n      const store = transaction.objectStore(STORE_NAME);\n\n      await new Promise<void>((resolve, reject) => {\n        const request = store.clear();\n        request.onsuccess = () => {\n          console.log('IndexedDBHelper: All data cleared from IndexedDB');\n          resolve();\n        };\n        request.onerror = () => reject(request.error);\n      });\n    } catch (error) {\n      console.error('IndexedDBHelper: Failed to clear all data:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Get all stored keys (for debugging)\n   */\n  static async getAllKeys(): Promise<string[]> {\n    try {\n      const db = await this.getDB();\n      const transaction = db.transaction([STORE_NAME], 'readonly');\n      const store = transaction.objectStore(STORE_NAME);\n\n      return new Promise<string[]>((resolve, reject) => {\n        const request = store.getAllKeys();\n        request.onsuccess = () => resolve(request.result as string[]);\n        request.onerror = () => reject(request.error);\n      });\n    } catch (error) {\n      console.error('IndexedDBHelper: Failed to get all keys:', error);\n      return [];\n    }\n  }\n}\n\n/**\n * Global hnswlib-wasm initialization function\n * Ensures initialization only once across the entire application\n */\nasync function initializeGlobalHnswlib(): Promise<any> {\n  if (globalHnswlibInitialized && globalHnswlib) {\n    return globalHnswlib;\n  }\n\n  if (globalHnswlibInitPromise) {\n    return globalHnswlibInitPromise;\n  }\n\n  globalHnswlibInitPromise = (async () => {\n    try {\n      console.log('VectorDatabase: Initializing global hnswlib-wasm instance...');\n      globalHnswlib = await loadHnswlib();\n      globalHnswlibInitialized = true;\n      console.log('VectorDatabase: Global hnswlib-wasm instance initialized successfully');\n      return globalHnswlib;\n    } catch (error) {\n      console.error('VectorDatabase: Failed to initialize global hnswlib-wasm:', error);\n      globalHnswlibInitPromise = null;\n      throw error;\n    }\n  })();\n\n  return globalHnswlibInitPromise;\n}\n\nexport class VectorDatabase {\n  private index: any = null;\n  private isInitialized = false;\n  private isInitializing = false;\n  private initPromise: Promise<void> | null = null;\n\n  private documents = new Map<number, VectorDocument>();\n  private tabDocuments = new Map<number, Set<number>>();\n  private nextLabel = 0;\n\n  private readonly config: VectorDatabaseConfig;\n\n  constructor(config?: Partial<VectorDatabaseConfig>) {\n    this.config = {\n      dimension: 384,\n      maxElements: 100000,\n      efConstruction: 200,\n      M: 48,\n      efSearch: 50,\n      indexFileName: 'tab_content_index.dat',\n      enableAutoCleanup: true,\n      maxRetentionDays: 30,\n      ...config,\n    };\n\n    console.log('VectorDatabase: Initialized with config:', {\n      dimension: this.config.dimension,\n      efSearch: this.config.efSearch,\n      M: this.config.M,\n      efConstruction: this.config.efConstruction,\n      enableAutoCleanup: this.config.enableAutoCleanup,\n      maxRetentionDays: this.config.maxRetentionDays,\n    });\n  }\n\n  /**\n   * Initialize vector database\n   */\n  public async initialize(): Promise<void> {\n    if (this.isInitialized) return;\n    if (this.isInitializing && this.initPromise) return this.initPromise;\n\n    this.isInitializing = true;\n    this.initPromise = this._doInitialize().finally(() => {\n      this.isInitializing = false;\n    });\n\n    return this.initPromise;\n  }\n\n  private async _doInitialize(): Promise<void> {\n    try {\n      console.log('VectorDatabase: Initializing...');\n\n      const hnswlib = await initializeGlobalHnswlib();\n\n      hnswlib.EmscriptenFileSystemManager.setDebugLogs(true);\n\n      this.index = new hnswlib.HierarchicalNSW(\n        'cosine',\n        this.config.dimension,\n        this.config.indexFileName,\n      );\n\n      await this.syncFileSystem('read');\n\n      const indexExists = hnswlib.EmscriptenFileSystemManager.checkFileExists(\n        this.config.indexFileName,\n      );\n\n      if (indexExists) {\n        console.log('VectorDatabase: Loading existing index...');\n        try {\n          await this.index.readIndex(this.config.indexFileName, this.config.maxElements);\n          this.index.setEfSearch(this.config.efSearch);\n\n          await this.loadDocumentMappings();\n\n          if (this.documents.size > 0) {\n            const maxLabel = Math.max(...Array.from(this.documents.keys()));\n            this.nextLabel = maxLabel + 1;\n            console.log(\n              `VectorDatabase: Loaded existing index with ${this.documents.size} documents, next label: ${this.nextLabel}`,\n            );\n          } else {\n            const indexCount = this.index.getCurrentCount();\n            if (indexCount > 0) {\n              console.warn(\n                `VectorDatabase: Index has ${indexCount} vectors but no document mappings found. This may cause label mismatch.`,\n              );\n              this.nextLabel = indexCount;\n            } else {\n              this.nextLabel = 0;\n            }\n            console.log(\n              `VectorDatabase: No document mappings found, starting with next label: ${this.nextLabel}`,\n            );\n          }\n        } catch (loadError) {\n          console.warn(\n            'VectorDatabase: Failed to load existing index, creating new one:',\n            loadError,\n          );\n\n          this.index.initIndex(\n            this.config.maxElements,\n            this.config.M,\n            this.config.efConstruction,\n            200,\n          );\n          this.index.setEfSearch(this.config.efSearch);\n          this.nextLabel = 0;\n        }\n      } else {\n        console.log('VectorDatabase: Creating new index...');\n        this.index.initIndex(\n          this.config.maxElements,\n          this.config.M,\n          this.config.efConstruction,\n          200,\n        );\n        this.index.setEfSearch(this.config.efSearch);\n        this.nextLabel = 0;\n      }\n\n      this.isInitialized = true;\n      console.log('VectorDatabase: Initialization completed successfully');\n    } catch (error) {\n      console.error('VectorDatabase: Initialization failed:', error);\n      this.isInitialized = false;\n      throw error;\n    }\n  }\n\n  /**\n   * Add document to vector database\n   */\n  public async addDocument(\n    tabId: number,\n    url: string,\n    title: string,\n    chunk: TextChunk,\n    embedding: Float32Array,\n  ): Promise<number> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    const documentId = this.generateDocumentId(tabId, chunk.index);\n    const document: VectorDocument = {\n      id: documentId,\n      tabId,\n      url,\n      title,\n      chunk,\n      embedding,\n      timestamp: Date.now(),\n    };\n\n    try {\n      // Validate vector data\n      if (!embedding || embedding.length !== this.config.dimension) {\n        const errorMsg = `Invalid embedding dimension: expected ${this.config.dimension}, got ${embedding?.length || 0}`;\n        console.error('VectorDatabase: Dimension mismatch detected!', {\n          expectedDimension: this.config.dimension,\n          actualDimension: embedding?.length || 0,\n          documentId,\n          tabId,\n          url,\n          title: title.substring(0, 50) + '...',\n        });\n\n        // This might be caused by model switching, suggest reinitialization\n        console.warn(\n          'VectorDatabase: This might be caused by model switching. Consider reinitializing the vector database with the correct dimension.',\n        );\n\n        throw new Error(errorMsg);\n      }\n\n      // Check if vector data contains invalid values\n      for (let i = 0; i < embedding.length; i++) {\n        if (!isFinite(embedding[i])) {\n          throw new Error(`Invalid embedding value at index ${i}: ${embedding[i]}`);\n        }\n      }\n\n      // Ensure we have a clean Float32Array\n      let cleanEmbedding: Float32Array;\n      if (embedding instanceof Float32Array) {\n        cleanEmbedding = embedding;\n      } else {\n        cleanEmbedding = new Float32Array(embedding);\n      }\n\n      // Use current nextLabel as label\n      const label = this.nextLabel++;\n\n      console.log(\n        `VectorDatabase: Adding document with label ${label}, embedding dimension: ${embedding.length}`,\n      );\n\n      // Add vector to index\n      // According to hnswlib-wasm-static emscripten binding requirements, need to create VectorFloat type\n      console.log(`VectorDatabase: 🔧 DEBUGGING - About to call addPoint with:`, {\n        embeddingType: typeof cleanEmbedding,\n        isFloat32Array: cleanEmbedding instanceof Float32Array,\n        length: cleanEmbedding.length,\n        firstFewValues: Array.from(cleanEmbedding.slice(0, 3)),\n        label: label,\n        replaceDeleted: false,\n      });\n\n      // Method 1: Try using VectorFloat constructor (if available)\n      let vectorToAdd;\n      try {\n        // Check if VectorFloat constructor exists\n        if (globalHnswlib && globalHnswlib.VectorFloat) {\n          console.log('VectorDatabase: Using VectorFloat constructor');\n          vectorToAdd = new globalHnswlib.VectorFloat();\n          // Add elements to VectorFloat one by one\n          for (let i = 0; i < cleanEmbedding.length; i++) {\n            vectorToAdd.push_back(cleanEmbedding[i]);\n          }\n        } else {\n          // Method 2: Use plain JS array (fallback)\n          console.log('VectorDatabase: Using plain JS array as fallback');\n          vectorToAdd = Array.from(cleanEmbedding);\n        }\n\n        // Call addPoint with constructed vector\n        this.index.addPoint(vectorToAdd, label, false);\n\n        // Clean up VectorFloat object (if manually created)\n        if (vectorToAdd && typeof vectorToAdd.delete === 'function') {\n          vectorToAdd.delete();\n        }\n      } catch (vectorError) {\n        console.error(\n          'VectorDatabase: VectorFloat approach failed, trying alternatives:',\n          vectorError,\n        );\n\n        // Method 3: Try passing Float32Array directly\n        try {\n          console.log('VectorDatabase: Trying Float32Array directly');\n          this.index.addPoint(cleanEmbedding, label, false);\n        } catch (float32Error) {\n          console.error('VectorDatabase: Float32Array approach failed:', float32Error);\n\n          // Method 4: Last resort - use spread operator\n          console.log('VectorDatabase: Trying spread operator as last resort');\n          this.index.addPoint([...cleanEmbedding], label, false);\n        }\n      }\n      console.log(`VectorDatabase: ✅ Successfully added document with label ${label}`);\n\n      // Store document mapping\n      this.documents.set(label, document);\n\n      // Update tab document mapping\n      if (!this.tabDocuments.has(tabId)) {\n        this.tabDocuments.set(tabId, new Set());\n      }\n      this.tabDocuments.get(tabId)!.add(label);\n\n      // Save index and mappings\n      await this.saveIndex();\n      await this.saveDocumentMappings();\n\n      // Check if auto cleanup is needed\n      if (this.config.enableAutoCleanup) {\n        await this.checkAndPerformAutoCleanup();\n      }\n\n      console.log(`VectorDatabase: Successfully added document ${documentId} with label ${label}`);\n      return label;\n    } catch (error) {\n      console.error('VectorDatabase: Failed to add document:', error);\n      console.error('VectorDatabase: Embedding info:', {\n        type: typeof embedding,\n        constructor: embedding?.constructor?.name,\n        length: embedding?.length,\n        isFloat32Array: embedding instanceof Float32Array,\n        firstFewValues: embedding ? Array.from(embedding.slice(0, 5)) : null,\n      });\n      throw error;\n    }\n  }\n\n  /**\n   * Search similar documents\n   */\n  public async search(queryEmbedding: Float32Array, topK: number = 10): Promise<SearchResult[]> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    try {\n      // Validate query vector\n      if (!queryEmbedding || queryEmbedding.length !== this.config.dimension) {\n        throw new Error(\n          `Invalid query embedding dimension: expected ${this.config.dimension}, got ${queryEmbedding?.length || 0}`,\n        );\n      }\n\n      // Check if query vector contains invalid values\n      for (let i = 0; i < queryEmbedding.length; i++) {\n        if (!isFinite(queryEmbedding[i])) {\n          throw new Error(`Invalid query embedding value at index ${i}: ${queryEmbedding[i]}`);\n        }\n      }\n\n      console.log(\n        `VectorDatabase: Searching with query embedding dimension: ${queryEmbedding.length}, topK: ${topK}`,\n      );\n\n      // Check if index is empty\n      const currentCount = this.index.getCurrentCount();\n      if (currentCount === 0) {\n        console.log('VectorDatabase: Index is empty, returning no results');\n        return [];\n      }\n\n      console.log(`VectorDatabase: Index contains ${currentCount} vectors`);\n\n      // Check if document mapping and index are synchronized\n      const mappingCount = this.documents.size;\n      if (mappingCount === 0 && currentCount > 0) {\n        console.warn(\n          `VectorDatabase: Index has ${currentCount} vectors but document mapping is empty. Attempting to reload mappings...`,\n        );\n        await this.loadDocumentMappings();\n\n        if (this.documents.size === 0) {\n          console.error(\n            'VectorDatabase: Failed to load document mappings. Index and mappings are out of sync.',\n          );\n          return [];\n        }\n        console.log(\n          `VectorDatabase: Successfully reloaded ${this.documents.size} document mappings`,\n        );\n      }\n\n      // Process query vector according to hnswlib-wasm-static emscripten binding requirements\n      let queryVector;\n      let searchResult;\n\n      try {\n        // Method 1: Try using VectorFloat constructor (if available)\n        if (globalHnswlib && globalHnswlib.VectorFloat) {\n          console.log('VectorDatabase: Using VectorFloat for search query');\n          queryVector = new globalHnswlib.VectorFloat();\n          // Add elements to VectorFloat one by one\n          for (let i = 0; i < queryEmbedding.length; i++) {\n            queryVector.push_back(queryEmbedding[i]);\n          }\n          searchResult = this.index.searchKnn(queryVector, topK, undefined);\n\n          // Clean up VectorFloat object\n          if (queryVector && typeof queryVector.delete === 'function') {\n            queryVector.delete();\n          }\n        } else {\n          // Method 2: Use plain JS array (fallback)\n          console.log('VectorDatabase: Using plain JS array for search query');\n          const queryArray = Array.from(queryEmbedding);\n          searchResult = this.index.searchKnn(queryArray, topK, undefined);\n        }\n      } catch (vectorError) {\n        console.error(\n          'VectorDatabase: VectorFloat search failed, trying alternatives:',\n          vectorError,\n        );\n\n        // Method 3: Try passing Float32Array directly\n        try {\n          console.log('VectorDatabase: Trying Float32Array directly for search');\n          searchResult = this.index.searchKnn(queryEmbedding, topK, undefined);\n        } catch (float32Error) {\n          console.error('VectorDatabase: Float32Array search failed:', float32Error);\n\n          // Method 4: Last resort - use spread operator\n          console.log('VectorDatabase: Trying spread operator for search as last resort');\n          searchResult = this.index.searchKnn([...queryEmbedding], topK, undefined);\n        }\n      }\n\n      const results: SearchResult[] = [];\n\n      console.log(`VectorDatabase: Processing ${searchResult.neighbors.length} search neighbors`);\n      console.log(`VectorDatabase: Available documents in mapping: ${this.documents.size}`);\n      console.log(`VectorDatabase: Index current count: ${this.index.getCurrentCount()}`);\n\n      for (let i = 0; i < searchResult.neighbors.length; i++) {\n        const label = searchResult.neighbors[i];\n        const distance = searchResult.distances[i];\n        const similarity = 1 - distance; // Convert cosine distance to similarity\n\n        console.log(\n          `VectorDatabase: Processing neighbor ${i}: label=${label}, distance=${distance}, similarity=${similarity}`,\n        );\n\n        // Find corresponding document by label\n        const document = this.findDocumentByLabel(label);\n        if (document) {\n          console.log(`VectorDatabase: Found document for label ${label}: ${document.id}`);\n          results.push({\n            document,\n            similarity,\n            distance,\n          });\n        } else {\n          console.warn(`VectorDatabase: No document found for label ${label}`);\n\n          // Detailed debug information\n          if (i < 5) {\n            // Only show detailed info for first 5 neighbors to avoid log spam\n            console.warn(\n              `VectorDatabase: Available labels (first 20): ${Array.from(this.documents.keys()).slice(0, 20).join(', ')}`,\n            );\n            console.warn(`VectorDatabase: Total available labels: ${this.documents.size}`);\n            console.warn(\n              `VectorDatabase: Label type: ${typeof label}, Available label types: ${Array.from(\n                this.documents.keys(),\n              )\n                .slice(0, 3)\n                .map((k) => typeof k)\n                .join(', ')}`,\n            );\n          }\n        }\n      }\n\n      console.log(\n        `VectorDatabase: Found ${results.length} search results out of ${searchResult.neighbors.length} neighbors`,\n      );\n\n      // If no results found but index has data, indicates label mismatch\n      if (results.length === 0 && searchResult.neighbors.length > 0) {\n        console.error(\n          'VectorDatabase: Label mismatch detected! Index has vectors but no matching documents found.',\n        );\n        console.error(\n          'VectorDatabase: This usually indicates the index and document mappings are out of sync.',\n        );\n        console.error('VectorDatabase: Consider rebuilding the index to fix this issue.');\n\n        // Provide some diagnostic information\n        const sampleLabels = searchResult.neighbors.slice(0, 5);\n        const availableLabels = Array.from(this.documents.keys()).slice(0, 5);\n        console.error('VectorDatabase: Sample search labels:', sampleLabels);\n        console.error('VectorDatabase: Sample available labels:', availableLabels);\n      }\n\n      return results.sort((a, b) => b.similarity - a.similarity);\n    } catch (error) {\n      console.error('VectorDatabase: Search failed:', error);\n      console.error('VectorDatabase: Query embedding info:', {\n        type: typeof queryEmbedding,\n        constructor: queryEmbedding?.constructor?.name,\n        length: queryEmbedding?.length,\n        isFloat32Array: queryEmbedding instanceof Float32Array,\n        firstFewValues: queryEmbedding ? Array.from(queryEmbedding.slice(0, 5)) : null,\n      });\n      throw error;\n    }\n  }\n\n  /**\n   * Remove all documents for a tab\n   */\n  public async removeTabDocuments(tabId: number): Promise<void> {\n    if (!this.isInitialized) {\n      await this.initialize();\n    }\n\n    const documentLabels = this.tabDocuments.get(tabId);\n    if (!documentLabels) {\n      return;\n    }\n\n    try {\n      // Remove documents from mapping (hnswlib-wasm doesn't support direct deletion, only mark as deleted)\n      for (const label of documentLabels) {\n        this.documents.delete(label);\n      }\n\n      // Clean up tab mapping\n      this.tabDocuments.delete(tabId);\n\n      // Save changes\n      await this.saveDocumentMappings();\n\n      console.log(`VectorDatabase: Removed ${documentLabels.size} documents for tab ${tabId}`);\n    } catch (error) {\n      console.error('VectorDatabase: Failed to remove tab documents:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Get database statistics\n   */\n  public getStats(): {\n    totalDocuments: number;\n    totalTabs: number;\n    indexSize: number;\n    isInitialized: boolean;\n  } {\n    return {\n      totalDocuments: this.documents.size,\n      totalTabs: this.tabDocuments.size,\n      indexSize: this.calculateStorageSize(),\n      isInitialized: this.isInitialized,\n    };\n  }\n\n  /**\n   * Calculate actual storage size (bytes)\n   */\n  private calculateStorageSize(): number {\n    let totalSize = 0;\n\n    try {\n      // 1. 计算文档映射的大小\n      const documentsSize = this.calculateDocumentMappingsSize();\n      totalSize += documentsSize;\n\n      // 2. 计算向量数据的大小\n      const vectorsSize = this.calculateVectorsSize();\n      totalSize += vectorsSize;\n\n      // 3. 估算索引结构的大小\n      const indexStructureSize = this.calculateIndexStructureSize();\n      totalSize += indexStructureSize;\n\n      console.log(\n        `VectorDatabase: Storage size breakdown - Documents: ${documentsSize}, Vectors: ${vectorsSize}, Index: ${indexStructureSize}, Total: ${totalSize} bytes`,\n      );\n    } catch (error) {\n      console.warn('VectorDatabase: Failed to calculate storage size:', error);\n      // 返回一个基于文档数量的估算值\n      totalSize = this.documents.size * 1024; // 每个文档估算1KB\n    }\n\n    return totalSize;\n  }\n\n  /**\n   * Calculate document mappings size\n   */\n  private calculateDocumentMappingsSize(): number {\n    let size = 0;\n\n    // Calculate documents Map size\n    for (const [label, document] of this.documents.entries()) {\n      // label (number): 8 bytes\n      size += 8;\n\n      // document object\n      size += this.calculateObjectSize(document);\n    }\n\n    // Calculate tabDocuments Map size\n    for (const [tabId, labels] of this.tabDocuments.entries()) {\n      // tabId (number): 8 bytes\n      size += 8;\n\n      // Set of labels: 8 bytes per label + Set overhead\n      size += labels.size * 8 + 32; // 32 bytes Set overhead\n    }\n\n    return size;\n  }\n\n  /**\n   * Calculate vectors data size\n   */\n  private calculateVectorsSize(): number {\n    const documentCount = this.documents.size;\n    const dimension = this.config.dimension;\n\n    // Each vector: dimension * 4 bytes (Float32)\n    const vectorSize = dimension * 4;\n\n    return documentCount * vectorSize;\n  }\n\n  /**\n   * Estimate index structure size\n   */\n  private calculateIndexStructureSize(): number {\n    const documentCount = this.documents.size;\n\n    if (documentCount === 0) return 0;\n\n    // HNSW index size estimation\n    // Based on papers and actual testing, HNSW index size is about 20-40% of vector data\n    const vectorsSize = this.calculateVectorsSize();\n    const indexOverhead = Math.floor(vectorsSize * 0.3); // 30% overhead\n\n    // Additional graph structure overhead\n    const graphOverhead = documentCount * 64; // About 64 bytes graph structure overhead per node\n\n    return indexOverhead + graphOverhead;\n  }\n\n  /**\n   * Calculate object size (rough estimation)\n   */\n  private calculateObjectSize(obj: any): number {\n    let size = 0;\n\n    try {\n      const jsonString = JSON.stringify(obj);\n      // UTF-8 encoding, most characters 1 byte, Chinese etc 3 bytes, average 2 bytes\n      size = jsonString.length * 2;\n    } catch (error) {\n      // If JSON serialization fails, use default estimation\n      size = 512; // Default 512 bytes\n    }\n\n    return size;\n  }\n\n  /**\n   * Clear entire database\n   */\n  public async clear(): Promise<void> {\n    console.log('VectorDatabase: Starting complete database clear...');\n\n    try {\n      // Clear in-memory data structures\n      this.documents.clear();\n      this.tabDocuments.clear();\n      this.nextLabel = 0;\n\n      // Clear HNSW index file (in hnswlib-index database)\n      if (this.isInitialized && this.index) {\n        try {\n          console.log('VectorDatabase: Clearing HNSW index file from IndexedDB...');\n\n          // 1. First try to physically delete index file (using EmscriptenFileSystemManager)\n          try {\n            if (\n              globalHnswlib &&\n              globalHnswlib.EmscriptenFileSystemManager.checkFileExists(this.config.indexFileName)\n            ) {\n              console.log(\n                `VectorDatabase: Deleting physical index file: ${this.config.indexFileName}`,\n              );\n              globalHnswlib.EmscriptenFileSystemManager.deleteFile(this.config.indexFileName);\n              await this.syncFileSystem('write'); // Ensure deletion is synced to persistent storage\n              console.log(\n                `VectorDatabase: Physical index file ${this.config.indexFileName} deleted successfully`,\n              );\n            } else {\n              console.log(\n                `VectorDatabase: Physical index file ${this.config.indexFileName} does not exist or already deleted`,\n              );\n            }\n          } catch (fileError) {\n            console.warn(\n              `VectorDatabase: Failed to delete physical index file ${this.config.indexFileName}:`,\n              fileError,\n            );\n            // Continue with other cleanup operations, don't block the process\n          }\n\n          // 2. Delete index file from IndexedDB\n          await this.index.deleteIndex(this.config.indexFileName);\n          console.log('VectorDatabase: HNSW index file cleared from IndexedDB');\n\n          // 3. Reinitialize empty index\n          console.log('VectorDatabase: Reinitializing empty HNSW index...');\n          this.index.initIndex(\n            this.config.maxElements,\n            this.config.M,\n            this.config.efConstruction,\n            200,\n          );\n          this.index.setEfSearch(this.config.efSearch);\n\n          // 4. Force save empty index\n          await this.forceSaveIndex();\n        } catch (indexError) {\n          console.warn('VectorDatabase: Failed to clear HNSW index file:', indexError);\n          // Continue with other cleanup operations\n        }\n      }\n\n      // Clear document mappings from IndexedDB (in VectorDatabaseStorage database)\n      try {\n        console.log('VectorDatabase: Clearing document mappings from IndexedDB...');\n        await IndexedDBHelper.deleteData(this.config.indexFileName);\n        console.log('VectorDatabase: Document mappings cleared from IndexedDB');\n      } catch (idbError) {\n        console.warn(\n          'VectorDatabase: Failed to clear document mappings from IndexedDB, trying chrome.storage fallback:',\n          idbError,\n        );\n\n        // Clear backup data from chrome.storage\n        try {\n          const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;\n          await chrome.storage.local.remove([storageKey]);\n          console.log('VectorDatabase: Chrome storage fallback cleared');\n        } catch (storageError) {\n          console.warn('VectorDatabase: Failed to clear chrome.storage fallback:', storageError);\n        }\n      }\n\n      // Save empty document mappings to ensure consistency\n      await this.saveDocumentMappings();\n\n      console.log('VectorDatabase: Complete database clear finished successfully');\n    } catch (error) {\n      console.error('VectorDatabase: Failed to clear database:', error);\n      throw error;\n    }\n  }\n\n  /**\n   * Force save index and sync filesystem\n   */\n  private async forceSaveIndex(): Promise<void> {\n    try {\n      await this.index.writeIndex(this.config.indexFileName);\n      await this.syncFileSystem('write'); // Force sync\n    } catch (error) {\n      console.error('VectorDatabase: Failed to force save index:', error);\n    }\n  }\n\n  /**\n   * Check and perform auto cleanup\n   */\n  private async checkAndPerformAutoCleanup(): Promise<void> {\n    try {\n      const currentCount = this.documents.size;\n      const maxElements = this.config.maxElements;\n\n      console.log(\n        `VectorDatabase: Auto cleanup check - current: ${currentCount}, max: ${maxElements}`,\n      );\n\n      // Check if maximum element count is exceeded\n      if (currentCount >= maxElements) {\n        console.log('VectorDatabase: Document count reached limit, performing cleanup...');\n        await this.performLRUCleanup(Math.floor(maxElements * 0.2)); // Clean up 20% of data\n      }\n\n      // Check if there's expired data\n      if (this.config.maxRetentionDays && this.config.maxRetentionDays > 0) {\n        await this.performTimeBasedCleanup();\n      }\n    } catch (error) {\n      console.error('VectorDatabase: Auto cleanup failed:', error);\n    }\n  }\n\n  /**\n   * Perform LRU-based cleanup (delete oldest documents)\n   */\n  private async performLRUCleanup(cleanupCount: number): Promise<void> {\n    try {\n      console.log(\n        `VectorDatabase: Starting LRU cleanup, removing ${cleanupCount} oldest documents`,\n      );\n\n      // Get all documents and sort by timestamp\n      const allDocuments = Array.from(this.documents.entries());\n      allDocuments.sort((a, b) => a[1].timestamp - b[1].timestamp);\n\n      // Select documents to delete\n      const documentsToDelete = allDocuments.slice(0, cleanupCount);\n\n      for (const [label, _document] of documentsToDelete) {\n        await this.removeDocumentByLabel(label);\n      }\n\n      // Save updated index and mappings\n      await this.saveIndex();\n      await this.saveDocumentMappings();\n\n      console.log(\n        `VectorDatabase: LRU cleanup completed, removed ${documentsToDelete.length} documents`,\n      );\n    } catch (error) {\n      console.error('VectorDatabase: LRU cleanup failed:', error);\n    }\n  }\n\n  /**\n   * Perform time-based cleanup (delete expired documents)\n   */\n  private async performTimeBasedCleanup(): Promise<void> {\n    try {\n      const maxRetentionMs = this.config.maxRetentionDays! * 24 * 60 * 60 * 1000;\n      const cutoffTime = Date.now() - maxRetentionMs;\n\n      console.log(\n        `VectorDatabase: Starting time-based cleanup, removing documents older than ${this.config.maxRetentionDays} days`,\n      );\n\n      const documentsToDelete: number[] = [];\n\n      for (const [label, document] of this.documents.entries()) {\n        if (document.timestamp < cutoffTime) {\n          documentsToDelete.push(label);\n        }\n      }\n\n      for (const label of documentsToDelete) {\n        await this.removeDocumentByLabel(label);\n      }\n\n      // Save updated index and mappings\n      if (documentsToDelete.length > 0) {\n        await this.saveIndex();\n        await this.saveDocumentMappings();\n      }\n\n      console.log(\n        `VectorDatabase: Time-based cleanup completed, removed ${documentsToDelete.length} expired documents`,\n      );\n    } catch (error) {\n      console.error('VectorDatabase: Time-based cleanup failed:', error);\n    }\n  }\n\n  /**\n   * Remove single document by label\n   */\n  private async removeDocumentByLabel(label: number): Promise<void> {\n    try {\n      const document = this.documents.get(label);\n      if (!document) {\n        console.warn(`VectorDatabase: Document with label ${label} not found`);\n        return;\n      }\n\n      // Remove vector from HNSW index\n      if (this.index) {\n        try {\n          this.index.markDelete(label);\n        } catch (indexError) {\n          console.warn(\n            `VectorDatabase: Failed to mark delete in index for label ${label}:`,\n            indexError,\n          );\n        }\n      }\n\n      // Remove from memory mapping\n      this.documents.delete(label);\n\n      // Remove from tab mapping\n      const tabId = document.tabId;\n      if (this.tabDocuments.has(tabId)) {\n        this.tabDocuments.get(tabId)!.delete(label);\n        // If tab has no other documents, delete entire tab mapping\n        if (this.tabDocuments.get(tabId)!.size === 0) {\n          this.tabDocuments.delete(tabId);\n        }\n      }\n\n      console.log(`VectorDatabase: Removed document with label ${label} from tab ${tabId}`);\n    } catch (error) {\n      console.error(`VectorDatabase: Failed to remove document with label ${label}:`, error);\n    }\n  }\n\n  // 私有辅助方法\n\n  private generateDocumentId(tabId: number, chunkIndex: number): string {\n    return `tab_${tabId}_chunk_${chunkIndex}_${Date.now()}`;\n  }\n\n  private findDocumentByLabel(label: number): VectorDocument | null {\n    return this.documents.get(label) || null;\n  }\n\n  private async syncFileSystem(direction: 'read' | 'write'): Promise<void> {\n    try {\n      if (!globalHnswlib) {\n        return;\n      }\n\n      // If sync operation is already in progress, wait for it to complete\n      if (syncInProgress && pendingSyncPromise) {\n        console.log(`VectorDatabase: Sync already in progress, waiting...`);\n        await pendingSyncPromise;\n        return;\n      }\n\n      // Mark sync start\n      syncInProgress = true;\n\n      // Create sync Promise with timeout mechanism\n      pendingSyncPromise = new Promise<void>((resolve, reject) => {\n        const timeout = setTimeout(() => {\n          console.warn(`VectorDatabase: Filesystem sync (${direction}) timeout`);\n          syncInProgress = false;\n          pendingSyncPromise = null;\n          reject(new Error('Sync timeout'));\n        }, 5000); // 5 second timeout\n\n        try {\n          globalHnswlib.EmscriptenFileSystemManager.syncFS(direction === 'read', () => {\n            clearTimeout(timeout);\n            console.log(`VectorDatabase: Filesystem sync (${direction}) completed`);\n            syncInProgress = false;\n            pendingSyncPromise = null;\n            resolve();\n          });\n        } catch (error) {\n          clearTimeout(timeout);\n          console.warn(`VectorDatabase: Failed to sync filesystem (${direction}):`, error);\n          syncInProgress = false;\n          pendingSyncPromise = null;\n          reject(error);\n        }\n      });\n\n      await pendingSyncPromise;\n    } catch (error) {\n      console.warn(`VectorDatabase: Failed to sync filesystem (${direction}):`, error);\n      syncInProgress = false;\n      pendingSyncPromise = null;\n    }\n  }\n\n  private async saveIndex(): Promise<void> {\n    try {\n      await this.index.writeIndex(this.config.indexFileName);\n      // Reduce sync frequency, only sync when necessary\n      if (this.documents.size % 10 === 0) {\n        // Sync every 10 documents\n        await this.syncFileSystem('write');\n      }\n    } catch (error) {\n      console.error('VectorDatabase: Failed to save index:', error);\n    }\n  }\n\n  private async saveDocumentMappings(): Promise<void> {\n    try {\n      // Save document mappings to IndexedDB\n      const mappingData = {\n        documents: Array.from(this.documents.entries()),\n        tabDocuments: Array.from(this.tabDocuments.entries()).map(([tabId, labels]) => [\n          tabId,\n          Array.from(labels),\n        ]),\n        nextLabel: this.nextLabel,\n      };\n\n      try {\n        // Use IndexedDB to save data, supports larger storage capacity\n        await IndexedDBHelper.saveData(this.config.indexFileName, mappingData);\n        console.log('VectorDatabase: Document mappings saved to IndexedDB');\n      } catch (idbError) {\n        console.warn(\n          'VectorDatabase: Failed to save to IndexedDB, falling back to chrome.storage:',\n          idbError,\n        );\n\n        // Fall back to chrome.storage.local\n        try {\n          const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;\n          await chrome.storage.local.set({ [storageKey]: mappingData });\n          console.log('VectorDatabase: Document mappings saved to chrome.storage.local (fallback)');\n        } catch (storageError) {\n          console.error(\n            'VectorDatabase: Failed to save to both IndexedDB and chrome.storage:',\n            storageError,\n          );\n        }\n      }\n    } catch (error) {\n      console.error('VectorDatabase: Failed to save document mappings:', error);\n    }\n  }\n\n  public async loadDocumentMappings(): Promise<void> {\n    try {\n      // Load document mappings from IndexedDB\n      if (!globalHnswlib) {\n        return;\n      }\n\n      let mappingData = null;\n\n      try {\n        // First try to read from IndexedDB\n        mappingData = await IndexedDBHelper.loadData(this.config.indexFileName);\n        if (mappingData) {\n          console.log(`VectorDatabase: Loaded document mappings from IndexedDB`);\n        }\n      } catch (idbError) {\n        console.warn(\n          'VectorDatabase: Failed to read from IndexedDB, trying chrome.storage:',\n          idbError,\n        );\n      }\n\n      // If IndexedDB has no data, try reading from chrome.storage.local (backward compatibility)\n      if (!mappingData) {\n        try {\n          const storageKey = `hnswlib_document_mappings_${this.config.indexFileName}`;\n          const result = await chrome.storage.local.get([storageKey]);\n          mappingData = result[storageKey];\n          if (mappingData) {\n            console.log(\n              `VectorDatabase: Loaded document mappings from chrome.storage.local (fallback)`,\n            );\n\n            // Migrate to IndexedDB\n            try {\n              await IndexedDBHelper.saveData(this.config.indexFileName, mappingData);\n              console.log('VectorDatabase: Migrated data from chrome.storage to IndexedDB');\n            } catch (migrationError) {\n              console.warn('VectorDatabase: Failed to migrate data to IndexedDB:', migrationError);\n            }\n          }\n        } catch (storageError) {\n          console.warn('VectorDatabase: Failed to read from chrome.storage.local:', storageError);\n        }\n      }\n\n      if (mappingData) {\n        // Restore document mappings\n        this.documents.clear();\n        for (const [label, doc] of mappingData.documents) {\n          this.documents.set(label, doc);\n        }\n\n        // Restore tab mappings\n        this.tabDocuments.clear();\n        for (const [tabId, labels] of mappingData.tabDocuments) {\n          this.tabDocuments.set(tabId, new Set(labels));\n        }\n\n        // Restore nextLabel - use saved value or calculate max label + 1\n        if (mappingData.nextLabel !== undefined) {\n          this.nextLabel = mappingData.nextLabel;\n        } else if (this.documents.size > 0) {\n          // If no saved nextLabel, calculate max label + 1\n          const maxLabel = Math.max(...Array.from(this.documents.keys()));\n          this.nextLabel = maxLabel + 1;\n        } else {\n          this.nextLabel = 0;\n        }\n\n        console.log(\n          `VectorDatabase: Loaded ${this.documents.size} document mappings, next label: ${this.nextLabel}`,\n        );\n      } else {\n        console.log('VectorDatabase: No existing document mappings found');\n      }\n    } catch (error) {\n      console.error('VectorDatabase: Failed to load document mappings:', error);\n    }\n  }\n}\n\n// Global VectorDatabase singleton\nlet globalVectorDatabase: VectorDatabase | null = null;\nlet currentDimension: number | null = null;\n\n/**\n * Get global VectorDatabase singleton instance\n * If dimension changes, will recreate instance to ensure compatibility\n */\nexport async function getGlobalVectorDatabase(\n  config?: Partial<VectorDatabaseConfig>,\n): Promise<VectorDatabase> {\n  const newDimension = config?.dimension || 384;\n\n  // If dimension changes, need to recreate vector database\n  if (globalVectorDatabase && currentDimension !== null && currentDimension !== newDimension) {\n    console.log(\n      `VectorDatabase: Dimension changed from ${currentDimension} to ${newDimension}, recreating instance`,\n    );\n\n    // Clean up old instance - this will clean up index files and document mappings\n    try {\n      await globalVectorDatabase.clear();\n      console.log('VectorDatabase: Successfully cleared old instance for dimension change');\n    } catch (error) {\n      console.warn('VectorDatabase: Error during cleanup:', error);\n    }\n\n    globalVectorDatabase = null;\n    currentDimension = null;\n  }\n\n  if (!globalVectorDatabase) {\n    globalVectorDatabase = new VectorDatabase(config);\n    currentDimension = newDimension;\n    console.log(\n      `VectorDatabase: Created global singleton instance with dimension ${currentDimension}`,\n    );\n  }\n\n  return globalVectorDatabase;\n}\n\n/**\n * Synchronous version of getting global VectorDatabase instance (for backward compatibility)\n * Note: If dimension change is needed, recommend using async version\n */\nexport function getGlobalVectorDatabaseSync(\n  config?: Partial<VectorDatabaseConfig>,\n): VectorDatabase {\n  const newDimension = config?.dimension || 384;\n\n  // If dimension changes, log warning but don't clean up (avoid race conditions)\n  if (globalVectorDatabase && currentDimension !== null && currentDimension !== newDimension) {\n    console.warn(\n      `VectorDatabase: Dimension mismatch detected (${currentDimension} vs ${newDimension}). Consider using async version for proper cleanup.`,\n    );\n  }\n\n  if (!globalVectorDatabase) {\n    globalVectorDatabase = new VectorDatabase(config);\n    currentDimension = newDimension;\n    console.log(\n      `VectorDatabase: Created global singleton instance with dimension ${currentDimension}`,\n    );\n  }\n\n  return globalVectorDatabase;\n}\n\n/**\n * Reset global VectorDatabase instance (mainly for testing or model switching)\n */\nexport async function resetGlobalVectorDatabase(): Promise<void> {\n  console.log('VectorDatabase: Starting global instance reset...');\n\n  if (globalVectorDatabase) {\n    try {\n      console.log('VectorDatabase: Clearing existing global instance...');\n      await globalVectorDatabase.clear();\n      console.log('VectorDatabase: Global instance cleared successfully');\n    } catch (error) {\n      console.warn('VectorDatabase: Failed to clear during reset:', error);\n    }\n  }\n\n  // Additional cleanup: ensure all possible IndexedDB data is cleared\n  try {\n    console.log('VectorDatabase: Performing comprehensive IndexedDB cleanup...');\n\n    // Clear all data in VectorDatabaseStorage database\n    await IndexedDBHelper.clearAllData();\n\n    // Clear index files from hnswlib-index database\n    try {\n      console.log('VectorDatabase: Clearing HNSW index files from IndexedDB...');\n\n      // Try to clean up possible existing index files\n      const possibleIndexFiles = ['tab_content_index.dat', 'content_index.dat', 'vector_index.dat'];\n\n      // If global hnswlib instance exists, try to delete known index files\n      if (typeof globalHnswlib !== 'undefined' && globalHnswlib) {\n        for (const fileName of possibleIndexFiles) {\n          try {\n            // 1. First try to physically delete index file (using EmscriptenFileSystemManager)\n            try {\n              if (globalHnswlib.EmscriptenFileSystemManager.checkFileExists(fileName)) {\n                console.log(`VectorDatabase: Deleting physical index file: ${fileName}`);\n                globalHnswlib.EmscriptenFileSystemManager.deleteFile(fileName);\n                console.log(`VectorDatabase: Physical index file ${fileName} deleted successfully`);\n              }\n            } catch (fileError) {\n              console.log(\n                `VectorDatabase: Physical index file ${fileName} not found or failed to delete:`,\n                fileError,\n              );\n            }\n\n            // 2. Delete index file from IndexedDB\n            const tempIndex = new globalHnswlib.HierarchicalNSW('cosine', 384);\n            await tempIndex.deleteIndex(fileName);\n            console.log(`VectorDatabase: Deleted IndexedDB index file: ${fileName}`);\n          } catch (deleteError) {\n            // File might not exist, this is normal\n            console.log(`VectorDatabase: Index file ${fileName} not found or already deleted`);\n          }\n        }\n\n        // 3. Force sync filesystem to ensure deletion takes effect\n        try {\n          await new Promise<void>((resolve) => {\n            const timeout = setTimeout(() => {\n              console.warn('VectorDatabase: Filesystem sync timeout during cleanup');\n              resolve(); // Don't block the process\n            }, 3000);\n\n            globalHnswlib.EmscriptenFileSystemManager.syncFS(false, () => {\n              clearTimeout(timeout);\n              console.log('VectorDatabase: Filesystem sync completed during cleanup');\n              resolve();\n            });\n          });\n        } catch (syncError) {\n          console.warn('VectorDatabase: Failed to sync filesystem during cleanup:', syncError);\n        }\n      }\n    } catch (hnswError) {\n      console.warn('VectorDatabase: Failed to clear HNSW index files:', hnswError);\n    }\n\n    // Clear possible chrome.storage backup data (only clear vector database related data, preserve user preferences)\n    const possibleKeys = [\n      'hnswlib_document_mappings_tab_content_index.dat',\n      'hnswlib_document_mappings_content_index.dat',\n      'hnswlib_document_mappings_vector_index.dat',\n      // Note: Don't clear selectedModel and selectedVersion, these are user preference settings\n      // Note: Don't clear modelState, this contains model state info and should be handled by model management logic\n    ];\n\n    if (possibleKeys.length > 0) {\n      try {\n        await chrome.storage.local.remove(possibleKeys);\n        console.log('VectorDatabase: Chrome storage backup data cleared');\n      } catch (storageError) {\n        console.warn('VectorDatabase: Failed to clear chrome.storage backup:', storageError);\n      }\n    }\n\n    console.log('VectorDatabase: Comprehensive cleanup completed');\n  } catch (cleanupError) {\n    console.warn('VectorDatabase: Comprehensive cleanup failed:', cleanupError);\n  }\n\n  globalVectorDatabase = null;\n  currentDimension = null;\n  console.log('VectorDatabase: Global singleton instance reset completed');\n}\n\n/**\n * Specifically for data cleanup during model switching\n * Clear all IndexedDB data, including HNSW index files and document mappings\n */\nexport async function clearAllVectorData(): Promise<void> {\n  console.log('VectorDatabase: Starting comprehensive vector data cleanup for model switch...');\n\n  try {\n    // 1. Clear global instance\n    if (globalVectorDatabase) {\n      try {\n        await globalVectorDatabase.clear();\n      } catch (error) {\n        console.warn('VectorDatabase: Failed to clear global instance:', error);\n      }\n    }\n\n    // 2. Clear VectorDatabaseStorage database\n    try {\n      console.log('VectorDatabase: Clearing VectorDatabaseStorage database...');\n      await IndexedDBHelper.clearAllData();\n    } catch (error) {\n      console.warn('VectorDatabase: Failed to clear VectorDatabaseStorage:', error);\n    }\n\n    // 3. Clear hnswlib-index database and physical files\n    try {\n      console.log('VectorDatabase: Clearing hnswlib-index database and physical files...');\n\n      // 3.1 First try to physically delete index files (using EmscriptenFileSystemManager)\n      if (typeof globalHnswlib !== 'undefined' && globalHnswlib) {\n        const possibleIndexFiles = [\n          'tab_content_index.dat',\n          'content_index.dat',\n          'vector_index.dat',\n        ];\n\n        for (const fileName of possibleIndexFiles) {\n          try {\n            if (globalHnswlib.EmscriptenFileSystemManager.checkFileExists(fileName)) {\n              console.log(`VectorDatabase: Deleting physical index file: ${fileName}`);\n              globalHnswlib.EmscriptenFileSystemManager.deleteFile(fileName);\n              console.log(`VectorDatabase: Physical index file ${fileName} deleted successfully`);\n            }\n          } catch (fileError) {\n            console.log(\n              `VectorDatabase: Physical index file ${fileName} not found or failed to delete:`,\n              fileError,\n            );\n          }\n        }\n\n        // Force sync filesystem\n        try {\n          await new Promise<void>((resolve) => {\n            const timeout = setTimeout(() => {\n              console.warn('VectorDatabase: Filesystem sync timeout during model switch cleanup');\n              resolve();\n            }, 3000);\n\n            globalHnswlib.EmscriptenFileSystemManager.syncFS(false, () => {\n              clearTimeout(timeout);\n              console.log('VectorDatabase: Filesystem sync completed during model switch cleanup');\n              resolve();\n            });\n          });\n        } catch (syncError) {\n          console.warn(\n            'VectorDatabase: Failed to sync filesystem during model switch cleanup:',\n            syncError,\n          );\n        }\n      }\n\n      // 3.2 Delete entire hnswlib-index database\n      await new Promise<void>((resolve) => {\n        const deleteRequest = indexedDB.deleteDatabase('/hnswlib-index');\n        deleteRequest.onsuccess = () => {\n          console.log('VectorDatabase: Successfully deleted /hnswlib-index database');\n          resolve();\n        };\n        deleteRequest.onerror = () => {\n          console.warn(\n            'VectorDatabase: Failed to delete /hnswlib-index database:',\n            deleteRequest.error,\n          );\n          resolve(); // Don't block the process\n        };\n        deleteRequest.onblocked = () => {\n          console.warn('VectorDatabase: Deletion of /hnswlib-index database was blocked');\n          resolve(); // Don't block the process\n        };\n      });\n    } catch (error) {\n      console.warn(\n        'VectorDatabase: Failed to clear hnswlib-index database and physical files:',\n        error,\n      );\n    }\n\n    // 4. Clear backup data from chrome.storage\n    try {\n      const storageKeys = [\n        'hnswlib_document_mappings_tab_content_index.dat',\n        'hnswlib_document_mappings_content_index.dat',\n        'hnswlib_document_mappings_vector_index.dat',\n      ];\n      await chrome.storage.local.remove(storageKeys);\n      console.log('VectorDatabase: Chrome storage backup data cleared');\n    } catch (error) {\n      console.warn('VectorDatabase: Failed to clear chrome.storage backup:', error);\n    }\n\n    // 5. Reset global state\n    globalVectorDatabase = null;\n    currentDimension = null;\n\n    console.log('VectorDatabase: Comprehensive vector data cleanup completed successfully');\n  } catch (error) {\n    console.error('VectorDatabase: Comprehensive vector data cleanup failed:', error);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "app/chrome-extension/vitest.config.ts",
    "content": "import { fileURLToPath } from 'node:url';\n\nimport { defineConfig } from 'vitest/config';\n\nconst rootDir = fileURLToPath(new URL('.', import.meta.url));\n\nexport default defineConfig({\n  resolve: {\n    alias: {\n      // Match WXT's path aliases from .wxt/tsconfig.json\n      '@': rootDir,\n      '~': rootDir,\n      // Mock hnswlib-wasm-static to avoid native module issues in tests\n      'hnswlib-wasm-static': `${rootDir}/tests/__mocks__/hnswlib-wasm-static.ts`,\n    },\n  },\n  test: {\n    environment: 'jsdom',\n    include: ['tests/**/*.test.ts'],\n    exclude: ['node_modules', '.output', 'dist', '.wxt'],\n    setupFiles: ['tests/vitest.setup.ts'],\n    environmentOptions: {\n      jsdom: {\n        // Provide a stable URL for anchor/href tests\n        url: 'https://example.com/',\n      },\n    },\n    // Auto-cleanup mocks between tests\n    clearMocks: true,\n    restoreMocks: true,\n    // TypeScript support via esbuild (faster than ts-jest)\n    typecheck: {\n      enabled: false, // Run separately with vue-tsc\n    },\n  },\n});\n"
  },
  {
    "path": "app/chrome-extension/workers/ort-wasm-simd-threaded.jsep.mjs",
    "content": "var ortWasmThreaded = (() => {\n  var _scriptName = import.meta.url;\n  \n  return (\nasync function(moduleArg = {}) {\n  var moduleRtn;\n\nvar e=moduleArg,aa,ca,da=new Promise((a,b)=>{aa=a;ca=b}),ea=\"object\"==typeof window,k=\"undefined\"!=typeof WorkerGlobalScope,n=\"object\"==typeof process&&\"object\"==typeof process.versions&&\"string\"==typeof process.versions.node&&\"renderer\"!=process.type,q=k&&self.name?.startsWith(\"em-pthread\");if(n){const {createRequire:a}=await import(\"module\");var require=a(import.meta.url),fa=require(\"worker_threads\");global.Worker=fa.Worker;q=(k=!fa.pc)&&\"em-pthread\"==fa.workerData}\ne.mountExternalData=(a,b)=>{a.startsWith(\"./\")&&(a=a.substring(2));(e.Fb||(e.Fb=new Map)).set(a,b)};e.unmountExternalData=()=>{delete e.Fb};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,qc:!0})).buffer.constructor;\nconst ha=a=>async(...b)=>{try{if(e.Gb)throw Error(\"Session already started\");const c=e.Gb={ec:b[0],errors:[]},d=await a(...b);if(e.Gb!==c)throw Error(\"Session mismatch\");e.Kb?.flush();const f=c.errors;if(0<f.length){let g=await Promise.all(f);g=g.filter(h=>h);if(0<g.length)throw Error(g.join(\"\\n\"));}return d}finally{e.Gb=null}};\ne.jsepInit=(a,b)=>{if(\"webgpu\"===a){[e.Kb,e.Vb,e.Zb,e.Lb,e.Yb,e.kb,e.$b,e.bc,e.Wb,e.Xb,e.ac]=b;const c=e.Kb;e.jsepRegisterBuffer=(d,f,g,h)=>c.registerBuffer(d,f,g,h);e.jsepGetBuffer=d=>c.getBuffer(d);e.jsepCreateDownloader=(d,f,g)=>c.createDownloader(d,f,g);e.jsepOnCreateSession=d=>{c.onCreateSession(d)};e.jsepOnReleaseSession=d=>{c.onReleaseSession(d)};e.jsepOnRunStart=d=>c.onRunStart(d);e.cc=(d,f)=>{c.upload(d,f)}}else if(\"webnn\"===a){const c=b[0];[e.oc,e.Ob,e.webnnEnsureTensor,e.Pb,e.webnnDownloadTensor]=\nb.slice(1);e.webnnReleaseTensorId=e.Ob;e.webnnUploadTensor=e.Pb;e.webnnOnRunStart=d=>c.onRunStart(d);e.webnnOnRunEnd=c.onRunEnd.bind(c);e.webnnRegisterMLContext=(d,f)=>{c.registerMLContext(d,f)};e.webnnOnReleaseSession=d=>{c.onReleaseSession(d)};e.webnnCreateMLTensorDownloader=(d,f)=>c.createMLTensorDownloader(d,f);e.webnnRegisterMLTensor=(d,f,g,h)=>c.registerMLTensor(d,f,g,h);e.webnnCreateMLContext=d=>c.createMLContext(d);e.webnnRegisterMLConstant=(d,f,g,h,l,m)=>c.registerMLConstant(d,f,g,h,l,e.Fb,\nm);e.webnnRegisterGraphInput=c.registerGraphInput.bind(c);e.webnnIsGraphInput=c.isGraphInput.bind(c);e.webnnRegisterGraphOutput=c.registerGraphOutput.bind(c);e.webnnIsGraphOutput=c.isGraphOutput.bind(c);e.webnnCreateTemporaryTensor=c.createTemporaryTensor.bind(c);e.webnnIsGraphInputOutputTypeSupported=c.isGraphInputOutputTypeSupported.bind(c)}};\nlet ja=()=>{const a=(b,c,d)=>(...f)=>{const g=t,h=c?.();f=b(...f);const l=c?.();h!==l&&(b=l,d(h),c=d=null);return t!=g?ia():f};(b=>{for(const c of b)e[c]=a(e[c],()=>e[c],d=>e[c]=d)})([\"_OrtAppendExecutionProvider\",\"_OrtCreateSession\",\"_OrtRun\",\"_OrtRunWithBinding\",\"_OrtBindInput\"]);\"undefined\"!==typeof ha&&(e._OrtRun=ha(e._OrtRun),e._OrtRunWithBinding=ha(e._OrtRunWithBinding));ja=void 0};e.asyncInit=()=>{ja?.()};var ka=Object.assign({},e),la=\"./this.program\",ma=(a,b)=>{throw b;},v=\"\",na,oa;\nif(n){var fs=require(\"fs\"),pa=require(\"path\");import.meta.url.startsWith(\"data:\")||(v=pa.dirname(require(\"url\").fileURLToPath(import.meta.url))+\"/\");oa=a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a)};na=async a=>{a=qa(a)?new URL(a):a;return fs.readFileSync(a,void 0)};!e.thisProgram&&1<process.argv.length&&(la=process.argv[1].replace(/\\\\/g,\"/\"));process.argv.slice(2);ma=(a,b)=>{process.exitCode=a;throw b;}}else if(ea||k)k?v=self.location.href:\"undefined\"!=typeof document&&\ndocument.currentScript&&(v=document.currentScript.src),_scriptName&&(v=_scriptName),v.startsWith(\"blob:\")?v=\"\":v=v.slice(0,v.replace(/[?#].*/,\"\").lastIndexOf(\"/\")+1),n||(k&&(oa=a=>{var b=new XMLHttpRequest;b.open(\"GET\",a,!1);b.responseType=\"arraybuffer\";b.send(null);return new Uint8Array(b.response)}),na=async a=>{if(qa(a))return new Promise((c,d)=>{var f=new XMLHttpRequest;f.open(\"GET\",a,!0);f.responseType=\"arraybuffer\";f.onload=()=>{200==f.status||0==f.status&&f.response?c(f.response):d(f.status)};\nf.onerror=d;f.send(null)});var b=await fetch(a,{credentials:\"same-origin\"});if(b.ok)return b.arrayBuffer();throw Error(b.status+\" : \"+b.url);});var ra=console.log.bind(console),sa=console.error.bind(console);n&&(ra=(...a)=>fs.writeSync(1,a.join(\" \")+\"\\n\"),sa=(...a)=>fs.writeSync(2,a.join(\" \")+\"\\n\"));var ta=ra,x=sa;Object.assign(e,ka);ka=null;var ua=e.wasmBinary,z,va,A=!1,wa,B,xa,ya,za,Aa,Ba,Ca,C,Da,Ea,qa=a=>a.startsWith(\"file://\");function D(){z.buffer!=B.buffer&&E();return B}\nfunction F(){z.buffer!=B.buffer&&E();return xa}function G(){z.buffer!=B.buffer&&E();return ya}function Fa(){z.buffer!=B.buffer&&E();return za}function H(){z.buffer!=B.buffer&&E();return Aa}function I(){z.buffer!=B.buffer&&E();return Ba}function Ga(){z.buffer!=B.buffer&&E();return Ca}function J(){z.buffer!=B.buffer&&E();return Ea}\nif(q){var Ha;if(n){var Ia=fa.parentPort;Ia.on(\"message\",b=>onmessage({data:b}));Object.assign(globalThis,{self:global,postMessage:b=>Ia.postMessage(b)})}var Ja=!1;x=function(...b){b=b.join(\" \");n?fs.writeSync(2,b+\"\\n\"):console.error(b)};self.alert=function(...b){postMessage({Cb:\"alert\",text:b.join(\" \"),jc:Ka()})};self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var c=b.data,d=c.Cb;if(\"load\"===d){let f=[];self.onmessage=g=>f.push(g);self.startWorker=()=>{postMessage({Cb:\"loaded\"});\nfor(let g of f)a(g);self.onmessage=a};for(const g of c.Sb)if(!e[g]||e[g].proxy)e[g]=(...h)=>{postMessage({Cb:\"callHandler\",Rb:g,args:h})},\"print\"==g&&(ta=e[g]),\"printErr\"==g&&(x=e[g]);z=c.lc;E();Ha(c.mc)}else if(\"run\"===d){La(c.Bb);Ma(c.Bb,0,0,1,0,0);Na();Oa(c.Bb);Ja||(Pa(),Ja=!0);try{Qa(c.hc,c.Ib)}catch(f){if(\"unwind\"!=f)throw f;}}else\"setimmediate\"!==c.target&&(\"checkMailbox\"===d?Ja&&Ra():d&&(x(`worker: received unknown command ${d}`),x(c)))}catch(f){throw Sa(),f;}}self.onmessage=a}\nfunction E(){var a=z.buffer;e.HEAP8=B=new Int8Array(a);e.HEAP16=ya=new Int16Array(a);e.HEAPU8=xa=new Uint8Array(a);e.HEAPU16=za=new Uint16Array(a);e.HEAP32=Aa=new Int32Array(a);e.HEAPU32=Ba=new Uint32Array(a);e.HEAPF32=Ca=new Float32Array(a);e.HEAPF64=Ea=new Float64Array(a);e.HEAP64=C=new BigInt64Array(a);e.HEAPU64=Da=new BigUint64Array(a)}q||(z=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),E());function Ta(){q?startWorker(e):K.Da()}var Ua=0,Va=null;\nfunction Wa(){Ua--;if(0==Ua&&Va){var a=Va;Va=null;a()}}function L(a){a=\"Aborted(\"+a+\")\";x(a);A=!0;a=new WebAssembly.RuntimeError(a+\". Build with -sASSERTIONS for more info.\");ca(a);throw a;}var Xa;async function Ya(a){if(!ua)try{var b=await na(a);return new Uint8Array(b)}catch{}if(a==Xa&&ua)a=new Uint8Array(ua);else if(oa)a=oa(a);else throw\"both async and sync fetching of the wasm failed\";return a}\nasync function Za(a,b){try{var c=await Ya(a);return await WebAssembly.instantiate(c,b)}catch(d){x(`failed to asynchronously prepare wasm: ${d}`),L(d)}}async function $a(a){var b=Xa;if(!ua&&\"function\"==typeof WebAssembly.instantiateStreaming&&!qa(b)&&!n)try{var c=fetch(b,{credentials:\"same-origin\"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){x(`wasm streaming compile failed: ${d}`),x(\"falling back to ArrayBuffer instantiation\")}return Za(b,a)}\nfunction ab(){bb={L:cb,Aa:db,b:eb,$:fb,A:gb,pa:hb,X:ib,Z:jb,qa:kb,na:lb,ga:mb,ma:nb,J:ob,Y:pb,V:qb,oa:rb,W:sb,va:tb,E:ub,Q:vb,O:wb,D:xb,v:yb,r:zb,P:Ab,z:Bb,R:Cb,ja:Db,T:Eb,aa:Fb,M:Gb,F:Hb,ia:Oa,sa:Ib,t:Jb,Ca:Kb,w:Lb,o:Mb,m:Nb,c:Ob,Ba:Pb,n:Qb,j:Rb,u:Sb,p:Tb,f:Ub,s:Vb,l:Wb,e:Xb,k:Yb,h:Zb,g:$b,d:ac,da:bc,ea:cc,fa:dc,ba:ec,ca:fc,N:gc,xa:hc,ua:ic,i:jc,C:kc,G:lc,ta:mc,x:nc,ra:oc,U:pc,q:qc,y:rc,K:sc,S:tc,za:uc,ya:vc,ka:wc,la:xc,_:yc,B:zc,I:Ac,ha:Bc,H:Cc,a:z,wa:Dc};return{a:bb}}\nvar Ec={840156:(a,b,c,d,f)=>{if(\"undefined\"==typeof e||!e.Fb)return 1;a=M(Number(a>>>0));a.startsWith(\"./\")&&(a=a.substring(2));a=e.Fb.get(a);if(!a)return 2;b=Number(b>>>0);c=Number(c>>>0);d=Number(d>>>0);if(b+c>a.byteLength)return 3;try{const g=a.subarray(b,b+c);switch(f){case 0:F().set(g,d>>>0);break;case 1:e.nc?e.nc(d,g):e.cc(d,g);break;default:return 4}return 0}catch{return 4}},840980:(a,b,c)=>{e.Pb(a,F().subarray(b>>>0,b+c>>>0))},841044:()=>e.oc(),841086:a=>{e.Ob(a)},841123:()=>{e.Wb()},841154:()=>\n{e.Xb()},841183:()=>{e.ac()},841208:a=>e.Vb(a),841241:a=>e.Zb(a),841273:(a,b,c)=>{e.Lb(Number(a),Number(b),Number(c),!0)},841336:(a,b,c)=>{e.Lb(Number(a),Number(b),Number(c))},841393:()=>\"undefined\"!==typeof wasmOffsetConverter,841450:a=>{e.kb(\"Abs\",a,void 0)},841501:a=>{e.kb(\"Neg\",a,void 0)},841552:a=>{e.kb(\"Floor\",a,void 0)},841605:a=>{e.kb(\"Ceil\",a,void 0)},841657:a=>{e.kb(\"Reciprocal\",a,void 0)},841715:a=>{e.kb(\"Sqrt\",a,void 0)},841767:a=>{e.kb(\"Exp\",a,void 0)},841818:a=>{e.kb(\"Erf\",a,void 0)},\n841869:a=>{e.kb(\"Sigmoid\",a,void 0)},841924:(a,b,c)=>{e.kb(\"HardSigmoid\",a,{alpha:b,beta:c})},842003:a=>{e.kb(\"Log\",a,void 0)},842054:a=>{e.kb(\"Sin\",a,void 0)},842105:a=>{e.kb(\"Cos\",a,void 0)},842156:a=>{e.kb(\"Tan\",a,void 0)},842207:a=>{e.kb(\"Asin\",a,void 0)},842259:a=>{e.kb(\"Acos\",a,void 0)},842311:a=>{e.kb(\"Atan\",a,void 0)},842363:a=>{e.kb(\"Sinh\",a,void 0)},842415:a=>{e.kb(\"Cosh\",a,void 0)},842467:a=>{e.kb(\"Asinh\",a,void 0)},842520:a=>{e.kb(\"Acosh\",a,void 0)},842573:a=>{e.kb(\"Atanh\",a,void 0)},\n842626:a=>{e.kb(\"Tanh\",a,void 0)},842678:a=>{e.kb(\"Not\",a,void 0)},842729:(a,b,c)=>{e.kb(\"Clip\",a,{min:b,max:c})},842798:a=>{e.kb(\"Clip\",a,void 0)},842850:(a,b)=>{e.kb(\"Elu\",a,{alpha:b})},842908:a=>{e.kb(\"Gelu\",a,void 0)},842960:a=>{e.kb(\"Relu\",a,void 0)},843012:(a,b)=>{e.kb(\"LeakyRelu\",a,{alpha:b})},843076:(a,b)=>{e.kb(\"ThresholdedRelu\",a,{alpha:b})},843146:(a,b)=>{e.kb(\"Cast\",a,{to:b})},843204:a=>{e.kb(\"Add\",a,void 0)},843255:a=>{e.kb(\"Sub\",a,void 0)},843306:a=>{e.kb(\"Mul\",a,void 0)},843357:a=>\n{e.kb(\"Div\",a,void 0)},843408:a=>{e.kb(\"Pow\",a,void 0)},843459:a=>{e.kb(\"Equal\",a,void 0)},843512:a=>{e.kb(\"Greater\",a,void 0)},843567:a=>{e.kb(\"GreaterOrEqual\",a,void 0)},843629:a=>{e.kb(\"Less\",a,void 0)},843681:a=>{e.kb(\"LessOrEqual\",a,void 0)},843740:(a,b,c,d,f)=>{e.kb(\"ReduceMean\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},843915:(a,b,c,d,f)=>{e.kb(\"ReduceMax\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>\n0,Number(f)>>>0)):[]})},844089:(a,b,c,d,f)=>{e.kb(\"ReduceMin\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},844263:(a,b,c,d,f)=>{e.kb(\"ReduceProd\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},844438:(a,b,c,d,f)=>{e.kb(\"ReduceSum\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},844612:(a,b,c,d,f)=>{e.kb(\"ReduceL1\",a,{keepDims:!!b,\nnoopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},844785:(a,b,c,d,f)=>{e.kb(\"ReduceL2\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},844958:(a,b,c,d,f)=>{e.kb(\"ReduceLogSum\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},845135:(a,b,c,d,f)=>{e.kb(\"ReduceSumSquare\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>\n0,Number(f)>>>0)):[]})},845315:(a,b,c,d,f)=>{e.kb(\"ReduceLogSumExp\",a,{keepDims:!!b,noopWithEmptyAxes:!!c,axes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},845495:a=>{e.kb(\"Where\",a,void 0)},845548:(a,b,c)=>{e.kb(\"Transpose\",a,{perm:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[]})},845672:(a,b,c,d)=>{e.kb(\"DepthToSpace\",a,{blocksize:b,mode:M(c),format:d?\"NHWC\":\"NCHW\"})},845805:(a,b,c,d)=>{e.kb(\"DepthToSpace\",a,{blocksize:b,mode:M(c),format:d?\"NHWC\":\"NCHW\"})},845938:(a,\nb,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.kb(\"ConvTranspose\",a,{format:m?\"NHWC\":\"NCHW\",autoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},846371:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"ConvTranspose\",a,{format:l?\"NHWC\":\"NCHW\",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>>\n0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},847032:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba)=>{e.kb(\"ConvTranspose\",a,{format:m?\"NHWC\":\"NCHW\",\nautoPad:b,dilations:[c],group:d,kernelShape:[f],pads:[g,h],strides:[l],wIsConst:()=>!!D()[p>>>0],outputPadding:r?Array.from(H().subarray(Number(r)>>>0,Number(u)>>>0)):[],outputShape:w?Array.from(H().subarray(Number(w)>>>0,Number(y)>>>0)):[],activation:M(ba)})},847465:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"ConvTranspose\",a,{format:l?\"NHWC\":\"NCHW\",autoPad:b,dilations:Array.from(H().subarray(Number(c)>>>0,(Number(c)>>>0)+2>>>0)),group:d,kernelShape:Array.from(H().subarray(Number(f)>>>0,(Number(f)>>>0)+\n2>>>0)),pads:Array.from(H().subarray(Number(g)>>>0,(Number(g)>>>0)+4>>>0)),strides:Array.from(H().subarray(Number(h)>>>0,(Number(h)>>>0)+2>>>0)),wIsConst:()=>!!D()[m>>>0],outputPadding:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],outputShape:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[],activation:M(y)})},848126:(a,b)=>{e.kb(\"GlobalAveragePool\",a,{format:b?\"NHWC\":\"NCHW\"})},848217:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"AveragePool\",a,{format:y?\"NHWC\":\"NCHW\",auto_pad:b,ceil_mode:c,\ncount_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},848696:(a,b)=>{e.kb(\"GlobalAveragePool\",a,{format:b?\"NHWC\":\"NCHW\"})},848787:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"AveragePool\",a,{format:y?\"NHWC\":\"NCHW\",auto_pad:b,ceil_mode:c,count_include_pad:d,\nstorage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},849266:(a,b)=>{e.kb(\"GlobalMaxPool\",a,{format:b?\"NHWC\":\"NCHW\"})},849353:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"MaxPool\",a,{format:y?\"NHWC\":\"NCHW\",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g?\nArray.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},849828:(a,b)=>{e.kb(\"GlobalMaxPool\",a,{format:b?\"NHWC\":\"NCHW\"})},849915:(a,b,c,d,f,g,h,l,m,p,r,u,w,y)=>{e.kb(\"MaxPool\",a,{format:y?\"NHWC\":\"NCHW\",auto_pad:b,ceil_mode:c,count_include_pad:d,storage_order:f,dilations:g?Array.from(H().subarray(Number(g)>>>\n0,Number(h)>>>0)):[],kernel_shape:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],pads:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],strides:u?Array.from(H().subarray(Number(u)>>>0,Number(w)>>>0)):[]})},850390:(a,b,c,d,f)=>{e.kb(\"Gemm\",a,{alpha:b,beta:c,transA:d,transB:f})},850494:a=>{e.kb(\"MatMul\",a,void 0)},850548:(a,b,c,d)=>{e.kb(\"ArgMax\",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},850656:(a,b,c,d)=>{e.kb(\"ArgMin\",a,{keepDims:!!b,selectLastIndex:!!c,axis:d})},850764:(a,\nb)=>{e.kb(\"Softmax\",a,{axis:b})},850827:(a,b)=>{e.kb(\"Concat\",a,{axis:b})},850887:(a,b,c,d,f)=>{e.kb(\"Split\",a,{axis:b,numOutputs:c,splitSizes:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},851043:a=>{e.kb(\"Expand\",a,void 0)},851097:(a,b)=>{e.kb(\"Gather\",a,{axis:Number(b)})},851168:(a,b)=>{e.kb(\"GatherElements\",a,{axis:Number(b)})},851247:(a,b)=>{e.kb(\"GatherND\",a,{batch_dims:Number(b)})},851326:(a,b,c,d,f,g,h,l,m,p,r)=>{e.kb(\"Resize\",a,{antialias:b,axes:c?Array.from(H().subarray(Number(c)>>>\n0,Number(d)>>>0)):[],coordinateTransformMode:M(f),cubicCoeffA:g,excludeOutside:h,extrapolationValue:l,keepAspectRatioPolicy:M(m),mode:M(p),nearestMode:M(r)})},851688:(a,b,c,d,f,g,h)=>{e.kb(\"Slice\",a,{starts:b?Array.from(H().subarray(Number(b)>>>0,Number(c)>>>0)):[],ends:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[],axes:g?Array.from(H().subarray(Number(g)>>>0,Number(h)>>>0)):[]})},851952:a=>{e.kb(\"Tile\",a,void 0)},852004:(a,b,c)=>{e.kb(\"InstanceNormalization\",a,{epsilon:b,format:c?\"NHWC\":\n\"NCHW\"})},852118:(a,b,c)=>{e.kb(\"InstanceNormalization\",a,{epsilon:b,format:c?\"NHWC\":\"NCHW\"})},852232:a=>{e.kb(\"Range\",a,void 0)},852285:(a,b)=>{e.kb(\"Einsum\",a,{equation:M(b)})},852366:(a,b,c,d,f)=>{e.kb(\"Pad\",a,{mode:b,value:c,pads:d?Array.from(H().subarray(Number(d)>>>0,Number(f)>>>0)):[]})},852509:(a,b,c,d,f,g)=>{e.kb(\"BatchNormalization\",a,{epsilon:b,momentum:c,spatial:!!f,trainingMode:!!d,format:g?\"NHWC\":\"NCHW\"})},852678:(a,b,c,d,f,g)=>{e.kb(\"BatchNormalization\",a,{epsilon:b,momentum:c,spatial:!!f,\ntrainingMode:!!d,format:g?\"NHWC\":\"NCHW\"})},852847:(a,b,c)=>{e.kb(\"CumSum\",a,{exclusive:Number(b),reverse:Number(c)})},852944:(a,b,c)=>{e.kb(\"DequantizeLinear\",a,{axis:b,blockSize:c})},853034:(a,b,c,d,f)=>{e.kb(\"GridSample\",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?\"NHWC\":\"NCHW\"})},853204:(a,b,c,d,f)=>{e.kb(\"GridSample\",a,{align_corners:b,mode:M(c),padding_mode:M(d),format:f?\"NHWC\":\"NCHW\"})},853374:(a,b)=>{e.kb(\"ScatterND\",a,{reduction:M(b)})},853459:(a,b,c,d,f,g,h,l,m)=>{e.kb(\"Attention\",\na,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g,qkvHiddenSizes:h?Array.from(H().subarray(Number(l)>>>0,Number(l)+h>>>0)):[],pastPresentShareBuffer:!!m})},853731:a=>{e.kb(\"BiasAdd\",a,void 0)},853786:a=>{e.kb(\"BiasSplitGelu\",a,void 0)},853847:a=>{e.kb(\"FastGelu\",a,void 0)},853903:(a,b,c,d,f,g,h,l,m,p,r,u,w,y,ba,Wd)=>{e.kb(\"Conv\",a,{format:u?\"NHWC\":\"NCHW\",auto_pad:b,dilations:c?Array.from(H().subarray(Number(c)>>>0,Number(d)>>>0)):[],group:f,kernel_shape:g?Array.from(H().subarray(Number(g)>>>\n0,Number(h)>>>0)):[],pads:l?Array.from(H().subarray(Number(l)>>>0,Number(m)>>>0)):[],strides:p?Array.from(H().subarray(Number(p)>>>0,Number(r)>>>0)):[],w_is_const:()=>!!D()[Number(w)>>>0],activation:M(y),activation_params:ba?Array.from(Ga().subarray(Number(ba)>>>0,Number(Wd)>>>0)):[]})},854487:a=>{e.kb(\"Gelu\",a,void 0)},854539:(a,b,c,d,f,g,h,l,m)=>{e.kb(\"GroupQueryAttention\",a,{numHeads:b,kvNumHeads:c,scale:d,softcap:f,doRotary:g,rotaryInterleaved:h,smoothSoftmax:l,localWindowSize:m})},854756:(a,\nb,c,d)=>{e.kb(\"LayerNormalization\",a,{axis:b,epsilon:c,simplified:!!d})},854867:(a,b,c,d)=>{e.kb(\"LayerNormalization\",a,{axis:b,epsilon:c,simplified:!!d})},854978:(a,b,c,d,f,g)=>{e.kb(\"MatMulNBits\",a,{k:b,n:c,accuracyLevel:d,bits:f,blockSize:g})},855105:(a,b,c,d,f,g)=>{e.kb(\"MultiHeadAttention\",a,{numHeads:b,isUnidirectional:c,maskFilterValue:d,scale:f,doRotary:g})},855264:(a,b)=>{e.kb(\"QuickGelu\",a,{alpha:b})},855328:(a,b,c,d,f)=>{e.kb(\"RotaryEmbedding\",a,{interleaved:!!b,numHeads:c,rotaryEmbeddingDim:d,\nscale:f})},855467:(a,b,c)=>{e.kb(\"SkipLayerNormalization\",a,{epsilon:b,simplified:!!c})},855569:(a,b,c)=>{e.kb(\"SkipLayerNormalization\",a,{epsilon:b,simplified:!!c})},855671:(a,b,c,d)=>{e.kb(\"GatherBlockQuantized\",a,{gatherAxis:b,quantizeAxis:c,blockSize:d})},855792:a=>{e.$b(a)},855826:(a,b)=>e.bc(Number(a),Number(b),e.Gb.ec,e.Gb.errors)};function db(a,b,c){return Fc(async()=>{await e.Yb(Number(a),Number(b),Number(c))})}function cb(){return\"undefined\"!==typeof wasmOffsetConverter}\nclass Gc{name=\"ExitStatus\";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}}\nvar Hc=a=>{a.terminate();a.onmessage=()=>{}},Ic=[],Mc=a=>{0==N.length&&(Jc(),Kc(N[0]));var b=N.pop();if(!b)return 6;Lc.push(b);O[a.Bb]=b;b.Bb=a.Bb;var c={Cb:\"run\",hc:a.fc,Ib:a.Ib,Bb:a.Bb};n&&b.unref();b.postMessage(c,a.Nb);return 0},P=0,Q=(a,b,...c)=>{for(var d=2*c.length,f=Nc(),g=Oc(8*d),h=g>>>3,l=0;l<c.length;l++){var m=c[l];\"bigint\"==typeof m?(C[h+2*l]=1n,C[h+2*l+1]=m):(C[h+2*l]=0n,J()[h+2*l+1>>>0]=m)}a=Pc(a,0,d,g,b);Qc(f);return a};\nfunction Dc(a){if(q)return Q(0,1,a);wa=a;if(!(0<P)){for(var b of Lc)Hc(b);for(b of N)Hc(b);N=[];Lc=[];O={};A=!0}ma(a,new Gc(a))}function Rc(a){if(q)return Q(1,0,a);yc(a)}var yc=a=>{wa=a;if(q)throw Rc(a),\"unwind\";Dc(a)},N=[],Lc=[],Sc=[],O={};function Tc(){for(var a=e.numThreads-1;a--;)Jc();Ic.unshift(()=>{Ua++;Uc(()=>Wa())})}var Wc=a=>{var b=a.Bb;delete O[b];N.push(a);Lc.splice(Lc.indexOf(a),1);a.Bb=0;Vc(b)};function Na(){Sc.forEach(a=>a())}\nvar Kc=a=>new Promise(b=>{a.onmessage=g=>{g=g.data;var h=g.Cb;if(g.Hb&&g.Hb!=Ka()){var l=O[g.Hb];l?l.postMessage(g,g.Nb):x(`Internal error! Worker sent a message \"${h}\" to target pthread ${g.Hb}, but that thread no longer exists!`)}else if(\"checkMailbox\"===h)Ra();else if(\"spawnThread\"===h)Mc(g);else if(\"cleanupThread\"===h)Wc(O[g.ic]);else if(\"loaded\"===h)a.loaded=!0,n&&!a.Bb&&a.unref(),b(a);else if(\"alert\"===h)alert(`Thread ${g.jc}: ${g.text}`);else if(\"setimmediate\"===g.target)a.postMessage(g);else if(\"callHandler\"===\nh)e[g.Rb](...g.args);else h&&x(`worker sent an unknown command ${h}`)};a.onerror=g=>{x(`${\"worker sent an error!\"} ${g.filename}:${g.lineno}: ${g.message}`);throw g;};n&&(a.on(\"message\",g=>a.onmessage({data:g})),a.on(\"error\",g=>a.onerror(g)));var c=[],d=[],f;for(f of d)e.propertyIsEnumerable(f)&&c.push(f);a.postMessage({Cb:\"load\",Sb:c,lc:z,mc:va})});function Uc(a){q?a():Promise.all(N.map(Kc)).then(a)}\nfunction Jc(){var a=new Worker(new URL(import.meta.url),{type:\"module\",workerData:\"em-pthread\",name:\"em-pthread\"});N.push(a)}var La=a=>{E();var b=I()[a+52>>>2>>>0];a=I()[a+56>>>2>>>0];Xc(b,b-a);Qc(b)},Qa=(a,b)=>{P=0;a=Yc(a,b);0<P?wa=a:Zc(a)};class $c{constructor(a){this.Jb=a-24}}var ad=0,bd=0;function eb(a,b,c){a>>>=0;var d=new $c(a);b>>>=0;c>>>=0;I()[d.Jb+16>>>2>>>0]=0;I()[d.Jb+4>>>2>>>0]=b;I()[d.Jb+8>>>2>>>0]=c;ad=a;bd++;throw ad;}\nfunction cd(a,b,c,d){return q?Q(2,1,a,b,c,d):fb(a,b,c,d)}function fb(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;if(\"undefined\"==typeof SharedArrayBuffer)return 6;var f=[];if(q&&0===f.length)return cd(a,b,c,d);a={fc:c,Bb:a,Ib:d,Nb:f};return q?(a.Cb=\"spawnThread\",postMessage(a,f),0):Mc(a)}\nvar dd=\"undefined\"!=typeof TextDecoder?new TextDecoder:void 0,ed=(a,b=0,c=NaN)=>{b>>>=0;var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16<c-b&&a.buffer&&dd)return dd.decode(a.buffer instanceof ArrayBuffer?a.subarray(b,c):a.slice(b,c));for(d=\"\";b<c;){var f=a[b++];if(f&128){var g=a[b++]&63;if(192==(f&224))d+=String.fromCharCode((f&31)<<6|g);else{var h=a[b++]&63;f=224==(f&240)?(f&15)<<12|g<<6|h:(f&7)<<18|g<<12|h<<6|a[b++]&63;65536>f?d+=String.fromCharCode(f):(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|\nf&1023))}}else d+=String.fromCharCode(f)}return d},M=(a,b)=>(a>>>=0)?ed(F(),a,b):\"\";function gb(a,b,c){return q?Q(3,1,a,b,c):0}function hb(a,b){if(q)return Q(4,1,a,b)}\nvar fd=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b},gd=(a,b,c)=>{var d=F();b>>>=0;if(0<c){var f=b;c=b+c-1;for(var g=0;g<a.length;++g){var h=a.charCodeAt(g);if(55296<=h&&57343>=h){var l=a.charCodeAt(++g);h=65536+((h&1023)<<10)|l&1023}if(127>=h){if(b>=c)break;d[b++>>>0]=h}else{if(2047>=h){if(b+1>=c)break;d[b++>>>0]=192|h>>6}else{if(65535>=h){if(b+2>=c)break;d[b++>>>0]=224|h>>12}else{if(b+3>=c)break;d[b++>>>0]=240|h>>18;\nd[b++>>>0]=128|h>>12&63}d[b++>>>0]=128|h>>6&63}d[b++>>>0]=128|h&63}}d[b>>>0]=0;a=b-f}else a=0;return a};function ib(a,b){if(q)return Q(5,1,a,b)}function jb(a,b,c){if(q)return Q(6,1,a,b,c)}function kb(a,b,c){return q?Q(7,1,a,b,c):0}function lb(a,b){if(q)return Q(8,1,a,b)}function mb(a,b,c){if(q)return Q(9,1,a,b,c)}function nb(a,b,c,d){if(q)return Q(10,1,a,b,c,d)}function ob(a,b,c,d){if(q)return Q(11,1,a,b,c,d)}function pb(a,b,c,d){if(q)return Q(12,1,a,b,c,d)}function qb(a){if(q)return Q(13,1,a)}\nfunction rb(a,b){if(q)return Q(14,1,a,b)}function sb(a,b,c){if(q)return Q(15,1,a,b,c)}var tb=()=>L(\"\"),hd,R=a=>{for(var b=\"\";F()[a>>>0];)b+=hd[F()[a++>>>0]];return b},jd={},kd={},ld={},S;function md(a,b,c={}){var d=b.name;if(!a)throw new S(`type \"${d}\" must have a positive integer typeid pointer`);if(kd.hasOwnProperty(a)){if(c.Tb)return;throw new S(`Cannot register type '${d}' twice`);}kd[a]=b;delete ld[a];jd.hasOwnProperty(a)&&(b=jd[a],delete jd[a],b.forEach(f=>f()))}\nfunction T(a,b,c={}){return md(a,b,c)}var nd=(a,b,c)=>{switch(b){case 1:return c?d=>D()[d>>>0]:d=>F()[d>>>0];case 2:return c?d=>G()[d>>>1>>>0]:d=>Fa()[d>>>1>>>0];case 4:return c?d=>H()[d>>>2>>>0]:d=>I()[d>>>2>>>0];case 8:return c?d=>C[d>>>3]:d=>Da[d>>>3];default:throw new TypeError(`invalid integer width (${b}): ${a}`);}};\nfunction ub(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:function(d,f){if(\"bigint\"!=typeof f&&\"number\"!=typeof f)throw null===f?f=\"null\":(d=typeof f,f=\"object\"===d||\"array\"===d||\"function\"===d?f.toString():\"\"+f),new TypeError(`Cannot convert \"${f}\" to ${this.name}`);\"number\"==typeof f&&(f=BigInt(f));return f},Db:U,readValueFromPointer:nd(b,c,-1==b.indexOf(\"u\")),Eb:null})}var U=8;\nfunction vb(a,b,c,d){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(f){return!!f},toWireType:function(f,g){return g?c:d},Db:U,readValueFromPointer:function(f){return this.fromWireType(F()[f>>>0])},Eb:null})}var od=[],V=[];function Ob(a){a>>>=0;9<a&&0===--V[a+1]&&(V[a]=void 0,od.push(a))}\nvar W=a=>{if(!a)throw new S(\"Cannot use deleted val. handle = \"+a);return V[a]},X=a=>{switch(a){case void 0:return 2;case null:return 4;case !0:return 6;case !1:return 8;default:const b=od.pop()||V.length;V[b]=a;V[b+1]=1;return b}};function pd(a){return this.fromWireType(I()[a>>>2>>>0])}var qd={name:\"emscripten::val\",fromWireType:a=>{var b=W(a);Ob(a);return b},toWireType:(a,b)=>X(b),Db:U,readValueFromPointer:pd,Eb:null};function wb(a){return T(a>>>0,qd)}\nvar rd=(a,b)=>{switch(b){case 4:return function(c){return this.fromWireType(Ga()[c>>>2>>>0])};case 8:return function(c){return this.fromWireType(J()[c>>>3>>>0])};default:throw new TypeError(`invalid float width (${b}): ${a}`);}};function xb(a,b,c){a>>>=0;c>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:d=>d,toWireType:(d,f)=>f,Db:U,readValueFromPointer:rd(b,c),Eb:null})}\nfunction yb(a,b,c,d,f){a>>>=0;c>>>=0;b=R(b>>>0);-1===f&&(f=4294967295);f=l=>l;if(0===d){var g=32-8*c;f=l=>l<<g>>>g}var h=b.includes(\"unsigned\")?function(l,m){return m>>>0}:function(l,m){return m};T(a,{name:b,fromWireType:f,toWireType:h,Db:U,readValueFromPointer:nd(b,c,0!==d),Eb:null})}\nfunction zb(a,b,c){function d(g){var h=I()[g>>>2>>>0];g=I()[g+4>>>2>>>0];return new f(D().buffer,g,h)}a>>>=0;var f=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array,BigInt64Array,BigUint64Array][b];c=R(c>>>0);T(a,{name:c,fromWireType:d,Db:U,readValueFromPointer:d},{Tb:!0})}\nfunction Ab(a,b){a>>>=0;b=R(b>>>0);T(a,{name:b,fromWireType:function(c){for(var d=I()[c>>>2>>>0],f=c+4,g,h=f,l=0;l<=d;++l){var m=f+l;if(l==d||0==F()[m>>>0])h=M(h,m-h),void 0===g?g=h:(g+=String.fromCharCode(0),g+=h),h=m+1}Y(c);return g},toWireType:function(c,d){d instanceof ArrayBuffer&&(d=new Uint8Array(d));var f=\"string\"==typeof d;if(!(f||d instanceof Uint8Array||d instanceof Uint8ClampedArray||d instanceof Int8Array))throw new S(\"Cannot pass non-string to std::string\");var g=f?fd(d):d.length;var h=\nsd(4+g+1),l=h+4;I()[h>>>2>>>0]=g;if(f)gd(d,l,g+1);else if(f)for(f=0;f<g;++f){var m=d.charCodeAt(f);if(255<m)throw Y(h),new S(\"String has UTF-16 code units that do not fit in 8 bits\");F()[l+f>>>0]=m}else for(f=0;f<g;++f)F()[l+f>>>0]=d[f];null!==c&&c.push(Y,h);return h},Db:U,readValueFromPointer:pd,Eb(c){Y(c)}})}\nvar td=\"undefined\"!=typeof TextDecoder?new TextDecoder(\"utf-16le\"):void 0,ud=(a,b)=>{var c=a>>1;for(var d=c+b/2;!(c>=d)&&Fa()[c>>>0];)++c;c<<=1;if(32<c-a&&td)return td.decode(F().slice(a,c));c=\"\";for(d=0;!(d>=b/2);++d){var f=G()[a+2*d>>>1>>>0];if(0==f)break;c+=String.fromCharCode(f)}return c},vd=(a,b,c)=>{c??=2147483647;if(2>c)return 0;c-=2;var d=b;c=c<2*a.length?c/2:a.length;for(var f=0;f<c;++f){var g=a.charCodeAt(f);G()[b>>>1>>>0]=g;b+=2}G()[b>>>1>>>0]=0;return b-d},wd=a=>2*a.length,xd=(a,b)=>{for(var c=\n0,d=\"\";!(c>=b/4);){var f=H()[a+4*c>>>2>>>0];if(0==f)break;++c;65536<=f?(f-=65536,d+=String.fromCharCode(55296|f>>10,56320|f&1023)):d+=String.fromCharCode(f)}return d},yd=(a,b,c)=>{b>>>=0;c??=2147483647;if(4>c)return 0;var d=b;c=d+c-4;for(var f=0;f<a.length;++f){var g=a.charCodeAt(f);if(55296<=g&&57343>=g){var h=a.charCodeAt(++f);g=65536+((g&1023)<<10)|h&1023}H()[b>>>2>>>0]=g;b+=4;if(b+4>c)break}H()[b>>>2>>>0]=0;return b-d},zd=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);55296<=d&&57343>=\nd&&++c;b+=4}return b};\nfunction Bb(a,b,c){a>>>=0;b>>>=0;c>>>=0;c=R(c);if(2===b){var d=ud;var f=vd;var g=wd;var h=l=>Fa()[l>>>1>>>0]}else 4===b&&(d=xd,f=yd,g=zd,h=l=>I()[l>>>2>>>0]);T(a,{name:c,fromWireType:l=>{for(var m=I()[l>>>2>>>0],p,r=l+4,u=0;u<=m;++u){var w=l+4+u*b;if(u==m||0==h(w))r=d(r,w-r),void 0===p?p=r:(p+=String.fromCharCode(0),p+=r),r=w+b}Y(l);return p},toWireType:(l,m)=>{if(\"string\"!=typeof m)throw new S(`Cannot pass non-string to C++ string type ${c}`);var p=g(m),r=sd(4+p+b);I()[r>>>2>>>0]=p/b;f(m,r+4,p+b);\nnull!==l&&l.push(Y,r);return r},Db:U,readValueFromPointer:pd,Eb(l){Y(l)}})}function Cb(a,b){a>>>=0;b=R(b>>>0);T(a,{Ub:!0,name:b,Db:0,fromWireType:()=>{},toWireType:()=>{}})}function Db(a){Ma(a>>>0,!k,1,!ea,131072,!1);Na()}var Ad=a=>{if(!A)try{if(a(),!(0<P))try{q?Zc(wa):yc(wa)}catch(b){b instanceof Gc||\"unwind\"==b||ma(1,b)}}catch(b){b instanceof Gc||\"unwind\"==b||ma(1,b)}};\nfunction Oa(a){a>>>=0;\"function\"===typeof Atomics.kc&&(Atomics.kc(H(),a>>>2,a).value.then(Ra),a+=128,Atomics.store(H(),a>>>2,1))}var Ra=()=>{var a=Ka();a&&(Oa(a),Ad(Bd))};function Eb(a,b){a>>>=0;a==b>>>0?setTimeout(Ra):q?postMessage({Hb:a,Cb:\"checkMailbox\"}):(a=O[a])&&a.postMessage({Cb:\"checkMailbox\"})}var Cd=[];function Fb(a,b,c,d,f){b>>>=0;d/=2;Cd.length=d;c=f>>>0>>>3;for(f=0;f<d;f++)Cd[f]=C[c+2*f]?C[c+2*f+1]:J()[c+2*f+1>>>0];return(b?Ec[b]:Dd[a])(...Cd)}var Gb=()=>{P=0};\nfunction Hb(a){a>>>=0;q?postMessage({Cb:\"cleanupThread\",ic:a}):Wc(O[a])}function Ib(a){n&&O[a>>>0].ref()}var Fd=(a,b)=>{var c=kd[a];if(void 0===c)throw a=Ed(a),c=R(a),Y(a),new S(`${b} has unknown type ${c}`);return c},Gd=(a,b,c)=>{var d=[];a=a.toWireType(d,c);d.length&&(I()[b>>>2>>>0]=X(d));return a};function Jb(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=Fd(b,\"emval::as\");return Gd(b,c,a)}function Kb(a,b){b>>>=0;a=W(a>>>0);b=Fd(b,\"emval::as\");return b.toWireType(null,a)}var Hd=a=>{try{a()}catch(b){L(b)}};\nfunction Id(){var a=K,b={};for(let [c,d]of Object.entries(a))b[c]=\"function\"==typeof d?(...f)=>{Jd.push(c);try{return d(...f)}finally{A||(Jd.pop(),t&&1===Z&&0===Jd.length&&(Z=0,P+=1,Hd(Kd),\"undefined\"!=typeof Fibers&&Fibers.sc()))}}:d;return b}var Z=0,t=null,Ld=0,Jd=[],Md={},Nd={},Od=0,Pd=null,Qd=[];function ia(){return new Promise((a,b)=>{Pd={resolve:a,reject:b}})}\nfunction Rd(){var a=sd(65548),b=a+12;I()[a>>>2>>>0]=b;I()[a+4>>>2>>>0]=b+65536;b=Jd[0];var c=Md[b];void 0===c&&(c=Od++,Md[b]=c,Nd[c]=b);b=c;H()[a+8>>>2>>>0]=b;return a}function Sd(){var a=H()[t+8>>>2>>>0];a=K[Nd[a]];--P;return a()}\nfunction Td(a){if(!A){if(0===Z){var b=!1,c=!1;a((d=0)=>{if(!A&&(Ld=d,b=!0,c)){Z=2;Hd(()=>Ud(t));\"undefined\"!=typeof MainLoop&&MainLoop.Qb&&MainLoop.resume();d=!1;try{var f=Sd()}catch(l){f=l,d=!0}var g=!1;if(!t){var h=Pd;h&&(Pd=null,(d?h.reject:h.resolve)(f),g=!0)}if(d&&!g)throw f;}});c=!0;b||(Z=1,t=Rd(),\"undefined\"!=typeof MainLoop&&MainLoop.Qb&&MainLoop.pause(),Hd(()=>Vd(t)))}else 2===Z?(Z=0,Hd(Xd),Y(t),t=null,Qd.forEach(Ad)):L(`invalid state: ${Z}`);return Ld}}\nfunction Fc(a){return Td(b=>{a().then(b)})}function Lb(a){a>>>=0;return Fc(async()=>{var b=await W(a);return X(b)})}var Yd=[];function Mb(a,b,c,d){c>>>=0;d>>>=0;a=Yd[a>>>0];b=W(b>>>0);return a(null,b,c,d)}var Zd={},$d=a=>{var b=Zd[a];return void 0===b?R(a):b};function Nb(a,b,c,d,f){c>>>=0;d>>>=0;f>>>=0;a=Yd[a>>>0];b=W(b>>>0);c=$d(c);return a(b,b[c],d,f)}function Pb(a,b){b>>>=0;a=W(a>>>0);b=W(b);return a==b}var ae=()=>\"object\"==typeof globalThis?globalThis:Function(\"return this\")();\nfunction Qb(a){a>>>=0;if(0===a)return X(ae());a=$d(a);return X(ae()[a])}var be=a=>{var b=Yd.length;Yd.push(a);return b},ce=(a,b)=>{for(var c=Array(a),d=0;d<a;++d)c[d]=Fd(I()[b+4*d>>>2>>>0],\"parameter \"+d);return c},de=(a,b)=>Object.defineProperty(b,\"name\",{value:a});\nfunction ee(a){var b=Function;if(!(b instanceof Function))throw new TypeError(`new_ called with constructor type ${typeof b} which is not a function`);var c=de(b.name||\"unknownFunctionName\",function(){});c.prototype=b.prototype;c=new c;a=b.apply(c,a);return a instanceof Object?a:c}\nfunction Rb(a,b,c){b=ce(a,b>>>0);var d=b.shift();a--;var f=\"return function (obj, func, destructorsRef, args) {\\n\",g=0,h=[];0===c&&h.push(\"obj\");for(var l=[\"retType\"],m=[d],p=0;p<a;++p)h.push(\"arg\"+p),l.push(\"argType\"+p),m.push(b[p]),f+=`  var arg${p} = argType${p}.readValueFromPointer(args${g?\"+\"+g:\"\"});\\n`,g+=b[p].Db;f+=`  var rv = ${1===c?\"new func\":\"func.call\"}(${h.join(\", \")});\\n`;d.Ub||(l.push(\"emval_returnValue\"),m.push(Gd),f+=\"  return emval_returnValue(retType, destructorsRef, rv);\\n\");l.push(f+\n\"};\\n\");a=ee(l)(...m);c=`methodCaller<(${b.map(r=>r.name).join(\", \")}) => ${d.name}>`;return be(de(c,a))}function Sb(a){a=$d(a>>>0);return X(e[a])}function Tb(a,b){b>>>=0;a=W(a>>>0);b=W(b);return X(a[b])}function Ub(a){a>>>=0;9<a&&(V[a+1]+=1)}function Vb(){return X([])}function Wb(a){a=W(a>>>0);for(var b=Array(a.length),c=0;c<a.length;c++)b[c]=a[c];return X(b)}function Xb(a){return X($d(a>>>0))}function Yb(){return X({})}\nfunction Zb(a){a>>>=0;for(var b=W(a);b.length;){var c=b.pop();b.pop()(c)}Ob(a)}function $b(a,b,c){b>>>=0;c>>>=0;a=W(a>>>0);b=W(b);c=W(c);a[b]=c}function ac(a,b){b>>>=0;a=Fd(a>>>0,\"_emval_take_value\");a=a.readValueFromPointer(b);return X(a)}\nfunction bc(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getUTCSeconds();H()[b+4>>>2>>>0]=a.getUTCMinutes();H()[b+8>>>2>>>0]=a.getUTCHours();H()[b+12>>>2>>>0]=a.getUTCDate();H()[b+16>>>2>>>0]=a.getUTCMonth();H()[b+20>>>2>>>0]=a.getUTCFullYear()-1900;H()[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;H()[b+28>>>2>>>0]=a}\nvar fe=a=>0===a%4&&(0!==a%100||0===a%400),ge=[0,31,60,91,121,152,182,213,244,274,305,335],he=[0,31,59,90,120,151,181,212,243,273,304,334];\nfunction cc(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);H()[b>>>2>>>0]=a.getSeconds();H()[b+4>>>2>>>0]=a.getMinutes();H()[b+8>>>2>>>0]=a.getHours();H()[b+12>>>2>>>0]=a.getDate();H()[b+16>>>2>>>0]=a.getMonth();H()[b+20>>>2>>>0]=a.getFullYear()-1900;H()[b+24>>>2>>>0]=a.getDay();var c=(fe(a.getFullYear())?ge:he)[a.getMonth()]+a.getDate()-1|0;H()[b+28>>>2>>>0]=c;H()[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset();\nvar d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0;H()[b+32>>>2>>>0]=a}\nfunction dc(a){a>>>=0;var b=new Date(H()[a+20>>>2>>>0]+1900,H()[a+16>>>2>>>0],H()[a+12>>>2>>>0],H()[a+8>>>2>>>0],H()[a+4>>>2>>>0],H()[a>>>2>>>0],0),c=H()[a+32>>>2>>>0],d=b.getTimezoneOffset(),f=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),h=Math.min(g,f);0>c?H()[a+32>>>2>>>0]=Number(f!=g&&h==d):0<c!=(h==d)&&(f=Math.max(g,f),b.setTime(b.getTime()+6E4*((0<c?h:f)-d)));H()[a+24>>>2>>>0]=b.getDay();c=(fe(b.getFullYear())?ge:he)[b.getMonth()]+\nb.getDate()-1|0;H()[a+28>>>2>>>0]=c;H()[a>>>2>>>0]=b.getSeconds();H()[a+4>>>2>>>0]=b.getMinutes();H()[a+8>>>2>>>0]=b.getHours();H()[a+12>>>2>>>0]=b.getDate();H()[a+16>>>2>>>0]=b.getMonth();H()[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function ec(a,b,c,d,f,g,h){return q?Q(16,1,a,b,c,d,f,g,h):-52}function fc(a,b,c,d,f,g){if(q)return Q(17,1,a,b,c,d,f,g)}var ie={},qc=()=>performance.timeOrigin+performance.now();\nfunction gc(a,b){if(q)return Q(18,1,a,b);ie[a]&&(clearTimeout(ie[a].id),delete ie[a]);if(!b)return 0;var c=setTimeout(()=>{delete ie[a];Ad(()=>je(a,performance.timeOrigin+performance.now()))},b);ie[a]={id:c,rc:b};return 0}\nfunction hc(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;var f=(new Date).getFullYear(),g=(new Date(f,0,1)).getTimezoneOffset();f=(new Date(f,6,1)).getTimezoneOffset();var h=Math.max(g,f);I()[a>>>2>>>0]=60*h;H()[b>>>2>>>0]=Number(g!=f);b=l=>{var m=Math.abs(l);return`UTC${0<=l?\"-\":\"+\"}${String(Math.floor(m/60)).padStart(2,\"0\")}${String(m%60).padStart(2,\"0\")}`};a=b(g);b=b(f);f<g?(gd(a,c,17),gd(b,d,17)):(gd(a,d,17),gd(b,c,17))}var mc=()=>Date.now(),ke=1;\nfunction ic(a,b,c){if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(ke)a=performance.timeOrigin+performance.now();else return 52;C[c>>>0>>>3]=BigInt(Math.round(1E6*a));return 0}var le=[],me=(a,b)=>{le.length=0;for(var c;c=F()[a++>>>0];){var d=105!=c;d&=112!=c;b+=d&&b%8?4:0;le.push(112==c?I()[b>>>2>>>0]:106==c?C[b>>>3]:105==c?H()[b>>>2>>>0]:J()[b>>>3>>>0]);b+=d?8:4}return le};function jc(a,b,c){a>>>=0;b=me(b>>>0,c>>>0);return Ec[a](...b)}\nfunction kc(a,b,c){a>>>=0;b=me(b>>>0,c>>>0);return Ec[a](...b)}var lc=()=>{};function nc(a,b){return x(M(a>>>0,b>>>0))}var oc=()=>{P+=1;throw\"unwind\";};function pc(){return 4294901760}var rc=()=>n?require(\"os\").cpus().length:navigator.hardwareConcurrency;function sc(){L(\"Cannot use emscripten_pc_get_function without -sUSE_OFFSET_CONVERTER\");return 0}\nfunction tc(a){a>>>=0;var b=F().length;if(a<=b||4294901760<a)return!1;for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(4294901760,65536*Math.ceil(Math.max(a,d)/65536))-z.buffer.byteLength+65535)/65536|0;try{z.grow(d);E();var f=1;break a}catch(g){}f=void 0}if(f)return!0}return!1}var ne=()=>{L(\"Cannot use convertFrameToPC (needed by __builtin_return_address) without -sUSE_OFFSET_CONVERTER\");return 0},oe={},pe=a=>{a.forEach(b=>{var c=ne();c&&(oe[c]=b)})};\nfunction uc(){var a=Error().stack.toString().split(\"\\n\");\"Error\"==a[0]&&a.shift();pe(a);oe.Mb=ne();oe.dc=a;return oe.Mb}function vc(a,b,c){a>>>=0;b>>>=0;if(oe.Mb==a)var d=oe.dc;else d=Error().stack.toString().split(\"\\n\"),\"Error\"==d[0]&&d.shift(),pe(d);for(var f=3;d[f]&&ne()!=a;)++f;for(a=0;a<c&&d[a+f];++a)H()[b+4*a>>>2>>>0]=ne();return a}\nvar qe={},se=()=>{if(!re){var a={USER:\"web_user\",LOGNAME:\"web_user\",PATH:\"/\",PWD:\"/\",HOME:\"/home/web_user\",LANG:(\"object\"==typeof navigator&&navigator.languages&&navigator.languages[0]||\"C\").replace(\"-\",\"_\")+\".UTF-8\",_:la||\"./this.program\"},b;for(b in qe)void 0===qe[b]?delete a[b]:a[b]=qe[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);re=c}return re},re;\nfunction wc(a,b){if(q)return Q(19,1,a,b);a>>>=0;b>>>=0;var c=0;se().forEach((d,f)=>{var g=b+c;f=I()[a+4*f>>>2>>>0]=g;for(g=0;g<d.length;++g)D()[f++>>>0]=d.charCodeAt(g);D()[f>>>0]=0;c+=d.length+1});return 0}function xc(a,b){if(q)return Q(20,1,a,b);a>>>=0;b>>>=0;var c=se();I()[a>>>2>>>0]=c.length;var d=0;c.forEach(f=>d+=f.length+1);I()[b>>>2>>>0]=d;return 0}function zc(a){return q?Q(21,1,a):52}function Ac(a,b,c,d){return q?Q(22,1,a,b,c,d):52}function Bc(a,b,c,d){return q?Q(23,1,a,b,c,d):70}\nvar te=[null,[],[]];function Cc(a,b,c,d){if(q)return Q(24,1,a,b,c,d);b>>>=0;c>>>=0;d>>>=0;for(var f=0,g=0;g<c;g++){var h=I()[b>>>2>>>0],l=I()[b+4>>>2>>>0];b+=8;for(var m=0;m<l;m++){var p=F()[h+m>>>0],r=te[a];0===p||10===p?((1===a?ta:x)(ed(r)),r.length=0):r.push(p)}f+=l}I()[d>>>2>>>0]=f;return 0}q||Tc();for(var ue=Array(256),ve=0;256>ve;++ve)ue[ve]=String.fromCharCode(ve);hd=ue;S=e.BindingError=class extends Error{constructor(a){super(a);this.name=\"BindingError\"}};\ne.InternalError=class extends Error{constructor(a){super(a);this.name=\"InternalError\"}};V.push(0,1,void 0,1,null,1,!0,1,!1,1);e.count_emval_handles=()=>V.length/2-5-od.length;var Dd=[Dc,Rc,cd,gb,hb,ib,jb,kb,lb,mb,nb,ob,pb,qb,rb,sb,ec,fc,gc,wc,xc,zc,Ac,Bc,Cc],bb,K;\n(async function(){function a(d,f){K=d.exports;K=Id();K=we();Sc.push(K.jb);va=f;Wa();return K}Ua++;var b=ab();if(e.instantiateWasm)return new Promise(d=>{e.instantiateWasm(b,(f,g)=>{a(f,g);d(f.exports)})});if(q)return new Promise(d=>{Ha=f=>{var g=new WebAssembly.Instance(f,ab());d(a(g,f))}});Xa??=e.locateFile?e.locateFile?e.locateFile(\"ort-wasm-simd-threaded.jsep.wasm\",v):v+\"ort-wasm-simd-threaded.jsep.wasm\":(new URL(\"ort-wasm-simd-threaded.jsep.wasm\",import.meta.url)).href;try{var c=await $a(b);\nreturn a(c.instance,c.module)}catch(d){return ca(d),Promise.reject(d)}})();var Ed=a=>(Ed=K.Ea)(a),Pa=()=>(Pa=K.Fa)();e._OrtInit=(a,b)=>(e._OrtInit=K.Ga)(a,b);e._OrtGetLastError=(a,b)=>(e._OrtGetLastError=K.Ha)(a,b);e._OrtCreateSessionOptions=(a,b,c,d,f,g,h,l,m,p)=>(e._OrtCreateSessionOptions=K.Ia)(a,b,c,d,f,g,h,l,m,p);e._OrtAppendExecutionProvider=(a,b,c,d,f)=>(e._OrtAppendExecutionProvider=K.Ja)(a,b,c,d,f);e._OrtAddFreeDimensionOverride=(a,b,c)=>(e._OrtAddFreeDimensionOverride=K.Ka)(a,b,c);\ne._OrtAddSessionConfigEntry=(a,b,c)=>(e._OrtAddSessionConfigEntry=K.La)(a,b,c);e._OrtReleaseSessionOptions=a=>(e._OrtReleaseSessionOptions=K.Ma)(a);e._OrtCreateSession=(a,b,c)=>(e._OrtCreateSession=K.Na)(a,b,c);e._OrtReleaseSession=a=>(e._OrtReleaseSession=K.Oa)(a);e._OrtGetInputOutputCount=(a,b,c)=>(e._OrtGetInputOutputCount=K.Pa)(a,b,c);e._OrtGetInputOutputMetadata=(a,b,c,d)=>(e._OrtGetInputOutputMetadata=K.Qa)(a,b,c,d);e._OrtFree=a=>(e._OrtFree=K.Ra)(a);\ne._OrtCreateTensor=(a,b,c,d,f,g)=>(e._OrtCreateTensor=K.Sa)(a,b,c,d,f,g);e._OrtGetTensorData=(a,b,c,d,f)=>(e._OrtGetTensorData=K.Ta)(a,b,c,d,f);e._OrtReleaseTensor=a=>(e._OrtReleaseTensor=K.Ua)(a);e._OrtCreateRunOptions=(a,b,c,d)=>(e._OrtCreateRunOptions=K.Va)(a,b,c,d);e._OrtAddRunConfigEntry=(a,b,c)=>(e._OrtAddRunConfigEntry=K.Wa)(a,b,c);e._OrtReleaseRunOptions=a=>(e._OrtReleaseRunOptions=K.Xa)(a);e._OrtCreateBinding=a=>(e._OrtCreateBinding=K.Ya)(a);\ne._OrtBindInput=(a,b,c)=>(e._OrtBindInput=K.Za)(a,b,c);e._OrtBindOutput=(a,b,c,d)=>(e._OrtBindOutput=K._a)(a,b,c,d);e._OrtClearBoundOutputs=a=>(e._OrtClearBoundOutputs=K.$a)(a);e._OrtReleaseBinding=a=>(e._OrtReleaseBinding=K.ab)(a);e._OrtRunWithBinding=(a,b,c,d,f)=>(e._OrtRunWithBinding=K.bb)(a,b,c,d,f);e._OrtRun=(a,b,c,d,f,g,h,l)=>(e._OrtRun=K.cb)(a,b,c,d,f,g,h,l);e._OrtEndProfiling=a=>(e._OrtEndProfiling=K.db)(a);e._JsepOutput=(a,b,c)=>(e._JsepOutput=K.eb)(a,b,c);\ne._JsepGetNodeName=a=>(e._JsepGetNodeName=K.fb)(a);\nvar Ka=()=>(Ka=K.gb)(),Y=e._free=a=>(Y=e._free=K.hb)(a),sd=e._malloc=a=>(sd=e._malloc=K.ib)(a),Ma=(a,b,c,d,f,g)=>(Ma=K.lb)(a,b,c,d,f,g),Sa=()=>(Sa=K.mb)(),Pc=(a,b,c,d,f)=>(Pc=K.nb)(a,b,c,d,f),Vc=a=>(Vc=K.ob)(a),Zc=a=>(Zc=K.pb)(a),je=(a,b)=>(je=K.qb)(a,b),Bd=()=>(Bd=K.rb)(),Xc=(a,b)=>(Xc=K.sb)(a,b),Qc=a=>(Qc=K.tb)(a),Oc=a=>(Oc=K.ub)(a),Nc=()=>(Nc=K.vb)(),Yc=e.dynCall_ii=(a,b)=>(Yc=e.dynCall_ii=K.wb)(a,b),Vd=a=>(Vd=K.xb)(a),Kd=()=>(Kd=K.yb)(),Ud=a=>(Ud=K.zb)(a),Xd=()=>(Xd=K.Ab)();\nfunction we(){var a=K;a=Object.assign({},a);var b=d=>f=>d(f)>>>0,c=d=>()=>d()>>>0;a.Ea=b(a.Ea);a.gb=c(a.gb);a.ib=b(a.ib);a.ub=b(a.ub);a.vb=c(a.vb);a.__cxa_get_exception_ptr=b(a.__cxa_get_exception_ptr);return a}e.stackSave=()=>Nc();e.stackRestore=a=>Qc(a);e.stackAlloc=a=>Oc(a);\ne.setValue=function(a,b,c=\"i8\"){c.endsWith(\"*\")&&(c=\"*\");switch(c){case \"i1\":D()[a>>>0]=b;break;case \"i8\":D()[a>>>0]=b;break;case \"i16\":G()[a>>>1>>>0]=b;break;case \"i32\":H()[a>>>2>>>0]=b;break;case \"i64\":C[a>>>3]=BigInt(b);break;case \"float\":Ga()[a>>>2>>>0]=b;break;case \"double\":J()[a>>>3>>>0]=b;break;case \"*\":I()[a>>>2>>>0]=b;break;default:L(`invalid type for setValue: ${c}`)}};\ne.getValue=function(a,b=\"i8\"){b.endsWith(\"*\")&&(b=\"*\");switch(b){case \"i1\":return D()[a>>>0];case \"i8\":return D()[a>>>0];case \"i16\":return G()[a>>>1>>>0];case \"i32\":return H()[a>>>2>>>0];case \"i64\":return C[a>>>3];case \"float\":return Ga()[a>>>2>>>0];case \"double\":return J()[a>>>3>>>0];case \"*\":return I()[a>>>2>>>0];default:L(`invalid type for getValue: ${b}`)}};e.UTF8ToString=M;e.stringToUTF8=gd;e.lengthBytesUTF8=fd;\nfunction xe(){if(0<Ua)Va=xe;else if(q)aa(e),Ta();else{for(;0<Ic.length;)Ic.shift()(e);0<Ua?Va=xe:(e.calledRun=!0,A||(Ta(),aa(e)))}}xe();e.PTR_SIZE=4;moduleRtn=da;\n\n\n  return moduleRtn;\n}\n);\n})();\nexport default ortWasmThreaded;\nvar isPthread = globalThis.self?.name?.startsWith('em-pthread');\nvar isNode = typeof globalThis.process?.versions?.node == 'string';\nif (isNode) isPthread = (await import('worker_threads')).workerData === 'em-pthread';\n\n// When running as a pthread, construct a new instance on startup\nisPthread && ortWasmThreaded();\n"
  },
  {
    "path": "app/chrome-extension/workers/ort-wasm-simd-threaded.mjs",
    "content": "var ortWasmThreaded = (() => {\n  var _scriptName = import.meta.url;\n  \n  return (\nasync function(moduleArg = {}) {\n  var moduleRtn;\n\nvar f=moduleArg,aa,ba,ca=new Promise((a,b)=>{aa=a;ba=b}),da=\"object\"==typeof window,k=\"undefined\"!=typeof WorkerGlobalScope,l=\"object\"==typeof process&&\"object\"==typeof process.versions&&\"string\"==typeof process.versions.node&&\"renderer\"!=process.type,m=k&&self.name?.startsWith(\"em-pthread\");if(l){const {createRequire:a}=await import(\"module\");var require=a(import.meta.url),n=require(\"worker_threads\");global.Worker=n.Worker;m=(k=!n.jb)&&\"em-pthread\"==n.workerData}\nf.mountExternalData=(a,b)=>{a.startsWith(\"./\")&&(a=a.substring(2));(f.Sa||(f.Sa=new Map)).set(a,b)};f.unmountExternalData=()=>{delete f.Sa};var SharedArrayBuffer=globalThis.SharedArrayBuffer??(new WebAssembly.Memory({initial:0,maximum:0,lb:!0})).buffer.constructor,ea=Object.assign({},f),fa=\"./this.program\",q=(a,b)=>{throw b;},r=\"\",ha,t;\nif(l){var fs=require(\"fs\"),ia=require(\"path\");import.meta.url.startsWith(\"data:\")||(r=ia.dirname(require(\"url\").fileURLToPath(import.meta.url))+\"/\");t=a=>{a=u(a)?new URL(a):a;return fs.readFileSync(a)};ha=async a=>{a=u(a)?new URL(a):a;return fs.readFileSync(a,void 0)};!f.thisProgram&&1<process.argv.length&&(fa=process.argv[1].replace(/\\\\/g,\"/\"));process.argv.slice(2);q=(a,b)=>{process.exitCode=a;throw b;}}else if(da||k)k?r=self.location.href:\"undefined\"!=typeof document&&document.currentScript&&\n(r=document.currentScript.src),_scriptName&&(r=_scriptName),r.startsWith(\"blob:\")?r=\"\":r=r.slice(0,r.replace(/[?#].*/,\"\").lastIndexOf(\"/\")+1),l||(k&&(t=a=>{var b=new XMLHttpRequest;b.open(\"GET\",a,!1);b.responseType=\"arraybuffer\";b.send(null);return new Uint8Array(b.response)}),ha=async a=>{if(u(a))return new Promise((c,d)=>{var e=new XMLHttpRequest;e.open(\"GET\",a,!0);e.responseType=\"arraybuffer\";e.onload=()=>{200==e.status||0==e.status&&e.response?c(e.response):d(e.status)};e.onerror=d;e.send(null)});\nvar b=await fetch(a,{credentials:\"same-origin\"});if(b.ok)return b.arrayBuffer();throw Error(b.status+\" : \"+b.url);});var ja=console.log.bind(console),ka=console.error.bind(console);l&&(ja=(...a)=>fs.writeSync(1,a.join(\" \")+\"\\n\"),ka=(...a)=>fs.writeSync(2,a.join(\" \")+\"\\n\"));var la=ja,w=ka;Object.assign(f,ea);ea=null;var x=f.wasmBinary,y,ma,z=!1,A,B,na,oa,pa,qa,ra,C,sa,u=a=>a.startsWith(\"file://\");function D(){y.buffer!=B.buffer&&E();return B}function F(){y.buffer!=B.buffer&&E();return na}\nfunction ta(){y.buffer!=B.buffer&&E();return oa}function G(){y.buffer!=B.buffer&&E();return pa}function H(){y.buffer!=B.buffer&&E();return qa}function va(){y.buffer!=B.buffer&&E();return ra}function I(){y.buffer!=B.buffer&&E();return sa}\nif(m){var wa;if(l){var xa=n.parentPort;xa.on(\"message\",b=>onmessage({data:b}));Object.assign(globalThis,{self:global,postMessage:b=>xa.postMessage(b)})}var ya=!1;w=function(...b){b=b.join(\" \");l?fs.writeSync(2,b+\"\\n\"):console.error(b)};self.alert=function(...b){postMessage({Ra:\"alert\",text:b.join(\" \"),eb:J()})};self.onunhandledrejection=b=>{throw b.reason||b;};function a(b){try{var c=b.data,d=c.Ra;if(\"load\"===d){let e=[];self.onmessage=g=>e.push(g);self.startWorker=()=>{postMessage({Ra:\"loaded\"});\nfor(let g of e)a(g);self.onmessage=a};for(const g of c.Za)if(!f[g]||f[g].proxy)f[g]=(...h)=>{postMessage({Ra:\"callHandler\",Ya:g,args:h})},\"print\"==g&&(la=f[g]),\"printErr\"==g&&(w=f[g]);y=c.gb;E();wa(c.hb)}else if(\"run\"===d){za(c.Qa);Aa(c.Qa,0,0,1,0,0);Ba();Ca(c.Qa);ya||=!0;try{Da(c.bb,c.Va)}catch(e){if(\"unwind\"!=e)throw e;}}else\"setimmediate\"!==c.target&&(\"checkMailbox\"===d?ya&&K():d&&(w(`worker: received unknown command ${d}`),w(c)))}catch(e){throw Ea(),e;}}self.onmessage=a}\nfunction E(){var a=y.buffer;f.HEAP8=B=new Int8Array(a);f.HEAP16=oa=new Int16Array(a);f.HEAPU8=na=new Uint8Array(a);f.HEAPU16=new Uint16Array(a);f.HEAP32=pa=new Int32Array(a);f.HEAPU32=qa=new Uint32Array(a);f.HEAPF32=ra=new Float32Array(a);f.HEAPF64=sa=new Float64Array(a);f.HEAP64=C=new BigInt64Array(a);f.HEAPU64=new BigUint64Array(a)}m||(y=new WebAssembly.Memory({initial:256,maximum:65536,shared:!0}),E());function Fa(){m?startWorker(f):L.$()}var M=0,N=null;\nfunction Ga(){M--;if(0==M&&N){var a=N;N=null;a()}}function O(a){a=\"Aborted(\"+a+\")\";w(a);z=!0;a=new WebAssembly.RuntimeError(a+\". Build with -sASSERTIONS for more info.\");ba(a);throw a;}var Ha;async function Ia(a){if(!x)try{var b=await ha(a);return new Uint8Array(b)}catch{}if(a==Ha&&x)a=new Uint8Array(x);else if(t)a=t(a);else throw\"both async and sync fetching of the wasm failed\";return a}\nasync function Ja(a,b){try{var c=await Ia(a);return await WebAssembly.instantiate(c,b)}catch(d){w(`failed to asynchronously prepare wasm: ${d}`),O(d)}}async function Ka(a){var b=Ha;if(!x&&\"function\"==typeof WebAssembly.instantiateStreaming&&!u(b)&&!l)try{var c=fetch(b,{credentials:\"same-origin\"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){w(`wasm streaming compile failed: ${d}`),w(\"falling back to ArrayBuffer instantiation\")}return Ja(b,a)}\nfunction La(){Ma={j:Na,b:Oa,E:Pa,f:Qa,U:Ra,A:Sa,C:Ta,V:Ua,S:Va,L:Wa,R:Xa,n:Ya,B:Za,y:$a,T:ab,z:bb,_:cb,O:db,w:eb,F:fb,t:gb,i:hb,N:Ca,X:ib,I:jb,J:kb,K:lb,G:mb,H:nb,u:ob,q:pb,Z:qb,o:rb,k:sb,Y:tb,d:ub,W:vb,x:wb,c:xb,e:yb,h:zb,v:Ab,s:Bb,r:Cb,P:Db,Q:Eb,D:Fb,g:Gb,m:Hb,M:Ib,l:Jb,a:y,p:Kb};return{a:Ma}}\nvar Mb={802156:(a,b,c,d,e)=>{if(\"undefined\"==typeof f||!f.Sa)return 1;a=Lb(Number(a>>>0));a.startsWith(\"./\")&&(a=a.substring(2));a=f.Sa.get(a);if(!a)return 2;b=Number(b>>>0);c=Number(c>>>0);d=Number(d>>>0);if(b+c>a.byteLength)return 3;try{const g=a.subarray(b,b+c);switch(e){case 0:F().set(g,d>>>0);break;case 1:f.ib?f.ib(d,g):f.kb(d,g);break;default:return 4}return 0}catch{return 4}},802980:()=>\"undefined\"!==typeof wasmOffsetConverter};function Na(){return\"undefined\"!==typeof wasmOffsetConverter}\nclass Nb{name=\"ExitStatus\";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}}\nvar Ob=a=>{a.terminate();a.onmessage=()=>{}},Pb=[],Sb=a=>{0==Q.length&&(Qb(),Rb(Q[0]));var b=Q.pop();if(!b)return 6;R.push(b);S[a.Qa]=b;b.Qa=a.Qa;var c={Ra:\"run\",bb:a.ab,Va:a.Va,Qa:a.Qa};l&&b.unref();b.postMessage(c,a.Xa);return 0},T=0,V=(a,b,...c)=>{for(var d=2*c.length,e=Tb(),g=Ub(8*d),h=g>>>3,p=0;p<c.length;p++){var v=c[p];\"bigint\"==typeof v?(C[h+2*p]=1n,C[h+2*p+1]=v):(C[h+2*p]=0n,I()[h+2*p+1>>>0]=v)}a=Vb(a,0,d,g,b);U(e);return a};\nfunction Kb(a){if(m)return V(0,1,a);A=a;if(!(0<T)){for(var b of R)Ob(b);for(b of Q)Ob(b);Q=[];R=[];S={};z=!0}q(a,new Nb(a))}function Wb(a){if(m)return V(1,0,a);Fb(a)}var Fb=a=>{A=a;if(m)throw Wb(a),\"unwind\";Kb(a)},Q=[],R=[],Xb=[],S={};function Yb(){for(var a=f.numThreads-1;a--;)Qb();Pb.unshift(()=>{M++;Zb(()=>Ga())})}var ac=a=>{var b=a.Qa;delete S[b];Q.push(a);R.splice(R.indexOf(a),1);a.Qa=0;$b(b)};function Ba(){Xb.forEach(a=>a())}\nvar Rb=a=>new Promise(b=>{a.onmessage=g=>{g=g.data;var h=g.Ra;if(g.Ta&&g.Ta!=J()){var p=S[g.Ta];p?p.postMessage(g,g.Xa):w(`Internal error! Worker sent a message \"${h}\" to target pthread ${g.Ta}, but that thread no longer exists!`)}else if(\"checkMailbox\"===h)K();else if(\"spawnThread\"===h)Sb(g);else if(\"cleanupThread\"===h)ac(S[g.cb]);else if(\"loaded\"===h)a.loaded=!0,l&&!a.Qa&&a.unref(),b(a);else if(\"alert\"===h)alert(`Thread ${g.eb}: ${g.text}`);else if(\"setimmediate\"===g.target)a.postMessage(g);else if(\"callHandler\"===\nh)f[g.Ya](...g.args);else h&&w(`worker sent an unknown command ${h}`)};a.onerror=g=>{w(`${\"worker sent an error!\"} ${g.filename}:${g.lineno}: ${g.message}`);throw g;};l&&(a.on(\"message\",g=>a.onmessage({data:g})),a.on(\"error\",g=>a.onerror(g)));var c=[],d=[],e;for(e of d)f.propertyIsEnumerable(e)&&c.push(e);a.postMessage({Ra:\"load\",Za:c,gb:y,hb:ma})});function Zb(a){m?a():Promise.all(Q.map(Rb)).then(a)}\nfunction Qb(){var a=new Worker(new URL(import.meta.url),{type:\"module\",workerData:\"em-pthread\",name:\"em-pthread\"});Q.push(a)}var za=a=>{E();var b=H()[a+52>>>2>>>0];a=H()[a+56>>>2>>>0];bc(b,b-a);U(b)},W=[],cc,Da=(a,b)=>{T=0;var c=W[a];c||(a>=W.length&&(W.length=a+1),W[a]=c=cc.get(a));a=c(b);0<T?A=a:dc(a)};class ec{constructor(a){this.Ua=a-24}}var fc=0,gc=0;\nfunction Oa(a,b,c){a>>>=0;var d=new ec(a);b>>>=0;c>>>=0;H()[d.Ua+16>>>2>>>0]=0;H()[d.Ua+4>>>2>>>0]=b;H()[d.Ua+8>>>2>>>0]=c;fc=a;gc++;throw fc;}function hc(a,b,c,d){return m?V(2,1,a,b,c,d):Pa(a,b,c,d)}function Pa(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;if(\"undefined\"==typeof SharedArrayBuffer)return 6;var e=[];if(m&&0===e.length)return hc(a,b,c,d);a={ab:c,Qa:a,Va:d,Xa:e};return m?(a.Ra=\"spawnThread\",postMessage(a,e),0):Sb(a)}\nvar ic=\"undefined\"!=typeof TextDecoder?new TextDecoder:void 0,jc=(a,b=0,c=NaN)=>{b>>>=0;var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16<c-b&&a.buffer&&ic)return ic.decode(a.buffer instanceof ArrayBuffer?a.subarray(b,c):a.slice(b,c));for(d=\"\";b<c;){var e=a[b++];if(e&128){var g=a[b++]&63;if(192==(e&224))d+=String.fromCharCode((e&31)<<6|g);else{var h=a[b++]&63;e=224==(e&240)?(e&15)<<12|g<<6|h:(e&7)<<18|g<<12|h<<6|a[b++]&63;65536>e?d+=String.fromCharCode(e):(e-=65536,d+=String.fromCharCode(55296|e>>10,56320|\ne&1023))}}else d+=String.fromCharCode(e)}return d},Lb=(a,b)=>(a>>>=0)?jc(F(),a,b):\"\";function Qa(a,b,c){return m?V(3,1,a,b,c):0}function Ra(a,b){if(m)return V(4,1,a,b)}\nvar X=(a,b,c)=>{var d=F();b>>>=0;if(0<c){var e=b;c=b+c-1;for(var g=0;g<a.length;++g){var h=a.charCodeAt(g);if(55296<=h&&57343>=h){var p=a.charCodeAt(++g);h=65536+((h&1023)<<10)|p&1023}if(127>=h){if(b>=c)break;d[b++>>>0]=h}else{if(2047>=h){if(b+1>=c)break;d[b++>>>0]=192|h>>6}else{if(65535>=h){if(b+2>=c)break;d[b++>>>0]=224|h>>12}else{if(b+3>=c)break;d[b++>>>0]=240|h>>18;d[b++>>>0]=128|h>>12&63}d[b++>>>0]=128|h>>6&63}d[b++>>>0]=128|h&63}}d[b>>>0]=0;a=b-e}else a=0;return a};\nfunction Sa(a,b){if(m)return V(5,1,a,b)}function Ta(a,b,c){if(m)return V(6,1,a,b,c)}function Ua(a,b,c){return m?V(7,1,a,b,c):0}function Va(a,b){if(m)return V(8,1,a,b)}function Wa(a,b,c){if(m)return V(9,1,a,b,c)}function Xa(a,b,c,d){if(m)return V(10,1,a,b,c,d)}function Ya(a,b,c,d){if(m)return V(11,1,a,b,c,d)}function Za(a,b,c,d){if(m)return V(12,1,a,b,c,d)}function $a(a){if(m)return V(13,1,a)}function ab(a,b){if(m)return V(14,1,a,b)}function bb(a,b,c){if(m)return V(15,1,a,b,c)}var cb=()=>O(\"\");\nfunction db(a){Aa(a>>>0,!k,1,!da,131072,!1);Ba()}var kc=a=>{if(!z)try{if(a(),!(0<T))try{m?dc(A):Fb(A)}catch(b){b instanceof Nb||\"unwind\"==b||q(1,b)}}catch(b){b instanceof Nb||\"unwind\"==b||q(1,b)}};function Ca(a){a>>>=0;\"function\"===typeof Atomics.fb&&(Atomics.fb(G(),a>>>2,a).value.then(K),a+=128,Atomics.store(G(),a>>>2,1))}var K=()=>{var a=J();a&&(Ca(a),kc(lc))};function eb(a,b){a>>>=0;a==b>>>0?setTimeout(K):m?postMessage({Ta:a,Ra:\"checkMailbox\"}):(a=S[a])&&a.postMessage({Ra:\"checkMailbox\"})}\nvar mc=[];function fb(a,b,c,d,e){b>>>=0;d/=2;mc.length=d;c=e>>>0>>>3;for(e=0;e<d;e++)mc[e]=C[c+2*e]?C[c+2*e+1]:I()[c+2*e+1>>>0];return(b?Mb[b]:nc[a])(...mc)}var gb=()=>{T=0};function hb(a){a>>>=0;m?postMessage({Ra:\"cleanupThread\",cb:a}):ac(S[a])}function ib(a){l&&S[a>>>0].ref()}\nfunction jb(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);G()[b>>>2>>>0]=a.getUTCSeconds();G()[b+4>>>2>>>0]=a.getUTCMinutes();G()[b+8>>>2>>>0]=a.getUTCHours();G()[b+12>>>2>>>0]=a.getUTCDate();G()[b+16>>>2>>>0]=a.getUTCMonth();G()[b+20>>>2>>>0]=a.getUTCFullYear()-1900;G()[b+24>>>2>>>0]=a.getUTCDay();a=(a.getTime()-Date.UTC(a.getUTCFullYear(),0,1,0,0,0,0))/864E5|0;G()[b+28>>>2>>>0]=a}\nvar oc=a=>0===a%4&&(0!==a%100||0===a%400),pc=[0,31,60,91,121,152,182,213,244,274,305,335],qc=[0,31,59,90,120,151,181,212,243,273,304,334];\nfunction kb(a,b){a=-9007199254740992>a||9007199254740992<a?NaN:Number(a);b>>>=0;a=new Date(1E3*a);G()[b>>>2>>>0]=a.getSeconds();G()[b+4>>>2>>>0]=a.getMinutes();G()[b+8>>>2>>>0]=a.getHours();G()[b+12>>>2>>>0]=a.getDate();G()[b+16>>>2>>>0]=a.getMonth();G()[b+20>>>2>>>0]=a.getFullYear()-1900;G()[b+24>>>2>>>0]=a.getDay();var c=(oc(a.getFullYear())?pc:qc)[a.getMonth()]+a.getDate()-1|0;G()[b+28>>>2>>>0]=c;G()[b+36>>>2>>>0]=-(60*a.getTimezoneOffset());c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset();\nvar d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();a=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0;G()[b+32>>>2>>>0]=a}\nfunction lb(a){a>>>=0;var b=new Date(G()[a+20>>>2>>>0]+1900,G()[a+16>>>2>>>0],G()[a+12>>>2>>>0],G()[a+8>>>2>>>0],G()[a+4>>>2>>>0],G()[a>>>2>>>0],0),c=G()[a+32>>>2>>>0],d=b.getTimezoneOffset(),e=(new Date(b.getFullYear(),6,1)).getTimezoneOffset(),g=(new Date(b.getFullYear(),0,1)).getTimezoneOffset(),h=Math.min(g,e);0>c?G()[a+32>>>2>>>0]=Number(e!=g&&h==d):0<c!=(h==d)&&(e=Math.max(g,e),b.setTime(b.getTime()+6E4*((0<c?h:e)-d)));G()[a+24>>>2>>>0]=b.getDay();c=(oc(b.getFullYear())?pc:qc)[b.getMonth()]+\nb.getDate()-1|0;G()[a+28>>>2>>>0]=c;G()[a>>>2>>>0]=b.getSeconds();G()[a+4>>>2>>>0]=b.getMinutes();G()[a+8>>>2>>>0]=b.getHours();G()[a+12>>>2>>>0]=b.getDate();G()[a+16>>>2>>>0]=b.getMonth();G()[a+20>>>2>>>0]=b.getYear();a=b.getTime();return BigInt(isNaN(a)?-1:a/1E3)}function mb(a,b,c,d,e,g,h){return m?V(16,1,a,b,c,d,e,g,h):-52}function nb(a,b,c,d,e,g){if(m)return V(17,1,a,b,c,d,e,g)}var Y={},xb=()=>performance.timeOrigin+performance.now();\nfunction ob(a,b){if(m)return V(18,1,a,b);Y[a]&&(clearTimeout(Y[a].id),delete Y[a]);if(!b)return 0;var c=setTimeout(()=>{delete Y[a];kc(()=>rc(a,performance.timeOrigin+performance.now()))},b);Y[a]={id:c,mb:b};return 0}\nfunction pb(a,b,c,d){a>>>=0;b>>>=0;c>>>=0;d>>>=0;var e=(new Date).getFullYear(),g=(new Date(e,0,1)).getTimezoneOffset();e=(new Date(e,6,1)).getTimezoneOffset();var h=Math.max(g,e);H()[a>>>2>>>0]=60*h;G()[b>>>2>>>0]=Number(g!=e);b=p=>{var v=Math.abs(p);return`UTC${0<=p?\"-\":\"+\"}${String(Math.floor(v/60)).padStart(2,\"0\")}${String(v%60).padStart(2,\"0\")}`};a=b(g);b=b(e);e<g?(X(a,c,17),X(b,d,17)):(X(a,d,17),X(b,c,17))}var tb=()=>Date.now(),sc=1;\nfunction qb(a,b,c){if(!(0<=a&&3>=a))return 28;if(0===a)a=Date.now();else if(sc)a=performance.timeOrigin+performance.now();else return 52;C[c>>>0>>>3]=BigInt(Math.round(1E6*a));return 0}var tc=[];function rb(a,b,c){a>>>=0;b>>>=0;c>>>=0;tc.length=0;for(var d;d=F()[b++>>>0];){var e=105!=d;e&=112!=d;c+=e&&c%8?4:0;tc.push(112==d?H()[c>>>2>>>0]:106==d?C[c>>>3]:105==d?G()[c>>>2>>>0]:I()[c>>>3>>>0]);c+=e?8:4}return Mb[a](...tc)}var sb=()=>{};function ub(a,b){return w(Lb(a>>>0,b>>>0))}\nvar vb=()=>{T+=1;throw\"unwind\";};function wb(){return 4294901760}var yb=()=>l?require(\"os\").cpus().length:navigator.hardwareConcurrency;function zb(){O(\"Cannot use emscripten_pc_get_function without -sUSE_OFFSET_CONVERTER\");return 0}\nfunction Ab(a){a>>>=0;var b=F().length;if(a<=b||4294901760<a)return!1;for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(4294901760,65536*Math.ceil(Math.max(a,d)/65536))-y.buffer.byteLength+65535)/65536|0;try{y.grow(d);E();var e=1;break a}catch(g){}e=void 0}if(e)return!0}return!1}var uc=()=>{O(\"Cannot use convertFrameToPC (needed by __builtin_return_address) without -sUSE_OFFSET_CONVERTER\");return 0},Z={},vc=a=>{a.forEach(b=>{var c=uc();c&&(Z[c]=b)})};\nfunction Bb(){var a=Error().stack.toString().split(\"\\n\");\"Error\"==a[0]&&a.shift();vc(a);Z.Wa=uc();Z.$a=a;return Z.Wa}function Cb(a,b,c){a>>>=0;b>>>=0;if(Z.Wa==a)var d=Z.$a;else d=Error().stack.toString().split(\"\\n\"),\"Error\"==d[0]&&d.shift(),vc(d);for(var e=3;d[e]&&uc()!=a;)++e;for(a=0;a<c&&d[a+e];++a)G()[b+4*a>>>2>>>0]=uc();return a}\nvar wc={},yc=()=>{if(!xc){var a={USER:\"web_user\",LOGNAME:\"web_user\",PATH:\"/\",PWD:\"/\",HOME:\"/home/web_user\",LANG:(\"object\"==typeof navigator&&navigator.languages&&navigator.languages[0]||\"C\").replace(\"-\",\"_\")+\".UTF-8\",_:fa||\"./this.program\"},b;for(b in wc)void 0===wc[b]?delete a[b]:a[b]=wc[b];var c=[];for(b in a)c.push(`${b}=${a[b]}`);xc=c}return xc},xc;\nfunction Db(a,b){if(m)return V(19,1,a,b);a>>>=0;b>>>=0;var c=0;yc().forEach((d,e)=>{var g=b+c;e=H()[a+4*e>>>2>>>0]=g;for(g=0;g<d.length;++g)D()[e++>>>0]=d.charCodeAt(g);D()[e>>>0]=0;c+=d.length+1});return 0}function Eb(a,b){if(m)return V(20,1,a,b);a>>>=0;b>>>=0;var c=yc();H()[a>>>2>>>0]=c.length;var d=0;c.forEach(e=>d+=e.length+1);H()[b>>>2>>>0]=d;return 0}function Gb(a){return m?V(21,1,a):52}function Hb(a,b,c,d){return m?V(22,1,a,b,c,d):52}function Ib(a,b,c,d){return m?V(23,1,a,b,c,d):70}\nvar zc=[null,[],[]];function Jb(a,b,c,d){if(m)return V(24,1,a,b,c,d);b>>>=0;c>>>=0;d>>>=0;for(var e=0,g=0;g<c;g++){var h=H()[b>>>2>>>0],p=H()[b+4>>>2>>>0];b+=8;for(var v=0;v<p;v++){var P=F()[h+v>>>0],ua=zc[a];0===P||10===P?((1===a?la:w)(jc(ua)),ua.length=0):ua.push(P)}e+=p}H()[d>>>2>>>0]=e;return 0}m||Yb();var nc=[Kb,Wb,hc,Qa,Ra,Sa,Ta,Ua,Va,Wa,Xa,Ya,Za,$a,ab,bb,mb,nb,ob,Db,Eb,Gb,Hb,Ib,Jb],Ma,L;\n(async function(){function a(d,e){L=d.exports;L=Ac();Xb.push(L.Da);cc=L.Ea;ma=e;Ga();return L}M++;var b=La();if(f.instantiateWasm)return new Promise(d=>{f.instantiateWasm(b,(e,g)=>{a(e,g);d(e.exports)})});if(m)return new Promise(d=>{wa=e=>{var g=new WebAssembly.Instance(e,La());d(a(g,e))}});Ha??=f.locateFile?f.locateFile?f.locateFile(\"ort-wasm-simd-threaded.wasm\",r):r+\"ort-wasm-simd-threaded.wasm\":(new URL(\"ort-wasm-simd-threaded.wasm\",import.meta.url)).href;try{var c=await Ka(b);return a(c.instance,\nc.module)}catch(d){return ba(d),Promise.reject(d)}})();f._OrtInit=(a,b)=>(f._OrtInit=L.aa)(a,b);f._OrtGetLastError=(a,b)=>(f._OrtGetLastError=L.ba)(a,b);f._OrtCreateSessionOptions=(a,b,c,d,e,g,h,p,v,P)=>(f._OrtCreateSessionOptions=L.ca)(a,b,c,d,e,g,h,p,v,P);f._OrtAppendExecutionProvider=(a,b,c,d,e)=>(f._OrtAppendExecutionProvider=L.da)(a,b,c,d,e);f._OrtAddFreeDimensionOverride=(a,b,c)=>(f._OrtAddFreeDimensionOverride=L.ea)(a,b,c);\nf._OrtAddSessionConfigEntry=(a,b,c)=>(f._OrtAddSessionConfigEntry=L.fa)(a,b,c);f._OrtReleaseSessionOptions=a=>(f._OrtReleaseSessionOptions=L.ga)(a);f._OrtCreateSession=(a,b,c)=>(f._OrtCreateSession=L.ha)(a,b,c);f._OrtReleaseSession=a=>(f._OrtReleaseSession=L.ia)(a);f._OrtGetInputOutputCount=(a,b,c)=>(f._OrtGetInputOutputCount=L.ja)(a,b,c);f._OrtGetInputOutputMetadata=(a,b,c,d)=>(f._OrtGetInputOutputMetadata=L.ka)(a,b,c,d);f._OrtFree=a=>(f._OrtFree=L.la)(a);\nf._OrtCreateTensor=(a,b,c,d,e,g)=>(f._OrtCreateTensor=L.ma)(a,b,c,d,e,g);f._OrtGetTensorData=(a,b,c,d,e)=>(f._OrtGetTensorData=L.na)(a,b,c,d,e);f._OrtReleaseTensor=a=>(f._OrtReleaseTensor=L.oa)(a);f._OrtCreateRunOptions=(a,b,c,d)=>(f._OrtCreateRunOptions=L.pa)(a,b,c,d);f._OrtAddRunConfigEntry=(a,b,c)=>(f._OrtAddRunConfigEntry=L.qa)(a,b,c);f._OrtReleaseRunOptions=a=>(f._OrtReleaseRunOptions=L.ra)(a);f._OrtCreateBinding=a=>(f._OrtCreateBinding=L.sa)(a);\nf._OrtBindInput=(a,b,c)=>(f._OrtBindInput=L.ta)(a,b,c);f._OrtBindOutput=(a,b,c,d)=>(f._OrtBindOutput=L.ua)(a,b,c,d);f._OrtClearBoundOutputs=a=>(f._OrtClearBoundOutputs=L.va)(a);f._OrtReleaseBinding=a=>(f._OrtReleaseBinding=L.wa)(a);f._OrtRunWithBinding=(a,b,c,d,e)=>(f._OrtRunWithBinding=L.xa)(a,b,c,d,e);f._OrtRun=(a,b,c,d,e,g,h,p)=>(f._OrtRun=L.ya)(a,b,c,d,e,g,h,p);f._OrtEndProfiling=a=>(f._OrtEndProfiling=L.za)(a);var J=()=>(J=L.Aa)();f._free=a=>(f._free=L.Ba)(a);f._malloc=a=>(f._malloc=L.Ca)(a);\nvar Aa=(a,b,c,d,e,g)=>(Aa=L.Fa)(a,b,c,d,e,g),Ea=()=>(Ea=L.Ga)(),Vb=(a,b,c,d,e)=>(Vb=L.Ha)(a,b,c,d,e),$b=a=>($b=L.Ia)(a),dc=a=>(dc=L.Ja)(a),rc=(a,b)=>(rc=L.Ka)(a,b),lc=()=>(lc=L.La)(),bc=(a,b)=>(bc=L.Ma)(a,b),U=a=>(U=L.Na)(a),Ub=a=>(Ub=L.Oa)(a),Tb=()=>(Tb=L.Pa)();function Ac(){var a=L;a=Object.assign({},a);var b=d=>()=>d()>>>0,c=d=>e=>d(e)>>>0;a.Aa=b(a.Aa);a.Ca=c(a.Ca);a.Oa=c(a.Oa);a.Pa=b(a.Pa);a.__cxa_get_exception_ptr=c(a.__cxa_get_exception_ptr);return a}f.stackSave=()=>Tb();f.stackRestore=a=>U(a);\nf.stackAlloc=a=>Ub(a);f.setValue=function(a,b,c=\"i8\"){c.endsWith(\"*\")&&(c=\"*\");switch(c){case \"i1\":D()[a>>>0]=b;break;case \"i8\":D()[a>>>0]=b;break;case \"i16\":ta()[a>>>1>>>0]=b;break;case \"i32\":G()[a>>>2>>>0]=b;break;case \"i64\":C[a>>>3]=BigInt(b);break;case \"float\":va()[a>>>2>>>0]=b;break;case \"double\":I()[a>>>3>>>0]=b;break;case \"*\":H()[a>>>2>>>0]=b;break;default:O(`invalid type for setValue: ${c}`)}};\nf.getValue=function(a,b=\"i8\"){b.endsWith(\"*\")&&(b=\"*\");switch(b){case \"i1\":return D()[a>>>0];case \"i8\":return D()[a>>>0];case \"i16\":return ta()[a>>>1>>>0];case \"i32\":return G()[a>>>2>>>0];case \"i64\":return C[a>>>3];case \"float\":return va()[a>>>2>>>0];case \"double\":return I()[a>>>3>>>0];case \"*\":return H()[a>>>2>>>0];default:O(`invalid type for getValue: ${b}`)}};f.UTF8ToString=Lb;f.stringToUTF8=X;\nf.lengthBytesUTF8=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=d?(b+=4,++c):b+=3}return b};function Bc(){if(0<M)N=Bc;else if(m)aa(f),Fa();else{for(;0<Pb.length;)Pb.shift()(f);0<M?N=Bc:(f.calledRun=!0,z||(Fa(),aa(f)))}}Bc();f.PTR_SIZE=4;moduleRtn=ca;\n\n\n  return moduleRtn;\n}\n);\n})();\nexport default ortWasmThreaded;\nvar isPthread = globalThis.self?.name?.startsWith('em-pthread');\nvar isNode = typeof globalThis.process?.versions?.node == 'string';\nif (isNode) isPthread = (await import('worker_threads')).workerData === 'em-pthread';\n\n// When running as a pthread, construct a new instance on startup\nisPthread && ortWasmThreaded();\n"
  },
  {
    "path": "app/chrome-extension/workers/simd_math.js",
    "content": "let wasm;\n\nconst cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );\n\nif (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };\n\nlet cachedUint8ArrayMemory0 = null;\n\nfunction getUint8ArrayMemory0() {\n    if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {\n        cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);\n    }\n    return cachedUint8ArrayMemory0;\n}\n\nfunction getStringFromWasm0(ptr, len) {\n    ptr = ptr >>> 0;\n    return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));\n}\n\nlet WASM_VECTOR_LEN = 0;\n\nconst cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );\n\nconst encodeString = (typeof cachedTextEncoder.encodeInto === 'function'\n    ? function (arg, view) {\n    return cachedTextEncoder.encodeInto(arg, view);\n}\n    : function (arg, view) {\n    const buf = cachedTextEncoder.encode(arg);\n    view.set(buf);\n    return {\n        read: arg.length,\n        written: buf.length\n    };\n});\n\nfunction passStringToWasm0(arg, malloc, realloc) {\n\n    if (realloc === undefined) {\n        const buf = cachedTextEncoder.encode(arg);\n        const ptr = malloc(buf.length, 1) >>> 0;\n        getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);\n        WASM_VECTOR_LEN = buf.length;\n        return ptr;\n    }\n\n    let len = arg.length;\n    let ptr = malloc(len, 1) >>> 0;\n\n    const mem = getUint8ArrayMemory0();\n\n    let offset = 0;\n\n    for (; offset < len; offset++) {\n        const code = arg.charCodeAt(offset);\n        if (code > 0x7F) break;\n        mem[ptr + offset] = code;\n    }\n\n    if (offset !== len) {\n        if (offset !== 0) {\n            arg = arg.slice(offset);\n        }\n        ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;\n        const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);\n        const ret = encodeString(arg, view);\n\n        offset += ret.written;\n        ptr = realloc(ptr, len, offset, 1) >>> 0;\n    }\n\n    WASM_VECTOR_LEN = offset;\n    return ptr;\n}\n\nlet cachedDataViewMemory0 = null;\n\nfunction getDataViewMemory0() {\n    if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {\n        cachedDataViewMemory0 = new DataView(wasm.memory.buffer);\n    }\n    return cachedDataViewMemory0;\n}\n\nexport function main() {\n    wasm.main();\n}\n\nlet cachedFloat32ArrayMemory0 = null;\n\nfunction getFloat32ArrayMemory0() {\n    if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) {\n        cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer);\n    }\n    return cachedFloat32ArrayMemory0;\n}\n\nfunction passArrayF32ToWasm0(arg, malloc) {\n    const ptr = malloc(arg.length * 4, 4) >>> 0;\n    getFloat32ArrayMemory0().set(arg, ptr / 4);\n    WASM_VECTOR_LEN = arg.length;\n    return ptr;\n}\n\nfunction getArrayF32FromWasm0(ptr, len) {\n    ptr = ptr >>> 0;\n    return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);\n}\n\nconst SIMDMathFinalization = (typeof FinalizationRegistry === 'undefined')\n    ? { register: () => {}, unregister: () => {} }\n    : new FinalizationRegistry(ptr => wasm.__wbg_simdmath_free(ptr >>> 0, 1));\n\nexport class SIMDMath {\n\n    __destroy_into_raw() {\n        const ptr = this.__wbg_ptr;\n        this.__wbg_ptr = 0;\n        SIMDMathFinalization.unregister(this);\n        return ptr;\n    }\n\n    free() {\n        const ptr = this.__destroy_into_raw();\n        wasm.__wbg_simdmath_free(ptr, 0);\n    }\n    constructor() {\n        const ret = wasm.simdmath_new();\n        this.__wbg_ptr = ret >>> 0;\n        SIMDMathFinalization.register(this, this.__wbg_ptr, this);\n        return this;\n    }\n    /**\n     * @param {Float32Array} vec_a\n     * @param {Float32Array} vec_b\n     * @returns {number}\n     */\n    cosine_similarity(vec_a, vec_b) {\n        const ptr0 = passArrayF32ToWasm0(vec_a, wasm.__wbindgen_malloc);\n        const len0 = WASM_VECTOR_LEN;\n        const ptr1 = passArrayF32ToWasm0(vec_b, wasm.__wbindgen_malloc);\n        const len1 = WASM_VECTOR_LEN;\n        const ret = wasm.simdmath_cosine_similarity(this.__wbg_ptr, ptr0, len0, ptr1, len1);\n        return ret;\n    }\n    /**\n     * @param {Float32Array} vectors\n     * @param {Float32Array} query\n     * @param {number} vector_dim\n     * @returns {Float32Array}\n     */\n    batch_similarity(vectors, query, vector_dim) {\n        const ptr0 = passArrayF32ToWasm0(vectors, wasm.__wbindgen_malloc);\n        const len0 = WASM_VECTOR_LEN;\n        const ptr1 = passArrayF32ToWasm0(query, wasm.__wbindgen_malloc);\n        const len1 = WASM_VECTOR_LEN;\n        const ret = wasm.simdmath_batch_similarity(this.__wbg_ptr, ptr0, len0, ptr1, len1, vector_dim);\n        var v3 = getArrayF32FromWasm0(ret[0], ret[1]).slice();\n        wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);\n        return v3;\n    }\n    /**\n     * @param {Float32Array} vectors_a\n     * @param {Float32Array} vectors_b\n     * @param {number} vector_dim\n     * @returns {Float32Array}\n     */\n    similarity_matrix(vectors_a, vectors_b, vector_dim) {\n        const ptr0 = passArrayF32ToWasm0(vectors_a, wasm.__wbindgen_malloc);\n        const len0 = WASM_VECTOR_LEN;\n        const ptr1 = passArrayF32ToWasm0(vectors_b, wasm.__wbindgen_malloc);\n        const len1 = WASM_VECTOR_LEN;\n        const ret = wasm.simdmath_similarity_matrix(this.__wbg_ptr, ptr0, len0, ptr1, len1, vector_dim);\n        var v3 = getArrayF32FromWasm0(ret[0], ret[1]).slice();\n        wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);\n        return v3;\n    }\n}\n\nasync function __wbg_load(module, imports) {\n    if (typeof Response === 'function' && module instanceof Response) {\n        if (typeof WebAssembly.instantiateStreaming === 'function') {\n            try {\n                return await WebAssembly.instantiateStreaming(module, imports);\n\n            } catch (e) {\n                if (module.headers.get('Content-Type') != 'application/wasm') {\n                    console.warn(\"`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\\n\", e);\n\n                } else {\n                    throw e;\n                }\n            }\n        }\n\n        const bytes = await module.arrayBuffer();\n        return await WebAssembly.instantiate(bytes, imports);\n\n    } else {\n        const instance = await WebAssembly.instantiate(module, imports);\n\n        if (instance instanceof WebAssembly.Instance) {\n            return { instance, module };\n\n        } else {\n            return instance;\n        }\n    }\n}\n\nfunction __wbg_get_imports() {\n    const imports = {};\n    imports.wbg = {};\n    imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {\n        let deferred0_0;\n        let deferred0_1;\n        try {\n            deferred0_0 = arg0;\n            deferred0_1 = arg1;\n            console.error(getStringFromWasm0(arg0, arg1));\n        } finally {\n            wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);\n        }\n    };\n    imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {\n        const ret = new Error();\n        return ret;\n    };\n    imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {\n        const ret = arg1.stack;\n        const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);\n        const len1 = WASM_VECTOR_LEN;\n        getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);\n        getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);\n    };\n    imports.wbg.__wbindgen_init_externref_table = function() {\n        const table = wasm.__wbindgen_export_3;\n        const offset = table.grow(4);\n        table.set(0, undefined);\n        table.set(offset + 0, undefined);\n        table.set(offset + 1, null);\n        table.set(offset + 2, true);\n        table.set(offset + 3, false);\n        ;\n    };\n    imports.wbg.__wbindgen_throw = function(arg0, arg1) {\n        throw new Error(getStringFromWasm0(arg0, arg1));\n    };\n\n    return imports;\n}\n\nfunction __wbg_init_memory(imports, memory) {\n\n}\n\nfunction __wbg_finalize_init(instance, module) {\n    wasm = instance.exports;\n    __wbg_init.__wbindgen_wasm_module = module;\n    cachedDataViewMemory0 = null;\n    cachedFloat32ArrayMemory0 = null;\n    cachedUint8ArrayMemory0 = null;\n\n\n    wasm.__wbindgen_start();\n    return wasm;\n}\n\nfunction initSync(module) {\n    if (wasm !== undefined) return wasm;\n\n\n    if (typeof module !== 'undefined') {\n        if (Object.getPrototypeOf(module) === Object.prototype) {\n            ({module} = module)\n        } else {\n            console.warn('using deprecated parameters for `initSync()`; pass a single object instead')\n        }\n    }\n\n    const imports = __wbg_get_imports();\n\n    __wbg_init_memory(imports);\n\n    if (!(module instanceof WebAssembly.Module)) {\n        module = new WebAssembly.Module(module);\n    }\n\n    const instance = new WebAssembly.Instance(module, imports);\n\n    return __wbg_finalize_init(instance, module);\n}\n\nasync function __wbg_init(module_or_path) {\n    if (wasm !== undefined) return wasm;\n\n\n    if (typeof module_or_path !== 'undefined') {\n        if (Object.getPrototypeOf(module_or_path) === Object.prototype) {\n            ({module_or_path} = module_or_path)\n        } else {\n            console.warn('using deprecated parameters for the initialization function; pass a single object instead')\n        }\n    }\n\n    if (typeof module_or_path === 'undefined') {\n        module_or_path = new URL('simd_math_bg.wasm', import.meta.url);\n    }\n    const imports = __wbg_get_imports();\n\n    if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {\n        module_or_path = fetch(module_or_path);\n    }\n\n    __wbg_init_memory(imports);\n\n    const { instance, module } = await __wbg_load(await module_or_path, imports);\n\n    return __wbg_finalize_init(instance, module);\n}\n\nexport { initSync };\nexport default __wbg_init;\n"
  },
  {
    "path": "app/chrome-extension/workers/similarity.worker.js",
    "content": "/* eslint-disable */\n// js/similarity.worker.js\nimportScripts('../libs/ort.min.js'); // 调整路径以匹配您的文件结构\n\n// 全局Worker状态\nlet session = null;\nlet modelPathInternal = null;\nlet ortEnvConfigured = false;\nlet sessionOptions = null;\nlet modelInputNames = null; // 存储模型的输入名称\n\n// 复用的 TypedArray 缓冲区，减少内存分配\nlet reusableBuffers = {\n  inputIds: null,\n  attentionMask: null,\n  tokenTypeIds: null,\n};\n\n// 性能统计\nlet workerStats = {\n  totalInferences: 0,\n  totalInferenceTime: 0,\n  averageInferenceTime: 0,\n  memoryAllocations: 0,\n};\n\n// 配置 ONNX Runtime 环境 (仅一次)\nfunction configureOrtEnv(numThreads = 1, executionProviders = ['wasm']) {\n  if (ortEnvConfigured) return;\n  try {\n    ort.env.wasm.numThreads = numThreads;\n    ort.env.wasm.simd = true; // 尽可能启用SIMD\n    ort.env.wasm.proxy = false; // 在Worker中，通常不需要代理\n    ort.env.logLevel = 'warning'; // 'verbose', 'info', 'warning', 'error', 'fatal'\n    ortEnvConfigured = true;\n\n    sessionOptions = {\n      executionProviders: executionProviders,\n      graphOptimizationLevel: 'all',\n      enableCpuMemArena: true,\n      enableMemPattern: true,\n      // executionMode: 'sequential' // 在worker内部通常是顺序执行一个任务\n    };\n  } catch (error) {\n    console.error('Worker: Failed to configure ORT environment', error);\n    throw error; // 抛出错误，让主线程知道\n  }\n}\n\nasync function initializeModel(modelPathOrData, numThreads, executionProviders) {\n  try {\n    configureOrtEnv(numThreads, executionProviders); // 确保环境已配置\n\n    if (!modelPathOrData) {\n      throw new Error('Worker: Model path or data is not provided.');\n    }\n\n    // Check if input is ArrayBuffer (cached model data) or string (URL path)\n    if (modelPathOrData instanceof ArrayBuffer) {\n      console.log(\n        `Worker: Initializing model from cached ArrayBuffer (${modelPathOrData.byteLength} bytes)`,\n      );\n      session = await ort.InferenceSession.create(modelPathOrData, sessionOptions);\n      modelPathInternal = '[Cached ArrayBuffer]'; // For debugging purposes\n    } else {\n      console.log(`Worker: Initializing model from URL: ${modelPathOrData}`);\n      modelPathInternal = modelPathOrData; // 存储模型路径以备调试或重载（如果需要）\n      session = await ort.InferenceSession.create(modelPathInternal, sessionOptions);\n    }\n\n    // 获取模型的输入名称，用于判断是否需要token_type_ids\n    modelInputNames = session.inputNames;\n    console.log(`Worker: ONNX session created successfully for model: ${modelPathInternal}`);\n    console.log(`Worker: Model input names:`, modelInputNames);\n\n    return { status: 'success', message: 'Model initialized' };\n  } catch (error) {\n    console.error(`Worker: Model initialization failed:`, error);\n    session = null; // 清理session以防部分初始化\n    modelInputNames = null;\n    // 将错误信息序列化，因为Error对象本身可能无法直接postMessage\n    throw new Error(`Worker: Model initialization failed - ${error.message}`);\n  }\n}\n\n// 优化的缓冲区管理函数\nfunction getOrCreateBuffer(name, requiredLength, type = BigInt64Array) {\n  if (!reusableBuffers[name] || reusableBuffers[name].length < requiredLength) {\n    reusableBuffers[name] = new type(requiredLength);\n    workerStats.memoryAllocations++;\n  }\n  return reusableBuffers[name];\n}\n\n// 优化的批处理推理函数\nasync function runBatchInference(batchData) {\n  if (!session) {\n    throw new Error(\"Worker: Session not initialized. Call 'initializeModel' first.\");\n  }\n\n  const startTime = performance.now();\n\n  try {\n    const feeds = {};\n    const batchSize = batchData.dims.input_ids[0];\n    const seqLength = batchData.dims.input_ids[1];\n\n    // 优化：复用缓冲区，减少内存分配\n    const inputIdsLength = batchData.input_ids.length;\n    const attentionMaskLength = batchData.attention_mask.length;\n\n    // 复用或创建 BigInt64Array 缓冲区\n    const inputIdsBuffer = getOrCreateBuffer('inputIds', inputIdsLength);\n    const attentionMaskBuffer = getOrCreateBuffer('attentionMask', attentionMaskLength);\n\n    // 批量填充数据（避免 map 操作）\n    for (let i = 0; i < inputIdsLength; i++) {\n      inputIdsBuffer[i] = BigInt(batchData.input_ids[i]);\n    }\n    for (let i = 0; i < attentionMaskLength; i++) {\n      attentionMaskBuffer[i] = BigInt(batchData.attention_mask[i]);\n    }\n\n    feeds['input_ids'] = new ort.Tensor(\n      'int64',\n      inputIdsBuffer.slice(0, inputIdsLength),\n      batchData.dims.input_ids,\n    );\n    feeds['attention_mask'] = new ort.Tensor(\n      'int64',\n      attentionMaskBuffer.slice(0, attentionMaskLength),\n      batchData.dims.attention_mask,\n    );\n\n    // 处理 token_type_ids - 只有当模型需要时才提供\n    if (modelInputNames && modelInputNames.includes('token_type_ids')) {\n      if (batchData.token_type_ids && batchData.dims.token_type_ids) {\n        const tokenTypeIdsLength = batchData.token_type_ids.length;\n        const tokenTypeIdsBuffer = getOrCreateBuffer('tokenTypeIds', tokenTypeIdsLength);\n\n        for (let i = 0; i < tokenTypeIdsLength; i++) {\n          tokenTypeIdsBuffer[i] = BigInt(batchData.token_type_ids[i]);\n        }\n\n        feeds['token_type_ids'] = new ort.Tensor(\n          'int64',\n          tokenTypeIdsBuffer.slice(0, tokenTypeIdsLength),\n          batchData.dims.token_type_ids,\n        );\n      } else {\n        // 创建默认的全零 token_type_ids\n        const tokenTypeIdsBuffer = getOrCreateBuffer('tokenTypeIds', inputIdsLength);\n        tokenTypeIdsBuffer.fill(0n, 0, inputIdsLength);\n\n        feeds['token_type_ids'] = new ort.Tensor(\n          'int64',\n          tokenTypeIdsBuffer.slice(0, inputIdsLength),\n          batchData.dims.input_ids,\n        );\n      }\n    } else {\n      console.log('Worker: Skipping token_type_ids as model does not require it');\n    }\n\n    // 执行批处理推理\n    const results = await session.run(feeds);\n    const outputTensor = results.last_hidden_state || results[Object.keys(results)[0]];\n\n    // 使用 Transferable Objects 优化数据传输\n    const outputData = new Float32Array(outputTensor.data);\n\n    // 更新统计信息\n    workerStats.totalInferences += batchSize; // 批处理计算多个推理\n    const inferenceTime = performance.now() - startTime;\n    workerStats.totalInferenceTime += inferenceTime;\n    workerStats.averageInferenceTime = workerStats.totalInferenceTime / workerStats.totalInferences;\n\n    return {\n      status: 'success',\n      output: {\n        data: outputData,\n        dims: outputTensor.dims,\n        batchSize: batchSize,\n        seqLength: seqLength,\n      },\n      transferList: [outputData.buffer],\n      stats: {\n        inferenceTime,\n        totalInferences: workerStats.totalInferences,\n        averageInferenceTime: workerStats.averageInferenceTime,\n        memoryAllocations: workerStats.memoryAllocations,\n        batchSize: batchSize,\n      },\n    };\n  } catch (error) {\n    console.error('Worker: Batch inference failed:', error);\n    throw new Error(`Worker: Batch inference failed - ${error.message}`);\n  }\n}\n\nasync function runInference(inputData) {\n  if (!session) {\n    throw new Error(\"Worker: Session not initialized. Call 'initializeModel' first.\");\n  }\n\n  const startTime = performance.now();\n\n  try {\n    const feeds = {};\n\n    // 优化：复用缓冲区，减少内存分配\n    const inputIdsLength = inputData.input_ids.length;\n    const attentionMaskLength = inputData.attention_mask.length;\n\n    // 复用或创建 BigInt64Array 缓冲区\n    const inputIdsBuffer = getOrCreateBuffer('inputIds', inputIdsLength);\n    const attentionMaskBuffer = getOrCreateBuffer('attentionMask', attentionMaskLength);\n\n    // 填充数据（避免 map 操作）\n    for (let i = 0; i < inputIdsLength; i++) {\n      inputIdsBuffer[i] = BigInt(inputData.input_ids[i]);\n    }\n    for (let i = 0; i < attentionMaskLength; i++) {\n      attentionMaskBuffer[i] = BigInt(inputData.attention_mask[i]);\n    }\n\n    feeds['input_ids'] = new ort.Tensor(\n      'int64',\n      inputIdsBuffer.slice(0, inputIdsLength),\n      inputData.dims.input_ids,\n    );\n    feeds['attention_mask'] = new ort.Tensor(\n      'int64',\n      attentionMaskBuffer.slice(0, attentionMaskLength),\n      inputData.dims.attention_mask,\n    );\n\n    // 处理 token_type_ids - 只有当模型需要时才提供\n    if (modelInputNames && modelInputNames.includes('token_type_ids')) {\n      if (inputData.token_type_ids && inputData.dims.token_type_ids) {\n        const tokenTypeIdsLength = inputData.token_type_ids.length;\n        const tokenTypeIdsBuffer = getOrCreateBuffer('tokenTypeIds', tokenTypeIdsLength);\n\n        for (let i = 0; i < tokenTypeIdsLength; i++) {\n          tokenTypeIdsBuffer[i] = BigInt(inputData.token_type_ids[i]);\n        }\n\n        feeds['token_type_ids'] = new ort.Tensor(\n          'int64',\n          tokenTypeIdsBuffer.slice(0, tokenTypeIdsLength),\n          inputData.dims.token_type_ids,\n        );\n      } else {\n        // 创建默认的全零 token_type_ids\n        const tokenTypeIdsBuffer = getOrCreateBuffer('tokenTypeIds', inputIdsLength);\n        tokenTypeIdsBuffer.fill(0n, 0, inputIdsLength);\n\n        feeds['token_type_ids'] = new ort.Tensor(\n          'int64',\n          tokenTypeIdsBuffer.slice(0, inputIdsLength),\n          inputData.dims.input_ids,\n        );\n      }\n    } else {\n      console.log('Worker: Skipping token_type_ids as model does not require it');\n    }\n\n    const results = await session.run(feeds);\n    const outputTensor = results.last_hidden_state || results[Object.keys(results)[0]];\n\n    // 使用 Transferable Objects 优化数据传输\n    const outputData = new Float32Array(outputTensor.data);\n\n    // 更新统计信息\n    workerStats.totalInferences++;\n    const inferenceTime = performance.now() - startTime;\n    workerStats.totalInferenceTime += inferenceTime;\n    workerStats.averageInferenceTime = workerStats.totalInferenceTime / workerStats.totalInferences;\n\n    return {\n      status: 'success',\n      output: {\n        data: outputData, // 直接返回 Float32Array\n        dims: outputTensor.dims,\n      },\n      transferList: [outputData.buffer], // 标记为可转移对象\n      stats: {\n        inferenceTime,\n        totalInferences: workerStats.totalInferences,\n        averageInferenceTime: workerStats.averageInferenceTime,\n        memoryAllocations: workerStats.memoryAllocations,\n      },\n    };\n  } catch (error) {\n    console.error('Worker: Inference failed:', error);\n    throw new Error(`Worker: Inference failed - ${error.message}`);\n  }\n}\n\nself.onmessage = async (event) => {\n  const { id, type, payload } = event.data;\n\n  try {\n    switch (type) {\n      case 'init':\n        // Support both modelPath (URL string) and modelData (ArrayBuffer)\n        const modelInput = payload.modelData || payload.modelPath;\n        await initializeModel(modelInput, payload.numThreads, payload.executionProviders);\n        self.postMessage({ id, type: 'init_complete', status: 'success' });\n        break;\n      case 'infer':\n        const result = await runInference(payload);\n        // 使用 Transferable Objects 优化数据传输\n        self.postMessage(\n          {\n            id,\n            type: 'infer_complete',\n            status: 'success',\n            payload: result.output,\n            stats: result.stats,\n          },\n          result.transferList || [],\n        );\n        break;\n      case 'batchInfer':\n        const batchResult = await runBatchInference(payload);\n        // 使用 Transferable Objects 优化数据传输\n        self.postMessage(\n          {\n            id,\n            type: 'batchInfer_complete',\n            status: 'success',\n            payload: batchResult.output,\n            stats: batchResult.stats,\n          },\n          batchResult.transferList || [],\n        );\n        break;\n      case 'getStats':\n        self.postMessage({\n          id,\n          type: 'stats_complete',\n          status: 'success',\n          payload: workerStats,\n        });\n        break;\n      case 'clearBuffers':\n        // 清理缓冲区，释放内存\n        reusableBuffers = {\n          inputIds: null,\n          attentionMask: null,\n          tokenTypeIds: null,\n        };\n        workerStats.memoryAllocations = 0;\n        self.postMessage({\n          id,\n          type: 'clear_complete',\n          status: 'success',\n          payload: { message: 'Buffers cleared' },\n        });\n        break;\n      default:\n        console.warn(`Worker: Unknown message type: ${type}`);\n        self.postMessage({\n          id,\n          type: 'error',\n          status: 'error',\n          payload: { message: `Unknown message type: ${type}` },\n        });\n    }\n  } catch (error) {\n    // 确保将错误作为普通对象发送，因为Error对象本身可能无法正确序列化\n    self.postMessage({\n      id,\n      type: `${type}_error`, // 如 'init_error' 或 'infer_error'\n      status: 'error',\n      payload: {\n        message: error.message,\n        stack: error.stack, // 可选，用于调试\n        name: error.name,\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "app/chrome-extension/wxt.config.ts",
    "content": "import { defineConfig } from 'wxt';\nimport tailwindcss from '@tailwindcss/vite';\nimport { viteStaticCopy } from 'vite-plugin-static-copy';\nimport { config } from 'dotenv';\nimport { resolve } from 'path';\nimport Icons from 'unplugin-icons/vite';\nimport Components from 'unplugin-vue-components/vite';\nimport IconsResolver from 'unplugin-icons/resolver';\n\nconfig({ path: resolve(process.cwd(), '.env') });\nconfig({ path: resolve(process.cwd(), '.env.local') });\n\nconst CHROME_EXTENSION_KEY = process.env.CHROME_EXTENSION_KEY;\n// Detect dev mode early for manifest-level switches\nconst IS_DEV = process.env.NODE_ENV !== 'production' && process.env.MODE !== 'production';\n\n// See https://wxt.dev/api/config.html\nexport default defineConfig({\n  modules: ['@wxt-dev/module-vue'],\n  runner: {\n    // 方案1: 禁用自动启动（推荐）\n    disabled: true,\n\n    // 方案2: 如果要启用自动启动并使用现有配置，取消注释下面的配置\n    // chromiumArgs: [\n    //   '--user-data-dir=' + homedir() + (process.platform === 'darwin'\n    //     ? '/Library/Application Support/Google/Chrome'\n    //     : process.platform === 'win32'\n    //     ? '/AppData/Local/Google/Chrome/User Data'\n    //     : '/.config/google-chrome'),\n    //   '--remote-debugging-port=9222',\n    // ],\n  },\n  manifest: {\n    // Use environment variable for the key, fallback to undefined if not set\n    key: CHROME_EXTENSION_KEY,\n    default_locale: 'zh_CN',\n    name: '__MSG_extensionName__',\n    description: '__MSG_extensionDescription__',\n    permissions: [\n      'nativeMessaging',\n      'tabs',\n      'activeTab',\n      'scripting',\n      'contextMenus',\n      'downloads',\n      'webRequest',\n      'webNavigation',\n      'debugger',\n      'history',\n      'bookmarks',\n      'offscreen',\n      'storage',\n      'declarativeNetRequest',\n      'alarms',\n      // Allow programmatic control of Chrome Side Panel\n      'sidePanel',\n    ],\n    host_permissions: ['<all_urls>'],\n    options_ui: {\n      page: 'options.html',\n      open_in_tab: true,\n    },\n    action: {\n      default_popup: 'popup.html',\n      default_title: 'Chrome MCP Server',\n    },\n    // Chrome Side Panel entry for workflow management\n    // Ref: https://developer.chrome.com/docs/extensions/reference/api/sidePanel\n    side_panel: {\n      default_path: 'sidepanel.html',\n    },\n    // Keyboard shortcuts for quick triggers\n    commands: {\n      // run_quick_trigger_1: {\n      //   suggested_key: { default: 'Ctrl+Shift+1' },\n      //   description: 'Run quick trigger 1',\n      // },\n      // run_quick_trigger_2: {\n      //   suggested_key: { default: 'Ctrl+Shift+2' },\n      //   description: 'Run quick trigger 2',\n      // },\n      // run_quick_trigger_3: {\n      //   suggested_key: { default: 'Ctrl+Shift+3' },\n      //   description: 'Run quick trigger 3',\n      // },\n      // open_workflow_sidepanel: {\n      //   suggested_key: { default: 'Ctrl+Shift+O' },\n      //   description: 'Open workflow sidepanel',\n      // },\n      toggle_web_editor: {\n        suggested_key: { default: 'Ctrl+Shift+O', mac: 'Command+Shift+O' },\n        description: 'Toggle Web Editor mode',\n      },\n      toggle_quick_panel: {\n        suggested_key: { default: 'Ctrl+Shift+U', mac: 'Command+Shift+U' },\n        description: 'Toggle Quick Panel AI Chat',\n      },\n    },\n    web_accessible_resources: [\n      {\n        resources: [\n          '/models/*', // 允许访问 public/models/ 下的所有文件\n          '/workers/*', // 允许访问 workers 文件\n          '/inject-scripts/*', // 允许内容脚本注入的助手文件\n        ],\n        matches: ['<all_urls>'],\n      },\n    ],\n    // 注意：以下安全策略在开发环境会阻断 dev server 的资源加载，\n    // 只在生产环境启用，开发环境交由 WXT 默认策略处理。\n    ...(IS_DEV\n      ? {}\n      : {\n          cross_origin_embedder_policy: { value: 'require-corp' as const },\n          cross_origin_opener_policy: { value: 'same-origin' as const },\n          content_security_policy: {\n            // Allow inline styles injected by Vite (compiled CSS) and data images used in UI thumbnails\n            extension_pages:\n              \"script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;\",\n          },\n        }),\n  },\n  vite: (env) => ({\n    plugins: [\n      // TailwindCSS v4 Vite plugin – no PostCSS config required\n      tailwindcss(),\n      // Auto-register SVG icons as Vue components; all icons are bundled locally\n      Components({\n        dts: false,\n        resolvers: [IconsResolver({ prefix: 'i', enabledCollections: ['lucide', 'mdi', 'ri'] })],\n      }) as any,\n      Icons({ compiler: 'vue3', autoInstall: false }) as any,\n      // Ensure static assets are available as early as possible to avoid race conditions in dev\n      // Copy workers/_locales/inject-scripts into the build output before other steps\n      viteStaticCopy({\n        targets: [\n          {\n            src: 'inject-scripts/*.js',\n            dest: 'inject-scripts',\n          },\n          {\n            src: ['workers/*'],\n            dest: 'workers',\n          },\n          {\n            src: '_locales/**/*',\n            dest: '_locales',\n          },\n        ],\n        // Use writeBundle so outDir exists for dev and prod\n        hook: 'writeBundle',\n        // Enable watch so changes to these files are reflected during dev\n        watch: {\n          // Use default patterns inferred from targets; explicit true enables watching\n          // Vite plugin will watch src patterns and re-copy on change\n        } as any,\n      }) as any,\n    ],\n    build: {\n      // 我们的构建产物需要兼容到es6\n      target: 'es2015',\n      // 非生产环境下生成sourcemap\n      sourcemap: env.mode !== 'production',\n      // 禁用gzip 压缩大小报告，因为压缩大型文件可能会很慢\n      reportCompressedSize: false,\n      // chunk大小超过1500kb是触发警告\n      chunkSizeWarningLimit: 1500,\n      minify: false,\n    },\n  }),\n});\n"
  },
  {
    "path": "app/native-server/.npmignore",
    "content": "# Development-only files that should not be published to npm\n\n# node_path.txt contains the absolute path to Node.js used during build.\n# It's written by build.ts for development hot-reload, but is useless\n# (and potentially confusing) in the published package since users will\n# have their own Node.js path written by postinstall.\nnode_path.txt\n**/node_path.txt\n"
  },
  {
    "path": "app/native-server/README.md",
    "content": "# Fastify Chrome Native Messaging服务\n\n这是一个基于Fastify的TypeScript项目，用于与Chrome扩展进行原生通信。\n\n## 功能特性\n\n- 通过Chrome Native Messaging协议与Chrome扩展进行双向通信\n- **支持多浏览器**: Chrome 和 Chromium (包括 Linux、macOS 和 Windows)\n- 提供RESTful API服务\n- 完全使用TypeScript开发\n- 包含完整的测试套件\n- 遵循代码质量最佳实践\n\n## 开发环境设置\n\n### 前置条件\n\n- Node.js 20+\n- npm 8+ 或 pnpm 8+\n\n### 安装\n\n```bash\ngit clone https://github.com/your-username/fastify-chrome-native.git\ncd fastify-chrome-native\nnpm install\n```\n\n### 开发\n\n1. 本地构建注册native server\n\n```bash\ncd app/native-server\nnpm run dev\n```\n\n2. 启动chrome extension\n\n```bash\ncd app/chrome-extension\nnpm run dev\n```\n\n### 构建\n\n```bash\nnpm run build\n```\n\n### 注册Native Messaging主机\n\n#### 自动检测并注册所有已安装的浏览器\n\n```bash\nmcp-chrome-bridge register --detect\n```\n\n#### 注册特定浏览器\n\n```bash\n# 仅注册 Chrome\nmcp-chrome-bridge register --browser chrome\n\n# 仅注册 Chromium\nmcp-chrome-bridge register --browser chromium\n\n# 注册所有支持的浏览器\nmcp-chrome-bridge register --browser all\n```\n\n#### 全局安装（会自动注册检测到的浏览器）\n\n```bash\nnpm i -g mcp-chrome-bridge\n```\n\n#### 浏览器支持\n\n| 浏览器        | Linux | macOS | Windows |\n| ------------- | ----- | ----- | ------- |\n| Google Chrome | ✓     | ✓     | ✓       |\n| Chromium      | ✓     | ✓     | ✓       |\n\n注册位置：\n\n- **Linux**: `~/.config/[browser-name]/NativeMessagingHosts/`\n- **macOS**: `~/Library/Application Support/[Browser]/NativeMessagingHosts/`\n- **Windows**: `%APPDATA%\\[Browser]\\NativeMessagingHosts\\`\n\n### 与Chrome扩展集成\n\n以下是Chrome扩展中如何使用此服务的简单示例：\n\n```javascript\n// background.js\nlet nativePort = null;\nlet serverRunning = false;\n\n// 启动Native Messaging服务\nfunction startServer() {\n  if (nativePort) {\n    console.log('已连接到Native Messaging主机');\n    return;\n  }\n\n  try {\n    nativePort = chrome.runtime.connectNative('com.yourcompany.fastify_native_host');\n\n    nativePort.onMessage.addListener((message) => {\n      console.log('收到Native消息:', message);\n\n      if (message.type === 'started') {\n        serverRunning = true;\n        console.log(`服务已启动，端口: ${message.payload.port}`);\n      } else if (message.type === 'stopped') {\n        serverRunning = false;\n        console.log('服务已停止');\n      } else if (message.type === 'error') {\n        console.error('Native错误:', message.payload.message);\n      }\n    });\n\n    nativePort.onDisconnect.addListener(() => {\n      console.log('Native连接断开:', chrome.runtime.lastError);\n      nativePort = null;\n      serverRunning = false;\n    });\n\n    // 启动服务器\n    nativePort.postMessage({ type: 'start', payload: { port: 3000 } });\n  } catch (error) {\n    console.error('启动Native Messaging时出错:', error);\n  }\n}\n\n// 停止服务器\nfunction stopServer() {\n  if (nativePort && serverRunning) {\n    nativePort.postMessage({ type: 'stop' });\n  }\n}\n\n// 测试与服务器的通信\nasync function testPing() {\n  try {\n    const response = await fetch('http://localhost:3000/ping');\n    const data = await response.json();\n    console.log('Ping响应:', data);\n    return data;\n  } catch (error) {\n    console.error('Ping失败:', error);\n    return null;\n  }\n}\n\n// 在扩展启动时连接Native主机\nchrome.runtime.onStartup.addListener(startServer);\n\n// 导出供popup或内容脚本使用的API\nchrome.runtime.onMessage.addListener((message, sender, sendResponse) => {\n  if (message.action === 'startServer') {\n    startServer();\n    sendResponse({ success: true });\n  } else if (message.action === 'stopServer') {\n    stopServer();\n    sendResponse({ success: true });\n  } else if (message.action === 'testPing') {\n    testPing().then(sendResponse);\n    return true; // 指示我们将异步发送响应\n  }\n});\n```\n\n### 测试\n\n```bash\nnpm run test\n```\n\n### 许可证\n\nMIT\n"
  },
  {
    "path": "app/native-server/debug.sh",
    "content": "#!/bin/bash\n# 获取脚本所在的绝对目录\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nLOG_DIR=\"/Users/hang/code/tencent/ai/chrome-mcp-server/app/native-server/dist/logs\" # 或者你选择的、确定有写入权限的目录\n\n# 获取当前时间戳用于日志文件名，避免覆盖\nTIMESTAMP=$(date +\"%Y%m%d_%H%M%S\")\nWRAPPER_LOG=\"${LOG_DIR}/native_host_wrapper_${TIMESTAMP}.log\"\n\n# Node.js 脚本的实际路径\nNODE_SCRIPT=\"${SCRIPT_DIR}/index.js\"\n\n# 确保日志目录存在\nmkdir -p \"${LOG_DIR}\"\n\n# 记录 wrapper 脚本被调用的信息\necho \"Wrapper script called at $(date)\" > \"${WRAPPER_LOG}\"\necho \"SCRIPT_DIR: ${SCRIPT_DIR}\" >> \"${WRAPPER_LOG}\"\necho \"LOG_DIR: ${LOG_DIR}\" >> \"${WRAPPER_LOG}\"\necho \"NODE_SCRIPT: ${NODE_SCRIPT}\" >> \"${WRAPPER_LOG}\"\necho \"Initial PATH: ${PATH}\" >> \"${WRAPPER_LOG}\"\n\n# 动态查找 Node.js 可执行文件\nNODE_EXEC=\"\"\n# 1. 尝试用 which (它会使用当前环境的 PATH, 但 Chrome 的 PATH 可能不完整)\nif command -v node &>/dev/null; then\n    NODE_EXEC=$(command -v node)\n    echo \"Found node using 'command -v node': ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\nfi\n\n# 2. 如果 which 找不到，尝试一些 macOS 上常见的 Node.js 安装路径\nif [ -z \"${NODE_EXEC}\" ]; then\n    COMMON_NODE_PATHS=(\n        \"/usr/local/bin/node\"            # Homebrew on Intel Macs / direct install\n        \"/opt/homebrew/bin/node\"         # Homebrew on Apple Silicon\n        \"$HOME/.nvm/versions/node/$(ls -t $HOME/.nvm/versions/node | head -n 1)/bin/node\" # NVM (latest installed)\n        # 你可以根据需要添加更多你环境中可能存在的路径\n    )\n    for path_to_node in \"${COMMON_NODE_PATHS[@]}\"; do\n        if [ -x \"${path_to_node}\" ]; then\n            NODE_EXEC=\"${path_to_node}\"\n            echo \"Found node at common path: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n            break\n        fi\n    done\nfi\n\n# 3. 如果还是找不到，记录错误并退出\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"ERROR: Node.js executable not found!\" >> \"${WRAPPER_LOG}\"\n    echo \"Please ensure Node.js is installed and its path is accessible or configured in this script.\" >> \"${WRAPPER_LOG}\"\n    # 对于 Native Host，它需要保持运行以接收消息，直接退出可能不是最佳\n    # 但如果node都找不到，也无法执行目标脚本\n    # 这里可以考虑输出一个符合 Native Messaging 协议的错误消息给扩展（如果可以的话）\n    # 或者就让它失败，Chrome会报告 Native Host Exited.\n    exit 1 # 必须退出，否则下面的 exec 会失败\nfi\n\necho \"Using Node executable: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\necho \"Node version found by script: $(${NODE_EXEC} -v)\" >> \"${WRAPPER_LOG}\"\necho \"Executing: ${NODE_EXEC} ${NODE_SCRIPT}\" >> \"${WRAPPER_LOG}\"\necho \"PWD: $(pwd)\" >> \"${WRAPPER_LOG}\" # PWD 记录一下，有时有用\n\nexec \"${NODE_EXEC}\" \"${NODE_SCRIPT}\" 2>> \"${LOG_DIR}/native_host_stderr_${TIMESTAMP}.log\""
  },
  {
    "path": "app/native-server/install.md",
    "content": "# Chrome MCP Bridge 安装指南\n\n本文档详细说明了 Chrome MCP Bridge 的安装和注册流程。\n\n## 安装流程概述\n\nChrome MCP Bridge 的安装和注册流程如下：\n\n```\nnpm install -g mcp-chrome-bridge\n└─ postinstall.js\n   ├─ 复制可执行文件到 npm_prefix/bin   ← 总是可写（用户或root权限）\n   ├─ 尝试用户级别注册                  ← 无需sudo，大多数情况下成功\n   └─ 如果失败 ➜ 提示用户运行 mcp-chrome-bridge register --system\n      └─ 需要手动使用管理员权限运行\n```\n\n上面的流程图展示了从全局安装开始，到最终完成注册的完整过程。\n\n## 详细安装步骤\n\n### 1. 全局安装\n\n```bash\nnpm install -g mcp-chrome-bridge\n```\n\n安装完成后，系统会自动尝试在用户目录中注册 Native Messaging 主机。这不需要管理员权限，是推荐的安装方式。\n\n### 2. 用户级别注册\n\n用户级别注册会在以下位置创建清单文件：\n\n```\n清单文件位置\n├─ 用户级别（无需管理员权限）\n│  ├─ Windows: %APPDATA%\\Google\\Chrome\\NativeMessagingHosts\\\n│  ├─ macOS:   ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/\n│  └─ Linux:   ~/.config/google-chrome/NativeMessagingHosts/\n│\n└─ 系统级别（需要管理员权限）\n   ├─ Windows: %ProgramFiles%\\Google\\Chrome\\NativeMessagingHosts\\\n   ├─ macOS:   /Library/Google/Chrome/NativeMessagingHosts/\n   └─ Linux:   /etc/opt/chrome/native-messaging-hosts/\n```\n\n如果自动注册失败，或者您想手动注册，可以运行：\n\n```bash\nmcp-chrome-bridge register\n```\n\n**推荐：运行诊断工具检查问题：**\n\n```bash\nmcp-chrome-bridge doctor\n```\n\n### 3. 系统级别注册\n\n如果用户级别注册失败（例如，由于权限问题），您可以尝试系统级别注册。系统级别注册需要管理员权限，但我们提供了两种便捷的方式来完成这一过程。\n\n系统级别注册有两种方式：\n\n#### 方式一：使用 `--system` 参数（推荐）\n\n```bash\n# macOS/Linux\nsudo mcp-chrome-bridge register --system\n\n# Windows (以管理员身份运行命令提示符)\nmcp-chrome-bridge register --system\n```\n\n系统级安装需要管理员权限才能写入系统目录和注册表。\n\n#### 方式二：直接使用管理员权限\n\n**Windows**：\n以管理员身份运行命令提示符或 PowerShell，然后执行：\n\n```\nmcp-chrome-bridge register\n```\n\n**macOS/Linux**：\n使用 sudo 命令：\n\n```\nsudo mcp-chrome-bridge register\n```\n\n## 注册流程详解\n\n### 注册流程图\n\n```\n注册流程\n├─ 用户级别注册 (mcp-chrome-bridge register)\n│  ├─ 获取用户级别清单路径\n│  ├─ 创建用户目录\n│  ├─ 生成清单内容\n│  ├─ 写入清单文件\n│  └─ Windows平台：创建用户级注册表项\n│\n└─ 系统级别注册 (mcp-chrome-bridge register --system)\n   ├─ 检查是否有管理员权限\n   │  ├─ 有权限 → 直接创建系统目录和写入清单\n   │  └─ 无权限 → 提示用户使用管理员权限运行\n   └─ Windows平台：创建系统级注册表项\n```\n\n### 清单文件结构\n\n```\nmanifest.json\n├─ name: \"com.chromemcp.nativehost\"\n├─ description: \"Node.js Host for Browser Bridge Extension\"\n├─ path: \"/path/to/run_host.sh\"       ← 启动脚本路径\n├─ type: \"stdio\"                      ← 通信类型\n└─ allowed_origins: [                 ← 允许连接的扩展\n   \"chrome-extension://扩展ID/\"\n]\n```\n\n### 用户级别注册流程\n\n1. 确定用户级别清单文件路径\n2. 创建必要的目录\n3. 生成清单内容，包括：\n   - 主机名称\n   - 描述\n   - Node.js 可执行文件路径\n   - 通信类型（stdio）\n   - 允许的扩展 ID\n   - 启动参数\n4. 写入清单文件\n5. 在 Windows 上，还会创建相应的注册表项\n\n### 系统级别注册流程\n\n1. 检测是否已有管理员权限\n2. 如果已有管理员权限：\n   - 直接创建系统级目录\n   - 写入清单文件\n   - 设置适当的权限\n   - 在 Windows 上创建系统级注册表项\n3. 如果没有管理员权限：\n   - 提示用户使用管理员权限重新运行命令\n   - macOS/Linux: `sudo mcp-chrome-bridge register --system`\n   - Windows: 以管理员身份运行命令提示符\n\n## 验证安装\n\n### 验证流程图\n\n```\n验证安装\n├─ 检查清单文件\n│  ├─ 文件存在 → 检查内容是否正确\n│  └─ 文件不存在 → 重新安装\n│\n├─ 检查Chrome扩展\n│  ├─ 扩展已安装 → 检查扩展权限\n│  └─ 扩展未安装 → 安装扩展\n│\n└─ 测试连接\n   ├─ 连接成功 → 安装完成\n   └─ 连接失败 → 检查错误日志 → 参考故障排除\n```\n\n### 验证步骤\n\n安装完成后，您可以通过以下方式验证安装是否成功：\n\n1. 检查清单文件是否存在于相应目录\n   - 用户级别：检查用户目录下的清单文件\n   - 系统级别：检查系统目录下的清单文件\n   - 确认清单文件内容是否正确\n\n2. 在 Chrome 中安装对应的扩展\n   - 确保扩展已正确安装\n   - 确保扩展有 `nativeMessaging` 权限\n\n3. 尝试通过扩展连接到本地服务\n   - 使用扩展的测试功能尝试连接\n   - 检查 Chrome 的扩展日志是否有错误信息\n\n## 故障排除\n\n### 故障排除流程图\n\n```\n故障排除\n├─ 权限问题\n│  ├─ 检查用户权限\n│  │  ├─ 有足够权限 → 检查目录权限\n│  │  └─ 无足够权限 → 尝试系统级别安装\n│  │\n│  ├─ 执行权限问题 (macOS/Linux)\n│  │  ├─ \"Permission denied\" 错误\n│  │  ├─ \"Native host has exited\" 错误\n│  │  └─ 运行 mcp-chrome-bridge fix-permissions\n│  │\n│  └─ 尝试 mcp-chrome-bridge register --system\n│\n├─ 路径问题\n│  ├─ 检查Node.js安装 (node -v)\n│  └─ 检查全局NPM路径 (npm root -g)\n│\n├─ 注册表问题 (Windows)\n│  ├─ 检查注册表访问权限\n│  └─ 尝试手动创建注册表项\n│\n└─ 其他问题\n   ├─ 检查控制台错误信息\n   └─ 提交Issue到项目仓库\n```\n\n### 常见问题解决步骤\n\n如果安装过程中遇到问题，请尝试以下步骤：\n\n1. 确保 Node.js 已正确安装\n   - 运行 `node -v` 和 `npm -v` 检查版本\n   - 确保 Node.js 版本 >= 20.x\n\n2. 检查是否有足够的权限创建文件和目录\n   - 用户级别安装需要对用户目录有写入权限\n   - 系统级别安装需要管理员/root权限\n\n3. **修复执行权限问题**\n\n   **macOS/Linux 平台**：\n\n   **问题描述**：\n   - npm 安装通常会保留文件权限，但 pnpm 可能不会\n   - 可能遇到 \"Permission denied\" 或 \"Native host has exited\" 错误\n   - Chrome 扩展无法启动 native host 进程\n\n   **解决方案**：\n\n   a) **使用内置修复命令（推荐）**：\n\n   ```bash\n   mcp-chrome-bridge fix-permissions\n   ```\n\n   b) **运行诊断工具自动修复**：\n\n   ```bash\n   mcp-chrome-bridge doctor --fix\n   ```\n\n   c) **手动设置权限**：\n\n   ```bash\n   # 查找安装路径\n   npm list -g mcp-chrome-bridge\n   # 或者对于 pnpm\n   pnpm list -g mcp-chrome-bridge\n\n   # 设置执行权限（替换为实际路径）\n   chmod +x /path/to/node_modules/mcp-chrome-bridge/run_host.sh\n   chmod +x /path/to/node_modules/mcp-chrome-bridge/index.js\n   chmod +x /path/to/node_modules/mcp-chrome-bridge/cli.js\n   ```\n\n   **Windows 平台**：\n\n   **问题描述**：\n   - Windows 上 `.bat` 文件通常不需要执行权限，但可能遇到其他问题\n   - 文件可能被标记为只读\n   - 可能遇到 \"Access denied\" 或文件无法执行的错误\n\n   **解决方案**：\n\n   a) **使用内置修复命令（推荐）**：\n\n   ```cmd\n   mcp-chrome-bridge fix-permissions\n   ```\n\n   b) **运行诊断工具自动修复**：\n\n   ```cmd\n   mcp-chrome-bridge doctor --fix\n   ```\n\n   c) **手动检查文件属性**：\n\n   ```cmd\n   # 查找安装路径\n   npm list -g mcp-chrome-bridge\n\n   # 检查文件属性（在文件资源管理器中右键 -> 属性）\n   # 确保 run_host.bat 不是只读文件\n   ```\n\n   d) **重新安装并强制权限**：\n\n   ```bash\n   # 卸载\n   npm uninstall -g mcp-chrome-bridge\n   # 或 pnpm uninstall -g mcp-chrome-bridge\n\n   # 重新安装\n   npm install -g mcp-chrome-bridge\n   # 或 pnpm install -g mcp-chrome-bridge\n\n   # 如果仍有问题，运行权限修复\n   mcp-chrome-bridge fix-permissions\n   ```\n\n4. 在 Windows 上，确保注册表访问没有被限制\n   - 检查是否可以访问 `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\`\n   - 对于系统级别，检查 `HKLM\\Software\\Google\\Chrome\\NativeMessagingHosts\\`\n\n5. 尝试使用系统级别安装\n   - 使用 `mcp-chrome-bridge register --system` 命令\n   - 或直接使用管理员权限运行\n\n6. 检查控制台输出的错误信息\n   - 详细的错误信息通常会指出问题所在\n   - 可以添加 `--verbose` 参数获取更多日志信息\n\n如果问题仍然存在，请提交 issue 到项目仓库，并附上以下信息：\n\n- 操作系统版本\n- Node.js 版本\n- 安装命令\n- 错误信息\n- 尝试过的解决方法\n"
  },
  {
    "path": "app/native-server/jest.config.js",
    "content": "module.exports = {\n  preset: 'ts-jest',\n  testEnvironment: 'node',\n  roots: ['<rootDir>/src'],\n  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],\n  collectCoverage: true,\n  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/scripts/**/*'],\n  coverageDirectory: 'coverage',\n  coverageThreshold: {\n    global: {\n      branches: 70,\n      functions: 80,\n      lines: 80,\n      statements: 80,\n    },\n  },\n};\n"
  },
  {
    "path": "app/native-server/package.json",
    "content": "{\n  \"name\": \"mcp-chrome-bridge\",\n  \"version\": \"1.0.29\",\n  \"description\": \"Chrome Native-Messaging host (Node)\",\n  \"main\": \"dist/index.js\",\n  \"bin\": {\n    \"mcp-chrome-bridge\": \"./dist/cli.js\",\n    \"chrome-mcp-bridge\": \"./dist/cli.js\",\n    \"mcp-chrome-stdio\": \"./dist/mcp/mcp-server-stdio.js\"\n  },\n  \"scripts\": {\n    \"dev\": \"nodemon --watch src --ext ts,js,json --ignore dist/ --exec \\\"npm run build && npm run register:dev\\\"\",\n    \"build\": \"ts-node src/scripts/build.ts\",\n    \"test\": \"jest\",\n    \"test:watch\": \"jest --watch\",\n    \"lint\": \"eslint 'src/**/*.{js,ts}'\",\n    \"lint:fix\": \"eslint 'src/**/*.{js,ts}' --fix\",\n    \"format\": \"prettier --write 'src/**/*.{js,ts,json}'\",\n    \"register:dev\": \"node dist/scripts/register-dev.js\",\n    \"postinstall\": \"node dist/scripts/postinstall.js\"\n  },\n  \"files\": [\n    \"dist\",\n    \"!dist/node_path.txt\"\n  ],\n  \"engines\": {\n    \"node\": \">=20.0.0\"\n  },\n  \"preferGlobal\": true,\n  \"keywords\": [\n    \"mcp\",\n    \"chrome\",\n    \"browser\"\n  ],\n  \"author\": \"hangye\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@anthropic-ai/claude-agent-sdk\": \"^0.1.69\",\n    \"@fastify/cors\": \"^11.0.1\",\n    \"@modelcontextprotocol/sdk\": \"^1.11.0\",\n    \"@types/node-fetch\": \"2\",\n    \"better-sqlite3\": \"^11.6.0\",\n    \"chalk\": \"^5.4.1\",\n    \"chrome-devtools-frontend\": \"^1.0.1299282\",\n    \"chrome-mcp-shared\": \"workspace:*\",\n    \"commander\": \"^13.1.0\",\n    \"drizzle-orm\": \"^0.38.2\",\n    \"fastify\": \"^5.3.2\",\n    \"is-admin\": \"^4.0.0\",\n    \"node-fetch\": \"2\",\n    \"pino\": \"^9.6.0\",\n    \"uuid\": \"^11.1.0\"\n  },\n  \"devDependencies\": {\n    \"@jest/globals\": \"^29.7.0\",\n    \"@types/better-sqlite3\": \"^7.6.12\",\n    \"@types/chrome\": \"^0.0.318\",\n    \"@types/jest\": \"^29.5.14\",\n    \"@types/node\": \"^22.15.3\",\n    \"@types/supertest\": \"^6.0.3\",\n    \"@typescript-eslint/parser\": \"^8.31.1\",\n    \"cross-env\": \"^7.0.3\",\n    \"husky\": \"^9.1.7\",\n    \"jest\": \"^29.7.0\",\n    \"lint-staged\": \"^15.5.1\",\n    \"nodemon\": \"^3.1.10\",\n    \"pino-pretty\": \"^13.0.0\",\n    \"rimraf\": \"^6.0.1\",\n    \"supertest\": \"^7.1.0\",\n    \"ts-jest\": \"^29.3.2\",\n    \"ts-node\": \"^10.9.2\"\n  },\n  \"husky\": {\n    \"hooks\": {\n      \"pre-commit\": \"lint-staged\"\n    }\n  },\n  \"lint-staged\": {\n    \"*.{js,ts}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ],\n    \"*.{json,md}\": [\n      \"prettier --write\"\n    ]\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/attachment-service.ts",
    "content": "/**\n * Attachment Service for persisting and managing image attachments.\n *\n * Handles:\n * - Saving attachments to persistent storage (not temp files)\n * - Getting attachment statistics per project\n * - Cleaning up attachments by project or all\n *\n * Storage structure:\n *   ~/.chrome-mcp-agent/attachments/{projectId}/{messageId}-{index}-{uuid}.{ext}\n */\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { randomUUID } from 'node:crypto';\nimport type {\n  AgentAttachment,\n  AttachmentMetadata,\n  AttachmentProjectStats,\n} from 'chrome-mcp-shared';\nimport { getAgentDataDir } from './storage';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface SaveAttachmentInput {\n  projectId: string;\n  messageId: string;\n  attachment: AgentAttachment;\n  index: number;\n}\n\nexport interface SavedAttachment {\n  /** Absolute path on disk (for engines) */\n  absolutePath: string;\n  /** Persisted filename under project dir */\n  filename: string;\n  /** Metadata to store in message.metadata.attachments */\n  metadata: AttachmentMetadata;\n}\n\nexport interface AttachmentStats {\n  rootDir: string;\n  totalFiles: number;\n  totalBytes: number;\n  projects: AttachmentProjectStats[];\n}\n\nexport interface CleanupAttachmentsInput {\n  /** If omitted, cleanup all project dirs under root */\n  projectIds?: string[];\n}\n\nexport interface CleanupProjectResult {\n  projectId: string;\n  dirPath: string;\n  existed: boolean;\n  removedFiles: number;\n  removedBytes: number;\n}\n\nexport interface CleanupResult {\n  rootDir: string;\n  removedFiles: number;\n  removedBytes: number;\n  results: CleanupProjectResult[];\n}\n\n// ============================================================\n// Constants\n// ============================================================\n\nconst ATTACHMENTS_DIR_NAME = 'attachments';\n\n/** Allowed MIME types for image attachments */\nconst ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Convert MIME type to file extension.\n */\nfunction mimeTypeToExt(mimeType: string): string {\n  switch (mimeType) {\n    case 'image/png':\n      return 'png';\n    case 'image/jpeg':\n      return 'jpg';\n    case 'image/gif':\n      return 'gif';\n    case 'image/webp':\n      return 'webp';\n    default:\n      return 'bin';\n  }\n}\n\n/**\n * Build a unique filename for an attachment.\n * Format: {messageId}-{index}-{uuid}.{ext}\n */\nfunction buildAttachmentFilename(params: {\n  messageId: string;\n  index: number;\n  mimeType: string;\n}): string {\n  const ext = mimeTypeToExt(params.mimeType);\n  const uuid = randomUUID().slice(0, 8);\n  return `${params.messageId}-${params.index}-${uuid}.${ext}`;\n}\n\n/**\n * Validate filename to prevent path traversal attacks.\n */\nfunction isValidFilename(filename: string): boolean {\n  // Reject empty, path separators, parent directory references\n  if (!filename || filename.includes('/') || filename.includes('\\\\')) {\n    return false;\n  }\n  if (filename === '.' || filename === '..' || filename.startsWith('.')) {\n    return false;\n  }\n  // Only allow alphanumeric, dash, underscore, dot\n  return /^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9]+$/.test(filename);\n}\n\n/**\n * Validate projectId to prevent path traversal attacks.\n */\nfunction isValidProjectId(projectId: string): boolean {\n  if (!projectId) return false;\n  // UUID format or alphanumeric with dashes\n  return /^[a-zA-Z0-9_-]+$/.test(projectId);\n}\n\n// ============================================================\n// AttachmentService Class\n// ============================================================\n\nexport class AttachmentService {\n  /**\n   * Get the root directory for all attachments.\n   */\n  getAttachmentsRootDir(): string {\n    return path.join(getAgentDataDir(), ATTACHMENTS_DIR_NAME);\n  }\n\n  /**\n   * Get the directory for a specific project's attachments.\n   */\n  getProjectAttachmentsDir(projectId: string): string {\n    if (!isValidProjectId(projectId)) {\n      throw new Error(`Invalid projectId: ${projectId}`);\n    }\n    return path.join(this.getAttachmentsRootDir(), projectId);\n  }\n\n  /**\n   * Get the absolute path for a specific attachment file.\n   * Validates to prevent path traversal attacks.\n   */\n  getAttachmentPath(projectId: string, filename: string): string {\n    if (!isValidProjectId(projectId)) {\n      throw new Error(`Invalid projectId: ${projectId}`);\n    }\n    if (!isValidFilename(filename)) {\n      throw new Error(`Invalid filename: ${filename}`);\n    }\n\n    const projectDir = this.getProjectAttachmentsDir(projectId);\n    const filePath = path.join(projectDir, filename);\n\n    // Double-check resolved path is within project directory (defense in depth)\n    const resolved = path.resolve(filePath);\n    const resolvedProjectDir = path.resolve(projectDir);\n    if (!resolved.startsWith(resolvedProjectDir + path.sep)) {\n      throw new Error('Path traversal attempt detected');\n    }\n\n    return filePath;\n  }\n\n  /**\n   * Save an attachment to persistent storage.\n   * Creates directories if needed.\n   */\n  async saveAttachment(input: SaveAttachmentInput): Promise<SavedAttachment> {\n    const { projectId, messageId, attachment, index } = input;\n\n    // Validate input\n    if (!isValidProjectId(projectId)) {\n      throw new Error(`Invalid projectId: ${projectId}`);\n    }\n    if (attachment.type !== 'image') {\n      throw new Error(`Unsupported attachment type: ${attachment.type}`);\n    }\n    if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) {\n      throw new Error(`Unsupported MIME type: ${attachment.mimeType}`);\n    }\n\n    // Build filename and paths\n    const filename = buildAttachmentFilename({\n      messageId,\n      index,\n      mimeType: attachment.mimeType,\n    });\n    const projectDir = this.getProjectAttachmentsDir(projectId);\n    const absolutePath = path.join(projectDir, filename);\n\n    // Decode base64 and get size\n    const buffer = Buffer.from(attachment.dataBase64, 'base64');\n    const sizeBytes = buffer.length;\n\n    // Create directory and write file\n    await fs.mkdir(projectDir, { recursive: true });\n    await fs.writeFile(absolutePath, buffer);\n\n    // Build metadata\n    const metadata: AttachmentMetadata = {\n      version: 1,\n      kind: 'image',\n      projectId,\n      messageId,\n      index,\n      filename,\n      urlPath: `/agent/attachments/${projectId}/${filename}`,\n      mimeType: attachment.mimeType,\n      sizeBytes,\n      originalName: attachment.name,\n      createdAt: new Date().toISOString(),\n    };\n\n    console.error(`[AttachmentService] Saved attachment: ${absolutePath} (${sizeBytes} bytes)`);\n\n    return {\n      absolutePath,\n      filename,\n      metadata,\n    };\n  }\n\n  /**\n   * Get statistics for all attachments.\n   */\n  async getAttachmentStats(): Promise<AttachmentStats> {\n    const rootDir = this.getAttachmentsRootDir();\n    const projects: AttachmentProjectStats[] = [];\n    let totalFiles = 0;\n    let totalBytes = 0;\n\n    try {\n      // Check if root directory exists\n      await fs.access(rootDir);\n\n      // Read all project directories\n      const entries = await fs.readdir(rootDir, { withFileTypes: true });\n\n      for (const entry of entries) {\n        if (!entry.isDirectory()) continue;\n\n        const projectId = entry.name;\n        const dirPath = path.join(rootDir, projectId);\n\n        try {\n          const stats = await this.getProjectStats(projectId, dirPath);\n          projects.push(stats);\n          totalFiles += stats.fileCount;\n          totalBytes += stats.totalBytes;\n        } catch (error) {\n          // Skip directories we can't read\n          console.error(`[AttachmentService] Failed to stat project ${projectId}:`, error);\n        }\n      }\n    } catch {\n      // Root directory doesn't exist - return empty stats\n    }\n\n    return {\n      rootDir,\n      totalFiles,\n      totalBytes,\n      projects,\n    };\n  }\n\n  /**\n   * Get statistics for a single project.\n   */\n  private async getProjectStats(\n    projectId: string,\n    dirPath: string,\n  ): Promise<AttachmentProjectStats> {\n    let fileCount = 0;\n    let totalBytes = 0;\n    let lastModifiedAt: string | undefined;\n    let latestMtime = 0;\n\n    try {\n      const files = await fs.readdir(dirPath);\n\n      for (const file of files) {\n        const filePath = path.join(dirPath, file);\n        try {\n          const stat = await fs.stat(filePath);\n          if (stat.isFile()) {\n            fileCount++;\n            totalBytes += stat.size;\n            if (stat.mtimeMs > latestMtime) {\n              latestMtime = stat.mtimeMs;\n              lastModifiedAt = stat.mtime.toISOString();\n            }\n          }\n        } catch {\n          // Skip files we can't stat\n        }\n      }\n\n      return {\n        projectId,\n        dirPath,\n        exists: true,\n        fileCount,\n        totalBytes,\n        lastModifiedAt,\n      };\n    } catch {\n      return {\n        projectId,\n        dirPath,\n        exists: false,\n        fileCount: 0,\n        totalBytes: 0,\n      };\n    }\n  }\n\n  /**\n   * Cleanup attachments for specified projects or all projects.\n   */\n  async cleanupAttachments(input?: CleanupAttachmentsInput): Promise<CleanupResult> {\n    const rootDir = this.getAttachmentsRootDir();\n    const results: CleanupProjectResult[] = [];\n    let totalRemovedFiles = 0;\n    let totalRemovedBytes = 0;\n\n    // Determine which projects to clean\n    let projectIds: string[];\n\n    if (input?.projectIds && input.projectIds.length > 0) {\n      // Clean specific projects\n      projectIds = input.projectIds;\n    } else {\n      // Clean all projects - enumerate from filesystem\n      try {\n        const entries = await fs.readdir(rootDir, { withFileTypes: true });\n        projectIds = entries.filter((e) => e.isDirectory()).map((e) => e.name);\n      } catch {\n        // Root doesn't exist - nothing to clean\n        return {\n          rootDir,\n          removedFiles: 0,\n          removedBytes: 0,\n          results: [],\n        };\n      }\n    }\n\n    // Clean each project\n    for (const projectId of projectIds) {\n      if (!isValidProjectId(projectId)) {\n        console.error(`[AttachmentService] Skipping invalid projectId: ${projectId}`);\n        continue;\n      }\n\n      const result = await this.cleanupProject(projectId);\n      results.push(result);\n      totalRemovedFiles += result.removedFiles;\n      totalRemovedBytes += result.removedBytes;\n    }\n\n    return {\n      rootDir,\n      removedFiles: totalRemovedFiles,\n      removedBytes: totalRemovedBytes,\n      results,\n    };\n  }\n\n  /**\n   * Cleanup attachments for a single project.\n   */\n  private async cleanupProject(projectId: string): Promise<CleanupProjectResult> {\n    const dirPath = this.getProjectAttachmentsDir(projectId);\n\n    try {\n      // Get stats before deletion\n      const stats = await this.getProjectStats(projectId, dirPath);\n\n      if (!stats.exists) {\n        return {\n          projectId,\n          dirPath,\n          existed: false,\n          removedFiles: 0,\n          removedBytes: 0,\n        };\n      }\n\n      // Remove directory and all contents\n      await fs.rm(dirPath, { recursive: true, force: true });\n\n      console.error(\n        `[AttachmentService] Cleaned up ${stats.fileCount} files (${stats.totalBytes} bytes) for project ${projectId}`,\n      );\n\n      return {\n        projectId,\n        dirPath,\n        existed: true,\n        removedFiles: stats.fileCount,\n        removedBytes: stats.totalBytes,\n      };\n    } catch (error) {\n      console.error(`[AttachmentService] Failed to cleanup project ${projectId}:`, error);\n      return {\n        projectId,\n        dirPath,\n        existed: false,\n        removedFiles: 0,\n        removedBytes: 0,\n      };\n    }\n  }\n\n  /**\n   * Check if an attachment file exists.\n   */\n  async attachmentExists(projectId: string, filename: string): Promise<boolean> {\n    try {\n      const filePath = this.getAttachmentPath(projectId, filename);\n      await fs.access(filePath);\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Read an attachment file.\n   */\n  async readAttachment(projectId: string, filename: string): Promise<Buffer> {\n    const filePath = this.getAttachmentPath(projectId, filename);\n    return fs.readFile(filePath);\n  }\n}\n\n// ============================================================\n// Singleton Export\n// ============================================================\n\nexport const attachmentService = new AttachmentService();\n"
  },
  {
    "path": "app/native-server/src/agent/ccr-detector.ts",
    "content": "/**\n * Claude Code Router (CCR) Auto-Detection Module.\n *\n * This module provides automatic detection of CCR configuration\n * for users who have already set up CCR on their system.\n *\n * CCR config location: ~/.claude-code-router/config.json\n * CCR uses env vars: ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN\n *\n * The detection flow:\n * 1. Check if CCR env vars are already set (skip if yes)\n * 2. Read CCR config file\n * 3. Parse JSON5 config with env var interpolation\n * 4. Verify CCR is running via health check\n * 5. Return derived env vars if healthy\n */\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\n\n/**\n * Result of CCR detection.\n */\nexport interface CcrDetectionResult {\n  detected: boolean;\n  baseUrl?: string;\n  authToken?: string;\n  source?: 'env' | 'config';\n  error?: string;\n}\n\n/**\n * Result of validating CCR configuration.\n */\nexport interface CcrValidationResult {\n  /** Whether a CCR config file was found and inspected */\n  checked: boolean;\n  /** Whether the configuration is valid */\n  valid: boolean;\n  /** Path to the CCR config file */\n  configPath: string;\n  /** Current Router.default value if available */\n  routerDefault?: string;\n  /** Human-readable issue description when valid is false */\n  issue?: string;\n  /** Suggested Router.default value in \"provider,model\" format */\n  suggestedFix?: string;\n  /** Full suggestion message for the user */\n  suggestion?: string;\n}\n\n/**\n * CCR Router configuration.\n */\ninterface CcrRouterConfig {\n  default?: string;\n  background?: string;\n  think?: string;\n  longContext?: string;\n  webSearch?: string;\n  image?: string;\n}\n\n/**\n * CCR Provider configuration.\n */\ninterface CcrProviderConfig {\n  name?: string;\n  models?: string[];\n}\n\n/**\n * CCR configuration structure.\n * Note: CCR uses uppercase field names in config.json\n */\ninterface CcrConfig {\n  // Uppercase (actual CCR config format)\n  PORT?: number;\n  HOST?: string;\n  APIKEY?: string;\n  Router?: CcrRouterConfig;\n  Providers?: CcrProviderConfig[];\n  // Lowercase (for compatibility)\n  port?: number;\n  host?: string;\n  apiKey?: string;\n  router?: CcrRouterConfig;\n  providers?: CcrProviderConfig[];\n}\n\n/**\n * Default CCR port.\n */\nconst DEFAULT_CCR_PORT = 9898;\n\n/**\n * CCR config file path.\n */\nconst CCR_CONFIG_PATH = path.join(os.homedir(), '.claude-code-router', 'config.json');\n\n/**\n * Health check timeout in milliseconds.\n */\nconst HEALTH_CHECK_TIMEOUT = 2000;\n\n/**\n * Cache for CCR detection result (to avoid repeated file reads and health checks).\n * Cached for the lifetime of the process.\n */\nlet cachedResult: CcrDetectionResult | null = null;\nlet cacheTimestamp = 0;\nconst CACHE_TTL = 60000; // 1 minute\n\n/**\n * Detect CCR configuration and verify it's running.\n *\n * This function:\n * 1. Returns cached result if still valid\n * 2. Checks if CCR env vars are already set in process.env\n * 3. If not, reads and parses CCR config file\n * 4. Verifies CCR is running via health check\n *\n * @returns Detection result with baseUrl and authToken if CCR is available\n */\nexport async function detectCcr(): Promise<CcrDetectionResult> {\n  // Check cache\n  const now = Date.now();\n  if (cachedResult && now - cacheTimestamp < CACHE_TTL) {\n    return cachedResult;\n  }\n\n  try {\n    // First, check if env vars are already set (user ran `eval \"$(ccr activate)\"`)\n    const envBaseUrl = process.env.ANTHROPIC_BASE_URL;\n    const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;\n\n    if (envBaseUrl && envAuthToken) {\n      // Verify CCR is running\n      const healthy = await checkCcrHealth(envBaseUrl);\n      if (healthy) {\n        cachedResult = {\n          detected: true,\n          baseUrl: envBaseUrl,\n          authToken: envAuthToken,\n          source: 'env',\n        };\n        cacheTimestamp = now;\n        return cachedResult;\n      }\n      // Env vars set but CCR not healthy - fall through to config detection\n    }\n\n    // Try to read CCR config file\n    const configResult = await readCcrConfig();\n    if (!configResult.config) {\n      cachedResult = {\n        detected: false,\n        error: configResult.error || 'CCR config not found or invalid',\n      };\n      cacheTimestamp = now;\n      return cachedResult;\n    }\n    const config = configResult.config;\n\n    // Derive env vars from config (support both uppercase and lowercase field names)\n    const port = config.PORT ?? config.port ?? DEFAULT_CCR_PORT;\n    const host = config.HOST ?? config.host ?? '127.0.0.1';\n    const baseUrl = `http://${host}:${port}`;\n    // APIKEY can be empty string in config, use 'APIKEY' as fallback (CCR accepts this)\n    const apiKey = config.APIKEY ?? config.apiKey;\n    const authToken = apiKey && apiKey.length > 0 ? apiKey : 'APIKEY';\n\n    // Verify CCR is running\n    const healthy = await checkCcrHealth(baseUrl);\n    if (!healthy) {\n      cachedResult = {\n        detected: false,\n        error: 'CCR config found but service not running',\n      };\n      cacheTimestamp = now;\n      return cachedResult;\n    }\n\n    cachedResult = {\n      detected: true,\n      baseUrl,\n      authToken,\n      source: 'config',\n    };\n    cacheTimestamp = now;\n    return cachedResult;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    cachedResult = { detected: false, error: message };\n    cacheTimestamp = now;\n    return cachedResult;\n  }\n}\n\n/**\n * Result of reading CCR config.\n */\ninterface ReadConfigResult {\n  config: CcrConfig | null;\n  error?: string;\n}\n\n/**\n * Read and parse CCR config file.\n */\nasync function readCcrConfig(): Promise<ReadConfigResult> {\n  try {\n    const content = await readFile(CCR_CONFIG_PATH, 'utf-8');\n    const config = parseJson5Config(content);\n    if (!config) {\n      return { config: null, error: 'Failed to parse CCR config file' };\n    }\n    return { config };\n  } catch (error) {\n    const err = error as NodeJS.ErrnoException;\n    if (err.code === 'ENOENT') {\n      // Config file doesn't exist - CCR not installed\n      return { config: null, error: 'CCR config file not found' };\n    }\n    return { config: null, error: `Failed to read CCR config: ${err.message}` };\n  }\n}\n\n/**\n * Parse CCR config file.\n *\n * CCR config is standard JSON (not JSON5), so we can use JSON.parse directly.\n * We only need to handle env var interpolation: ${VAR_NAME}\n *\n * Note: Previous implementation tried to strip comments using regex which\n * incorrectly matched \"http://\" URLs inside strings.\n */\nfunction parseJson5Config(content: string): CcrConfig | null {\n  try {\n    // First try standard JSON parse (CCR config is usually valid JSON)\n    // Only interpolate env vars if needed\n    let processed = content;\n\n    // Interpolate env vars: ${VAR_NAME} -> value\n    // Only do this outside of the JSON parsing to avoid breaking strings\n    if (content.includes('${')) {\n      processed = content.replace(/\\$\\{([^}]+)\\}/g, (_, varName) => {\n        const value = process.env[varName.trim()];\n        return value || '';\n      });\n    }\n\n    const parsed = JSON.parse(processed);\n    return parsed as CcrConfig;\n  } catch (parseError) {\n    // Log parse error for debugging\n    console.error('[CCR] Failed to parse config:', parseError);\n    return null;\n  }\n}\n\n/**\n * Check if CCR is running by hitting its health endpoint.\n */\nasync function checkCcrHealth(baseUrl: string): Promise<boolean> {\n  try {\n    const healthUrl = `${baseUrl}/health`;\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);\n\n    try {\n      const response = await fetch(healthUrl, {\n        method: 'GET',\n        signal: controller.signal,\n      });\n      clearTimeout(timeoutId);\n      return response.ok;\n    } catch {\n      clearTimeout(timeoutId);\n      return false;\n    }\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Clear the CCR detection cache.\n * Useful for testing or when user wants to re-detect.\n */\nexport function clearCcrCache(): void {\n  cachedResult = null;\n  cacheTimestamp = 0;\n}\n\n/**\n * Validate CCR configuration for common misconfigurations.\n *\n * This function checks for issues that would cause runtime errors in CCR,\n * particularly the \"Router.default must be provider,model\" requirement.\n *\n * The most common misconfiguration is setting Router.default to just a provider\n * name (e.g., \"venus\") instead of the required \"provider,model\" format\n * (e.g., \"venus,claude-4-5-sonnet-20250929\"). This causes CCR to crash with\n * \"Cannot read properties of undefined (reading 'includes')\" when it tries\n * to split the model name.\n */\nexport async function validateCcrConfig(): Promise<CcrValidationResult> {\n  const configResult = await readCcrConfig();\n\n  // If we can't read the config, return early (not our problem to report)\n  if (!configResult.config) {\n    return {\n      checked: false,\n      valid: true,\n      configPath: CCR_CONFIG_PATH,\n      issue: configResult.error,\n    };\n  }\n\n  const config = configResult.config;\n  const router = config.Router ?? config.router;\n  const routerDefault = router?.default?.trim();\n\n  // No Router.default configured\n  if (!routerDefault) {\n    return {\n      checked: true,\n      valid: false,\n      configPath: CCR_CONFIG_PATH,\n      issue: 'CCR Router.default is not configured.',\n      suggestion: `Edit ${CCR_CONFIG_PATH} and set Router.default to \"provider,model\" format, then restart CCR.`,\n    };\n  }\n\n  // Check if Router.default contains a comma (required format: provider,model)\n  if (!routerDefault.includes(',')) {\n    const suggestedFix = inferSuggestedRouterDefault(routerDefault, config, router);\n    const example = suggestedFix ?? `${routerDefault},<model>`;\n\n    return {\n      checked: true,\n      valid: false,\n      configPath: CCR_CONFIG_PATH,\n      routerDefault,\n      issue: `CCR Router.default must be \"provider,model\" format, but got \"${routerDefault}\" (missing model).`,\n      suggestedFix,\n      suggestion: `Edit ${CCR_CONFIG_PATH} and change Router.default from \"${routerDefault}\" to \"${example}\", then restart CCR.`,\n    };\n  }\n\n  // Validate the model part is not empty after splitting\n  const [providerPart, modelPart] = routerDefault.split(',', 2);\n  if (!providerPart?.trim() || !modelPart?.trim()) {\n    const suggestedFix = inferSuggestedRouterDefault(providerPart?.trim() ?? '', config, router);\n    return {\n      checked: true,\n      valid: false,\n      configPath: CCR_CONFIG_PATH,\n      routerDefault,\n      issue: `CCR Router.default \"${routerDefault}\" has empty provider or model part.`,\n      suggestedFix,\n      suggestion: `Edit ${CCR_CONFIG_PATH} and set Router.default to a valid \"provider,model\" format, then restart CCR.`,\n    };\n  }\n\n  return {\n    checked: true,\n    valid: true,\n    configPath: CCR_CONFIG_PATH,\n    routerDefault,\n  };\n}\n\n/**\n * Try to infer a suggested Router.default value based on available providers and models.\n */\nfunction inferSuggestedRouterDefault(\n  providerName: string,\n  config: CcrConfig,\n  router?: CcrRouterConfig,\n): string | undefined {\n  const normalizedProvider = providerName.toLowerCase();\n  if (!normalizedProvider) return undefined;\n\n  // Try to find the provider in Providers array and get its first model\n  const providers = config.Providers ?? config.providers ?? [];\n  const matchedProvider = providers.find((p) => p.name?.toLowerCase() === normalizedProvider);\n\n  if (matchedProvider?.name && matchedProvider.models?.[0]) {\n    return `${matchedProvider.name},${matchedProvider.models[0]}`;\n  }\n\n  // Fallback: look at other Router entries that have valid \"provider,model\" format\n  const routerEntries = [router?.background, router?.think, router?.longContext];\n  for (const entry of routerEntries) {\n    if (!entry || !entry.includes(',')) continue;\n\n    const [p, m] = entry.split(',', 2);\n    if (p?.trim().toLowerCase() === normalizedProvider && m?.trim()) {\n      return `${providerName},${m.trim()}`;\n    }\n  }\n\n  return undefined;\n}\n"
  },
  {
    "path": "app/native-server/src/agent/chat-service.ts",
    "content": "import { randomUUID } from 'node:crypto';\nimport type { AgentActRequest } from './types';\nimport type {\n  AgentEngine,\n  EngineExecutionContext,\n  EngineName,\n  EngineInitOptions,\n  RunningExecution,\n} from './engines/types';\nimport type { AgentMessage, RealtimeEvent } from './types';\nimport type { AttachmentMetadata } from 'chrome-mcp-shared';\nimport { AgentStreamManager } from './stream-manager';\nimport { getProject, touchProjectActivity, updateProjectClaudeSessionId } from './project-service';\nimport { createMessage as persistAgentMessage } from './message-service';\nimport {\n  getSession,\n  updateEngineSessionId,\n  updateManagementInfo,\n  touchSessionActivity,\n  type AgentSession,\n} from './session-service';\nimport { attachmentService, type SavedAttachment } from './attachment-service';\n\nexport interface AgentChatServiceOptions {\n  engines: AgentEngine[];\n  streamManager: AgentStreamManager;\n  defaultEngineName?: EngineName;\n}\n\n/**\n * AgentChatService coordinates incoming /agent/chat requests and delegates to engines.\n *\n * 中文说明：该服务负责会话级调度，不关心具体 CLI/SDK 实现细节。\n * 通过 Engine 接口实现依赖倒置，后续替换或新增引擎时无需修改 HTTP 路由层。\n */\nexport class AgentChatService {\n  private readonly engines = new Map<EngineName, AgentEngine>();\n  private readonly streamManager: AgentStreamManager;\n  private readonly defaultEngineName: EngineName;\n\n  /**\n   * Registry of currently running executions, keyed by requestId.\n   */\n  private readonly runningExecutions = new Map<string, RunningExecution>();\n\n  constructor(options: AgentChatServiceOptions) {\n    this.streamManager = options.streamManager;\n\n    for (const engine of options.engines) {\n      this.engines.set(engine.name, engine);\n    }\n\n    if (options.defaultEngineName && this.engines.has(options.defaultEngineName)) {\n      this.defaultEngineName = options.defaultEngineName;\n    } else {\n      // Fallback to first registered engine to avoid hard-coding 'claude' here.\n      const firstEngine = options.engines[0];\n      if (!firstEngine) {\n        throw new Error('AgentChatService requires at least one engine');\n      }\n      this.defaultEngineName = firstEngine.name;\n    }\n  }\n\n  async handleAct(sessionId: string, payload: AgentActRequest): Promise<{ requestId: string }> {\n    const trimmed = payload.instruction?.trim();\n    if (!trimmed) {\n      throw new Error('instruction is required');\n    }\n\n    const requestId = payload.requestId || randomUUID();\n    let projectId = payload.projectId;\n    // Normalize empty string to undefined\n    const rawDbSessionId =\n      typeof payload.dbSessionId === 'string' ? payload.dbSessionId.trim() : '';\n    const dbSessionId = rawDbSessionId || undefined;\n\n    // Load session from database if dbSessionId is provided\n    let dbSession: AgentSession | undefined;\n    if (dbSessionId) {\n      dbSession = await getSession(dbSessionId);\n      if (!dbSession) {\n        throw new Error(`Session not found for id: ${dbSessionId}`);\n      }\n      // Validate project association\n      if (projectId && dbSession.projectId !== projectId) {\n        throw new Error(`Session ${dbSessionId} does not belong to project: ${projectId}`);\n      }\n      // Use session's project if not explicitly provided\n      if (!projectId) {\n        projectId = dbSession.projectId;\n      }\n    }\n\n    // Project is required - workspace path must come from project system\n    if (!projectId) {\n      throw new Error('projectId is required. Please select or create a project first.');\n    }\n\n    const project = await getProject(projectId);\n    if (!project) {\n      throw new Error(`Project not found for id: ${projectId}`);\n    }\n\n    const projectRoot = project.rootPath;\n    const projectPreferredCli = project.preferredCli as EngineName | undefined;\n    const projectSelectedModel = project.selectedModel;\n    const projectUseCcr = project.useCcr;\n\n    // Legacy fallback: if caller does not use sessions table, use project-level resume id\n    let resumeClaudeSessionId: string | undefined;\n    if (!dbSessionId) {\n      resumeClaudeSessionId = project.activeClaudeSessionId;\n    }\n\n    // Resolve engine name - session binding takes precedence\n    let engineName: EngineName;\n    if (dbSession) {\n      engineName = dbSession.engineName as EngineName;\n      // Validate cliPreference matches session engine\n      if (payload.cliPreference && payload.cliPreference !== engineName) {\n        throw new Error(\n          `cliPreference (${payload.cliPreference}) does not match session.engineName (${engineName})`,\n        );\n      }\n    } else {\n      engineName = this.resolveEngineName(\n        payload.cliPreference as EngineName | undefined,\n        projectPreferredCli,\n      );\n    }\n\n    const engine = this.engines.get(engineName);\n    if (!engine) {\n      throw new Error(`No agent engine registered for ${engineName}`);\n    }\n\n    // Model priority: request > session > project\n    const effectiveModel = payload.model?.trim() || dbSession?.model || projectSelectedModel;\n\n    // For Claude engine with session, use session's engineSessionId for resume\n    if (dbSession && engineName === 'claude') {\n      resumeClaudeSessionId = dbSession.engineSessionId;\n    }\n\n    const now = new Date().toISOString();\n    const userMessageId = randomUUID();\n\n    // Process and persist image attachments\n    const savedAttachments: SavedAttachment[] = [];\n    let attachmentMetadata: AttachmentMetadata[] | undefined;\n    let resolvedImagePaths: string[] | undefined;\n\n    if (projectId && payload.attachments && payload.attachments.length > 0) {\n      const imageAttachments = payload.attachments.filter((a) => a.type === 'image');\n\n      if (imageAttachments.length > 0) {\n        try {\n          console.error(\n            `[AgentChatService] Saving ${imageAttachments.length} image attachment(s) for project ${projectId}`,\n          );\n\n          for (let i = 0; i < imageAttachments.length; i++) {\n            const attachment = imageAttachments[i];\n            const saved = await attachmentService.saveAttachment({\n              projectId,\n              messageId: userMessageId,\n              attachment,\n              index: i,\n            });\n            savedAttachments.push(saved);\n          }\n\n          // Build metadata array for message persistence\n          attachmentMetadata = savedAttachments.map((s) => s.metadata);\n          // Build paths array for engine consumption\n          resolvedImagePaths = savedAttachments.map((s) => s.absolutePath);\n\n          console.error(\n            `[AgentChatService] Saved ${savedAttachments.length} attachment(s): ${resolvedImagePaths.join(', ')}`,\n          );\n        } catch (error) {\n          console.error('[AgentChatService] Failed to save attachments:', error);\n          // Continue without attachments - don't fail the entire request\n        }\n      }\n    }\n\n    // Build metadata object for user message\n    // Include attachments, clientMeta, and displayText if present\n    let userMessageMetadata: Record<string, unknown> | undefined;\n    const hasAttachments = attachmentMetadata && attachmentMetadata.length > 0;\n    const hasClientMeta = payload.clientMeta !== undefined;\n    const hasDisplayText = payload.displayText !== undefined;\n\n    if (hasAttachments || hasClientMeta || hasDisplayText) {\n      userMessageMetadata = {};\n      if (hasAttachments) {\n        userMessageMetadata.attachments = attachmentMetadata;\n      }\n      if (hasClientMeta) {\n        userMessageMetadata.clientMeta = payload.clientMeta;\n      }\n      if (hasDisplayText) {\n        userMessageMetadata.displayText = payload.displayText;\n      }\n    }\n\n    // Emit a canonical user message into the stream so UI can render from server events only.\n    const userMessage: AgentMessage = {\n      id: userMessageId,\n      sessionId,\n      role: 'user',\n      content: trimmed,\n      messageType: 'chat',\n      cliSource: engineName,\n      requestId,\n      isStreaming: false,\n      isFinal: true,\n      createdAt: now,\n      metadata: userMessageMetadata,\n    };\n\n    this.streamManager.publish({ type: 'message', data: userMessage });\n\n    if (projectId) {\n      // Persist user message into project history for later reload.\n      try {\n        await touchProjectActivity(projectId);\n        // Update session activity timestamp so it appears at top of session list\n        if (dbSessionId) {\n          await touchSessionActivity(dbSessionId);\n        }\n        await persistAgentMessage({\n          projectId,\n          role: 'user',\n          messageType: 'chat',\n          content: trimmed,\n          sessionId,\n          cliSource: engineName,\n          requestId,\n          id: userMessage.id,\n          createdAt: userMessage.createdAt,\n          metadata: userMessageMetadata,\n        });\n      } catch (error) {\n        console.error('[AgentChatService] Failed to persist user message:', error);\n      }\n    }\n\n    this.streamManager.publish({\n      type: 'status',\n      data: {\n        sessionId,\n        status: 'starting',\n        requestId,\n        message: 'Agent request accepted',\n      },\n    });\n\n    const ctx: EngineExecutionContext = {\n      emit: (event: RealtimeEvent) => {\n        this.streamManager.publish(event);\n\n        if (!projectId) {\n          return;\n        }\n\n        if (event.type === 'message') {\n          const msg = event.data;\n          if (!msg) return;\n\n          // Only persist final snapshots; streaming deltas are transient.\n          if (msg.isStreaming && !msg.isFinal) {\n            return;\n          }\n\n          // User messages are already handled above.\n          if (msg.role === 'user') {\n            return;\n          }\n\n          const content = msg.content?.trim();\n          if (!content) {\n            return;\n          }\n\n          void persistAgentMessage({\n            projectId,\n            role: msg.role,\n            messageType: msg.messageType,\n            content,\n            metadata: msg.metadata,\n            sessionId: msg.sessionId,\n            conversationId: undefined,\n            cliSource: msg.cliSource,\n            requestId: msg.requestId,\n            id: msg.id,\n            createdAt: msg.createdAt,\n          }).catch((error) => {\n            console.error('[AgentChatService] Failed to persist agent message:', error);\n          });\n        }\n      },\n      // Callback to persist Claude session ID when SDK returns system/init message\n      // Prefer session-level persistence over project-level\n      persistClaudeSessionId: dbSessionId\n        ? async (claudeSessionId: string) => {\n            await updateEngineSessionId(dbSessionId, claudeSessionId);\n          }\n        : projectId\n          ? async (claudeSessionId: string) => {\n              await updateProjectClaudeSessionId(projectId, claudeSessionId);\n            }\n          : undefined,\n      // Callback to persist management info from system:init message\n      // Only available when using session-level persistence\n      persistManagementInfo: dbSessionId\n        ? async (info) => {\n            await updateManagementInfo(dbSessionId, info);\n          }\n        : undefined,\n    };\n\n    const engineOptions: EngineInitOptions = {\n      sessionId,\n      instruction: trimmed,\n      model: effectiveModel,\n      projectRoot,\n      requestId,\n      // Pass original attachments (for fallback) and resolved paths (preferred)\n      attachments: payload.attachments,\n      resolvedImagePaths,\n      projectId,\n      dbSessionId,\n      // Session-level configuration for ClaudeEngine\n      permissionMode: dbSession?.permissionMode,\n      allowDangerouslySkipPermissions: dbSession?.allowDangerouslySkipPermissions,\n      systemPromptConfig: dbSession?.systemPromptConfig,\n      optionsConfig: dbSession?.optionsConfig,\n      // Pass Claude session ID for session resumption (ClaudeEngine only)\n      resumeClaudeSessionId: engineName === 'claude' ? resumeClaudeSessionId : undefined,\n      // Pass useCcr flag for Claude Code Router support (ClaudeEngine only)\n      useCcr: engineName === 'claude' ? projectUseCcr : undefined,\n      // Pass Codex-specific configuration (CodexEngine only)\n      codexConfig: engineName === 'codex' ? dbSession?.optionsConfig?.codexConfig : undefined,\n    };\n\n    // Create abort controller for cancellation support\n    const abortController = new AbortController();\n\n    // Register execution in the running executions registry\n    this.runningExecutions.set(requestId, {\n      requestId,\n      sessionId,\n      engineName,\n      abortController,\n      startedAt: new Date(),\n    });\n\n    // Fire-and-forget execution to keep HTTP handler fast.\n    void this.runEngine(engine, engineOptions, ctx, sessionId, requestId, abortController);\n\n    return { requestId };\n  }\n\n  /**\n   * Cancel a running execution by requestId.\n   * Returns true if the execution was found and cancelled, false otherwise.\n   */\n  cancelExecution(requestId: string): boolean {\n    const execution = this.runningExecutions.get(requestId);\n    if (!execution) {\n      return false;\n    }\n\n    // Abort the execution\n    execution.abortController.abort();\n\n    // Emit cancelled status\n    this.streamManager.publish({\n      type: 'status',\n      data: {\n        sessionId: execution.sessionId,\n        status: 'cancelled',\n        requestId,\n        message: 'Execution cancelled by user',\n      },\n    });\n\n    // Remove from registry\n    this.runningExecutions.delete(requestId);\n\n    return true;\n  }\n\n  /**\n   * Cancel all running executions for a session.\n   * Returns the number of executions cancelled.\n   */\n  cancelSessionExecutions(sessionId: string): number {\n    let cancelled = 0;\n    for (const [requestId, execution] of this.runningExecutions) {\n      if (execution.sessionId === sessionId) {\n        execution.abortController.abort();\n        this.runningExecutions.delete(requestId);\n        cancelled++;\n      }\n    }\n\n    if (cancelled > 0) {\n      this.streamManager.publish({\n        type: 'status',\n        data: {\n          sessionId,\n          status: 'cancelled',\n          message: `Cancelled ${cancelled} running execution(s)`,\n        },\n      });\n    }\n\n    return cancelled;\n  }\n\n  /**\n   * Get list of running executions for diagnostics.\n   */\n  getRunningExecutions(): RunningExecution[] {\n    return Array.from(this.runningExecutions.values());\n  }\n\n  private resolveEngineName(preference?: EngineName, projectPreferredCli?: EngineName): EngineName {\n    if (preference && this.engines.has(preference)) {\n      return preference;\n    }\n    if (projectPreferredCli && this.engines.has(projectPreferredCli)) {\n      return projectPreferredCli;\n    }\n    return this.defaultEngineName;\n  }\n\n  private async runEngine(\n    engine: AgentEngine,\n    options: EngineInitOptions,\n    ctx: EngineExecutionContext,\n    sessionId: string,\n    requestId: string,\n    abortController: AbortController,\n  ): Promise<void> {\n    try {\n      // Check if already aborted before starting\n      if (abortController.signal.aborted) {\n        return;\n      }\n\n      this.streamManager.publish({\n        type: 'status',\n        data: {\n          sessionId,\n          status: 'running',\n          requestId,\n        },\n      });\n\n      // Pass abort signal to engine\n      const optionsWithSignal: EngineInitOptions = {\n        ...options,\n        signal: abortController.signal,\n      };\n\n      await engine.initializeAndRun(optionsWithSignal, ctx);\n\n      // Only emit completed if not aborted\n      if (!abortController.signal.aborted) {\n        this.streamManager.publish({\n          type: 'status',\n          data: {\n            sessionId,\n            status: 'completed',\n            requestId,\n          },\n        });\n      }\n    } catch (error) {\n      // Check if this was an abort error\n      if (abortController.signal.aborted) {\n        // Already handled by cancelExecution, just return\n        return;\n      }\n\n      const message = error instanceof Error ? error.message : String(error);\n\n      this.streamManager.publish({\n        type: 'error',\n        error: message,\n        data: { sessionId, requestId },\n      });\n\n      this.streamManager.publish({\n        type: 'status',\n        data: {\n          sessionId,\n          status: 'error',\n          message,\n          requestId,\n        },\n      });\n    } finally {\n      // Always remove from running executions when done\n      this.runningExecutions.delete(requestId);\n    }\n  }\n\n  /**\n   * Expose registered engines for UI and diagnostics.\n   */\n  getEngineInfos(): Array<{ name: EngineName; supportsMcp?: boolean }> {\n    const result: Array<{ name: EngineName; supportsMcp?: boolean }> = [];\n    for (const engine of this.engines.values()) {\n      result.push({\n        name: engine.name,\n        supportsMcp: engine.supportsMcp,\n      });\n    }\n    return result;\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/db/client.ts",
    "content": "/**\n * Database client singleton for Agent storage.\n *\n * Design principles:\n * - Lazy initialization - only connect when first accessed\n * - Singleton pattern - single connection throughout the app lifecycle\n * - Auto-create tables on first run (no migration tool needed)\n * - Configurable path via environment variable\n */\nimport Database from 'better-sqlite3';\nimport { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';\nimport { sql } from 'drizzle-orm';\nimport * as schema from './schema';\nimport { getAgentDataDir } from '../storage';\nimport path from 'node:path';\nimport { existsSync, mkdirSync } from 'node:fs';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport type DrizzleDB = BetterSQLite3Database<typeof schema>;\n\n// ============================================================\n// Singleton State\n// ============================================================\n\nlet dbInstance: DrizzleDB | null = null;\nlet sqliteInstance: Database.Database | null = null;\n\n// ============================================================\n// Database Path Resolution\n// ============================================================\n\n/**\n * Get the database file path.\n * Environment: CHROME_MCP_AGENT_DB_FILE overrides the default path.\n */\nexport function getDatabasePath(): string {\n  const envPath = process.env.CHROME_MCP_AGENT_DB_FILE;\n  if (envPath && envPath.trim()) {\n    return path.resolve(envPath.trim());\n  }\n  return path.join(getAgentDataDir(), 'agent.db');\n}\n\n// ============================================================\n// Schema Initialization SQL\n// ============================================================\n\nconst CREATE_TABLES_SQL = `\n-- Projects table\nCREATE TABLE IF NOT EXISTS projects (\n  id TEXT PRIMARY KEY,\n  name TEXT NOT NULL,\n  description TEXT,\n  root_path TEXT NOT NULL,\n  preferred_cli TEXT,\n  selected_model TEXT,\n  active_claude_session_id TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL,\n  last_active_at TEXT\n);\n\nCREATE INDEX IF NOT EXISTS projects_last_active_idx ON projects(last_active_at);\n\n-- Sessions table\nCREATE TABLE IF NOT EXISTS sessions (\n  id TEXT PRIMARY KEY,\n  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n  engine_name TEXT NOT NULL,\n  engine_session_id TEXT,\n  name TEXT,\n  model TEXT,\n  permission_mode TEXT NOT NULL DEFAULT 'bypassPermissions',\n  allow_dangerously_skip_permissions TEXT,\n  system_prompt_config TEXT,\n  options_config TEXT,\n  management_info TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS sessions_project_id_idx ON sessions(project_id);\nCREATE INDEX IF NOT EXISTS sessions_engine_name_idx ON sessions(engine_name);\n\n-- Messages table\nCREATE TABLE IF NOT EXISTS messages (\n  id TEXT PRIMARY KEY,\n  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n  session_id TEXT NOT NULL,\n  conversation_id TEXT,\n  role TEXT NOT NULL,\n  content TEXT NOT NULL,\n  message_type TEXT NOT NULL,\n  metadata TEXT,\n  cli_source TEXT,\n  request_id TEXT,\n  created_at TEXT NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS messages_project_id_idx ON messages(project_id);\nCREATE INDEX IF NOT EXISTS messages_session_id_idx ON messages(session_id);\nCREATE INDEX IF NOT EXISTS messages_created_at_idx ON messages(created_at);\nCREATE INDEX IF NOT EXISTS messages_request_id_idx ON messages(request_id);\n\n-- Enable foreign key enforcement\nPRAGMA foreign_keys = ON;\n`;\n\n/**\n * Migration SQL to add new columns to existing databases.\n * Each migration is idempotent - safe to run multiple times.\n */\nconst MIGRATION_SQL = `\n-- Add active_claude_session_id column if it doesn't exist (for existing databases)\n-- SQLite doesn't support IF NOT EXISTS for columns, so we use a workaround\n`;\n\n// ============================================================\n// Database Initialization\n// ============================================================\n\n/**\n * Check if a column exists in a table.\n */\nfunction columnExists(sqlite: Database.Database, tableName: string, columnName: string): boolean {\n  const result = sqlite.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;\n  return result.some((col) => col.name === columnName);\n}\n\n/**\n * Run migrations for existing databases.\n * Adds new columns that may be missing in older database versions.\n */\nfunction runMigrations(sqlite: Database.Database): void {\n  // Migration 1: Add active_claude_session_id column to projects table\n  if (!columnExists(sqlite, 'projects', 'active_claude_session_id')) {\n    sqlite.exec('ALTER TABLE projects ADD COLUMN active_claude_session_id TEXT');\n  }\n\n  // Migration 2: Add use_ccr column to projects table\n  if (!columnExists(sqlite, 'projects', 'use_ccr')) {\n    sqlite.exec('ALTER TABLE projects ADD COLUMN use_ccr TEXT');\n  }\n\n  // Migration 3: Add enable_chrome_mcp column to projects table (default enabled)\n  if (!columnExists(sqlite, 'projects', 'enable_chrome_mcp')) {\n    sqlite.exec(\"ALTER TABLE projects ADD COLUMN enable_chrome_mcp TEXT NOT NULL DEFAULT '1'\");\n  }\n}\n\n/**\n * Initialize the database schema.\n * Safe to call multiple times - uses IF NOT EXISTS.\n * Also runs migrations for existing databases.\n */\nfunction initializeSchema(sqlite: Database.Database): void {\n  sqlite.exec(CREATE_TABLES_SQL);\n  runMigrations(sqlite);\n}\n\n/**\n * Ensure the data directory exists.\n */\nfunction ensureDataDir(): void {\n  const dataDir = getAgentDataDir();\n  if (!existsSync(dataDir)) {\n    mkdirSync(dataDir, { recursive: true });\n  }\n}\n\n// ============================================================\n// Public API\n// ============================================================\n\n/**\n * Get the Drizzle database instance.\n * Lazily initializes the connection and schema on first call.\n */\nexport function getDb(): DrizzleDB {\n  if (dbInstance) {\n    return dbInstance;\n  }\n\n  ensureDataDir();\n  const dbPath = getDatabasePath();\n\n  // Create SQLite connection\n  sqliteInstance = new Database(dbPath);\n\n  // Enable WAL mode for better concurrent read performance\n  sqliteInstance.pragma('journal_mode = WAL');\n\n  // Initialize schema\n  initializeSchema(sqliteInstance);\n\n  // Create Drizzle instance\n  dbInstance = drizzle(sqliteInstance, { schema });\n\n  return dbInstance;\n}\n\n/**\n * Close the database connection.\n * Should be called on graceful shutdown.\n */\nexport function closeDb(): void {\n  if (sqliteInstance) {\n    sqliteInstance.close();\n    sqliteInstance = null;\n    dbInstance = null;\n  }\n}\n\n/**\n * Check if database is initialized.\n */\nexport function isDbInitialized(): boolean {\n  return dbInstance !== null;\n}\n\n/**\n * Execute raw SQL (for advanced use cases).\n */\nexport function execRawSql(sqlStr: string): void {\n  if (!sqliteInstance) {\n    getDb(); // Initialize if not already\n  }\n  sqliteInstance!.exec(sqlStr);\n}\n"
  },
  {
    "path": "app/native-server/src/agent/db/index.ts",
    "content": "/**\n * Database module exports.\n */\nexport * from './schema';\nexport * from './client';\n"
  },
  {
    "path": "app/native-server/src/agent/db/schema.ts",
    "content": "/**\n * Drizzle ORM Schema for Agent Storage.\n *\n * Design principles:\n * - Type-safe database access\n * - Consistent with shared types (AgentProject, AgentStoredMessage)\n * - Proper indexes for common query patterns\n * - Foreign key constraints with cascade delete\n */\nimport { sqliteTable, text, index } from 'drizzle-orm/sqlite-core';\n\n// ============================================================\n// Projects Table\n// ============================================================\n\nexport const projects = sqliteTable(\n  'projects',\n  {\n    id: text().primaryKey(),\n    name: text().notNull(),\n    description: text(),\n    rootPath: text('root_path').notNull(),\n    preferredCli: text('preferred_cli'),\n    selectedModel: text('selected_model'),\n    /**\n     * Active Claude session ID (UUID format) for session resumption.\n     * Captured from SDK's system/init message.\n     */\n    activeClaudeSessionId: text('active_claude_session_id'),\n    /**\n     * Whether to use Claude Code Router (CCR) for this project.\n     * Stored as '1' (true) or '0'/null (false).\n     */\n    useCcr: text('use_ccr'),\n    /**\n     * Whether to enable the local Chrome MCP server integration for this project.\n     * Stored as '1' (true) or '0' (false). Default: '1' (enabled).\n     */\n    enableChromeMcp: text('enable_chrome_mcp').notNull().default('1'),\n    createdAt: text('created_at').notNull(),\n    updatedAt: text('updated_at').notNull(),\n    lastActiveAt: text('last_active_at'),\n  },\n  (table) => ({\n    lastActiveIdx: index('projects_last_active_idx').on(table.lastActiveAt),\n  }),\n);\n\n// ============================================================\n// Sessions Table\n// ============================================================\n\nexport const sessions = sqliteTable(\n  'sessions',\n  {\n    id: text().primaryKey(),\n    projectId: text('project_id')\n      .notNull()\n      .references(() => projects.id, { onDelete: 'cascade' }),\n    /**\n     * Engine name: claude, codex, cursor, qwen, glm, etc.\n     */\n    engineName: text('engine_name').notNull(),\n    /**\n     * Engine-specific session ID for resumption.\n     * For Claude: SDK's session_id from system:init message.\n     */\n    engineSessionId: text('engine_session_id'),\n    /**\n     * User-defined session name for display.\n     */\n    name: text(),\n    /**\n     * Model override for this session.\n     */\n    model: text(),\n    /**\n     * Permission mode: default, acceptEdits, bypassPermissions, plan, dontAsk.\n     */\n    permissionMode: text('permission_mode').notNull().default('bypassPermissions'),\n    /**\n     * Whether to allow bypassing interactive permission prompts.\n     * Stored as '1' (true) or null (false).\n     */\n    allowDangerouslySkipPermissions: text('allow_dangerously_skip_permissions'),\n    /**\n     * JSON: System prompt configuration.\n     * Format: { type: 'custom', text: string } | { type: 'preset', preset: 'claude_code', append?: string }\n     */\n    systemPromptConfig: text('system_prompt_config'),\n    /**\n     * JSON: Engine/session option overrides (settingSources, tools, betas, etc.).\n     */\n    optionsConfig: text('options_config'),\n    /**\n     * JSON: Cached management info (supported models, commands, account, MCP servers, etc.).\n     */\n    managementInfo: text('management_info'),\n    createdAt: text('created_at').notNull(),\n    updatedAt: text('updated_at').notNull(),\n  },\n  (table) => ({\n    projectIdIdx: index('sessions_project_id_idx').on(table.projectId),\n    engineNameIdx: index('sessions_engine_name_idx').on(table.engineName),\n  }),\n);\n\n// ============================================================\n// Messages Table\n// ============================================================\n\nexport const messages = sqliteTable(\n  'messages',\n  {\n    id: text().primaryKey(),\n    projectId: text('project_id')\n      .notNull()\n      .references(() => projects.id, { onDelete: 'cascade' }),\n    sessionId: text('session_id').notNull(),\n    conversationId: text('conversation_id'),\n    role: text().notNull(), // 'user' | 'assistant' | 'tool' | 'system'\n    content: text().notNull(),\n    messageType: text('message_type').notNull(), // 'chat' | 'tool_use' | 'tool_result' | 'status'\n    metadata: text(), // JSON string\n    cliSource: text('cli_source'),\n    requestId: text('request_id'),\n    createdAt: text('created_at').notNull(),\n  },\n  (table) => ({\n    projectIdIdx: index('messages_project_id_idx').on(table.projectId),\n    sessionIdIdx: index('messages_session_id_idx').on(table.sessionId),\n    createdAtIdx: index('messages_created_at_idx').on(table.createdAt),\n    requestIdIdx: index('messages_request_id_idx').on(table.requestId),\n  }),\n);\n\n// ============================================================\n// Type Inference Helpers\n// ============================================================\n\nexport type ProjectRow = typeof projects.$inferSelect;\nexport type ProjectInsert = typeof projects.$inferInsert;\nexport type SessionRow = typeof sessions.$inferSelect;\nexport type SessionInsert = typeof sessions.$inferInsert;\nexport type MessageRow = typeof messages.$inferSelect;\nexport type MessageInsert = typeof messages.$inferInsert;\n"
  },
  {
    "path": "app/native-server/src/agent/directory-picker.ts",
    "content": "/**\n * Directory Picker Service.\n *\n * Provides cross-platform directory selection using native system dialogs.\n * Uses platform-specific commands:\n * - macOS: osascript (AppleScript)\n * - Windows: PowerShell\n * - Linux: zenity or kdialog\n */\nimport { exec } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport os from 'node:os';\n\nconst execAsync = promisify(exec);\n\nexport interface DirectoryPickerResult {\n  success: boolean;\n  path?: string;\n  cancelled?: boolean;\n  error?: string;\n}\n\n/**\n * Open a native directory picker dialog.\n * Returns the selected directory path or indicates cancellation.\n */\nexport async function openDirectoryPicker(\n  title = 'Select Project Directory',\n): Promise<DirectoryPickerResult> {\n  const platform = os.platform();\n\n  try {\n    switch (platform) {\n      case 'darwin':\n        return await openMacOSPicker(title);\n      case 'win32':\n        return await openWindowsPicker(title);\n      case 'linux':\n        return await openLinuxPicker(title);\n      default:\n        return {\n          success: false,\n          error: `Unsupported platform: ${platform}`,\n        };\n    }\n  } catch (error) {\n    return {\n      success: false,\n      error: error instanceof Error ? error.message : String(error),\n    };\n  }\n}\n\n/**\n * macOS: Use osascript to open Finder folder picker.\n */\nasync function openMacOSPicker(title: string): Promise<DirectoryPickerResult> {\n  const script = `\n    set selectedFolder to choose folder with prompt \"${title}\"\n    return POSIX path of selectedFolder\n  `;\n\n  try {\n    const { stdout } = await execAsync(`osascript -e '${script}'`);\n    const path = stdout.trim();\n    if (path) {\n      return { success: true, path };\n    }\n    return { success: false, cancelled: true };\n  } catch (error) {\n    // User cancelled returns error code 1\n    const err = error as { code?: number; stderr?: string };\n    if (err.code === 1) {\n      return { success: false, cancelled: true };\n    }\n    throw error;\n  }\n}\n\n/**\n * Windows: Use PowerShell to open folder browser dialog.\n */\nasync function openWindowsPicker(title: string): Promise<DirectoryPickerResult> {\n  const psScript = `\n    Add-Type -AssemblyName System.Windows.Forms\n    $dialog = New-Object System.Windows.Forms.FolderBrowserDialog\n    $dialog.Description = \"${title}\"\n    $dialog.ShowNewFolderButton = $true\n    $result = $dialog.ShowDialog()\n    if ($result -eq [System.Windows.Forms.DialogResult]::OK) {\n      Write-Output $dialog.SelectedPath\n    }\n  `;\n\n  // Escape for command line\n  const escapedScript = psScript.replace(/\"/g, '\\\\\"').replace(/\\n/g, ' ');\n\n  try {\n    const { stdout } = await execAsync(\n      `powershell -NoProfile -Command \"${escapedScript}\"`,\n      { timeout: 60000 }, // 60 second timeout\n    );\n    const path = stdout.trim();\n    if (path) {\n      return { success: true, path };\n    }\n    return { success: false, cancelled: true };\n  } catch (error) {\n    const err = error as { killed?: boolean };\n    if (err.killed) {\n      return { success: false, error: 'Dialog timed out' };\n    }\n    throw error;\n  }\n}\n\n/**\n * Linux: Try zenity first, then kdialog as fallback.\n */\nasync function openLinuxPicker(title: string): Promise<DirectoryPickerResult> {\n  // Try zenity first (GTK)\n  try {\n    const { stdout } = await execAsync(`zenity --file-selection --directory --title=\"${title}\"`, {\n      timeout: 60000,\n    });\n    const path = stdout.trim();\n    if (path) {\n      return { success: true, path };\n    }\n    return { success: false, cancelled: true };\n  } catch (zenityError) {\n    // zenity returns exit code 1 on cancel, 5 if not installed\n    const err = zenityError as { code?: number };\n    if (err.code === 1) {\n      return { success: false, cancelled: true };\n    }\n\n    // Try kdialog as fallback (KDE)\n    try {\n      const { stdout } = await execAsync(`kdialog --getexistingdirectory ~ --title \"${title}\"`, {\n        timeout: 60000,\n      });\n      const path = stdout.trim();\n      if (path) {\n        return { success: true, path };\n      }\n      return { success: false, cancelled: true };\n    } catch (kdialogError) {\n      const kdErr = kdialogError as { code?: number };\n      if (kdErr.code === 1) {\n        return { success: false, cancelled: true };\n      }\n\n      return {\n        success: false,\n        error: 'No directory picker available. Please install zenity or kdialog.',\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/engines/claude.ts",
    "content": "import { randomUUID } from 'node:crypto';\nimport path from 'node:path';\nimport type { AgentEngine, EngineExecutionContext, EngineInitOptions } from './types';\nimport type { AgentMessage, RealtimeEvent } from '../types';\nimport { detectCcr, validateCcrConfig } from '../ccr-detector';\nimport { getProject } from '../project-service';\nimport { getChromeMcpUrl } from '../../constant';\n\n// Images are provided to Claude Code via local file paths referenced in the prompt text.\n// Claude Code CLI reads images from local paths, so we write base64 images to temp files and reference them.\n\n/**\n * Tool action type for categorizing tool operations.\n */\ntype ToolAction = 'Edited' | 'Created' | 'Read' | 'Deleted' | 'Generated' | 'Searched' | 'Executed';\n\n/**\n * Map of tool names to their corresponding actions.\n */\nconst TOOL_NAME_ACTION_MAP: Record<string, ToolAction> = {\n  read: 'Read',\n  read_file: 'Read',\n  write: 'Created',\n  write_file: 'Created',\n  create_file: 'Created',\n  edit: 'Edited',\n  edit_file: 'Edited',\n  apply_patch: 'Edited',\n  patch_file: 'Edited',\n  remove_file: 'Deleted',\n  delete_file: 'Deleted',\n  list_files: 'Searched',\n  glob: 'Searched',\n  glob_files: 'Searched',\n  search_files: 'Searched',\n  grep: 'Searched',\n  bash: 'Executed',\n  run: 'Executed',\n  shell: 'Executed',\n  todo_write: 'Generated',\n  plan_write: 'Generated',\n};\n\n/**\n * ClaudeEngine integrates the Claude Agent SDK as an AgentEngine implementation.\n *\n * This engine uses the @anthropic-ai/claude-agent-sdk to interact with Claude,\n * streaming events back to the sidepanel UI via RealtimeEvent envelopes.\n */\nexport class ClaudeEngine implements AgentEngine {\n  public readonly name = 'claude' as const;\n  public readonly supportsMcp = true;\n\n  /**\n   * Maximum number of stderr lines to keep in memory.\n   */\n  private static readonly MAX_STDERR_LINES = 200;\n\n  async initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void> {\n    const {\n      sessionId,\n      instruction,\n      model,\n      projectRoot,\n      requestId,\n      signal,\n      attachments,\n      resolvedImagePaths,\n      projectId,\n      permissionMode,\n      allowDangerouslySkipPermissions,\n      systemPromptConfig,\n      optionsConfig,\n      resumeClaudeSessionId,\n      useCcr,\n    } = options;\n    const repoPath = this.resolveRepoPath(projectRoot);\n\n    // Check if already aborted\n    if (signal?.aborted) {\n      throw new Error('ClaudeEngine: execution was cancelled');\n    }\n\n    const normalizedInstruction = instruction.trim();\n    if (!normalizedInstruction) {\n      throw new Error('ClaudeEngine: instruction must not be empty');\n    }\n\n    // Dynamically import the Claude Agent SDK\n    // Images are passed via temp file paths appended to the prompt string\n    let query: (args: { prompt: string; options?: Record<string, unknown> }) => AsyncIterable<any>;\n    try {\n      // Dynamic import to avoid hard dependency - install @anthropic-ai/claude-agent-sdk to use this engine\n      // Use string variable to bypass TypeScript module resolution\n      const sdkModuleName = '@anthropic-ai/claude-agent-sdk';\n\n      const sdk = await (Function(\n        'moduleName',\n        'return import(moduleName)',\n      )(sdkModuleName) as Promise<any>);\n      query = sdk.query;\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      throw new Error(\n        `ClaudeEngine: Failed to load Claude Agent SDK. Please install @anthropic-ai/claude-agent-sdk. Error: ${message}`,\n      );\n    }\n\n    // Resolve model\n    const resolvedModel =\n      model?.trim() || process.env.CLAUDE_DEFAULT_MODEL || 'claude-sonnet-4-20250514';\n\n    // State management\n    const stderrBuffer: string[] = [];\n    let assistantBuffer = '';\n    let assistantMessageId: string | null = null;\n    let assistantCreatedAt: string | null = null;\n    let lastAssistantEmitted: { content: string; isFinal: boolean } | null = null;\n    const streamedToolHashes = new Set<string>();\n\n    // Tool input accumulation for streaming tool_use blocks\n    // Key: content block index, Value: { toolName, toolId, inputJson }\n    const pendingToolInputs = new Map<\n      number,\n      { toolName: string; toolId: string; inputJsonParts: string[] }\n    >();\n    let currentContentBlockIndex = -1;\n\n    /**\n     * Emit assistant message to the stream.\n     * Includes deduplication to prevent multiple identical final emissions.\n     */\n    const emitAssistant = (isFinal: boolean): void => {\n      const content = assistantBuffer.trim();\n      if (!content) return;\n\n      // Deduplicate: skip if same content and isFinal state was already emitted\n      if (\n        lastAssistantEmitted &&\n        lastAssistantEmitted.content === content &&\n        lastAssistantEmitted.isFinal === isFinal\n      ) {\n        return;\n      }\n      lastAssistantEmitted = { content, isFinal };\n\n      if (!assistantMessageId) {\n        assistantMessageId = randomUUID();\n      }\n      if (!assistantCreatedAt) {\n        assistantCreatedAt = new Date().toISOString();\n      }\n\n      const message: AgentMessage = {\n        id: assistantMessageId,\n        sessionId,\n        role: 'assistant',\n        content,\n        messageType: 'chat',\n        cliSource: this.name,\n        requestId,\n        isStreaming: !isFinal,\n        isFinal,\n        createdAt: assistantCreatedAt,\n      };\n\n      ctx.emit({ type: 'message', data: message });\n    };\n\n    /**\n     * Emit tool message with deduplication.\n     */\n    const dispatchToolMessage = (\n      content: string,\n      metadata: Record<string, unknown>,\n      messageType: 'tool_use' | 'tool_result',\n      isStreaming: boolean,\n    ): void => {\n      const trimmed = content.trim();\n      if (!trimmed) return;\n\n      const hash = this.encodeHash(\n        `${messageType}:${trimmed}:${JSON.stringify(metadata)}:${sessionId}:${requestId || ''}`,\n      ).slice(0, 16);\n      if (streamedToolHashes.has(hash)) return;\n      streamedToolHashes.add(hash);\n\n      const message: AgentMessage = {\n        id: randomUUID(),\n        sessionId,\n        role: 'tool',\n        content: trimmed,\n        messageType,\n        cliSource: this.name,\n        requestId,\n        isStreaming,\n        isFinal: !isStreaming,\n        createdAt: new Date().toISOString(),\n        metadata: { cli_type: 'claude', ...metadata },\n      };\n\n      ctx.emit({ type: 'message', data: message });\n    };\n\n    /**\n     * Infer tool action from tool name.\n     */\n    const inferActionFromToolName = (toolName: unknown): ToolAction | undefined => {\n      if (typeof toolName !== 'string') return undefined;\n      const normalized = toolName.trim().toLowerCase();\n      if (!normalized) return undefined;\n\n      if (TOOL_NAME_ACTION_MAP[normalized]) {\n        return TOOL_NAME_ACTION_MAP[normalized];\n      }\n\n      // Try suffix after colon (e.g., \"mcp__server__tool\" -> \"tool\")\n      const suffix = normalized.split(':').pop() ?? normalized;\n      if (suffix && TOOL_NAME_ACTION_MAP[suffix]) {\n        return TOOL_NAME_ACTION_MAP[suffix];\n      }\n\n      // Infer from name patterns\n      if (\n        normalized.includes('edit') ||\n        normalized.includes('modify') ||\n        normalized.includes('patch')\n      ) {\n        return 'Edited';\n      }\n      if (normalized.includes('write') || normalized.includes('create')) {\n        return 'Created';\n      }\n      if (normalized.includes('read') || normalized.includes('view')) {\n        return 'Read';\n      }\n      if (normalized.includes('delete') || normalized.includes('remove')) {\n        return 'Deleted';\n      }\n      if (\n        normalized.includes('search') ||\n        normalized.includes('find') ||\n        normalized.includes('glob') ||\n        normalized.includes('grep')\n      ) {\n        return 'Searched';\n      }\n      if (\n        normalized.includes('bash') ||\n        normalized.includes('shell') ||\n        normalized.includes('exec')\n      ) {\n        return 'Executed';\n      }\n      if (normalized.includes('todo') || normalized.includes('plan')) {\n        return 'Generated';\n      }\n\n      return undefined;\n    };\n\n    /**\n     * Build tool metadata from content block with detailed tool-specific information.\n     */\n    const buildToolMetadata = (contentBlock: Record<string, unknown>): Record<string, unknown> => {\n      const toolName = this.pickFirstString(contentBlock.name) || 'unknown';\n      const toolId = this.pickFirstString(contentBlock.id);\n      const input = contentBlock.input as Record<string, unknown> | undefined;\n      const action = inferActionFromToolName(toolName);\n\n      const metadata: Record<string, unknown> = {\n        toolName,\n        tool_name: toolName,\n        toolId,\n        action,\n      };\n\n      if (!input) {\n        return metadata;\n      }\n\n      // Extract tool-specific details\n      const normalizedName = toolName.toLowerCase();\n\n      // File operations (read, write, edit)\n      if (typeof input.file_path === 'string') {\n        metadata.filePath = input.file_path;\n      }\n\n      // Edit tool - extract diff information\n      if (\n        normalizedName.includes('edit') ||\n        normalizedName === 'apply_patch' ||\n        normalizedName === 'patch_file'\n      ) {\n        if (typeof input.old_string === 'string') {\n          metadata.oldString = input.old_string;\n          metadata.deletedLines = input.old_string.split('\\n').length;\n        }\n        if (typeof input.new_string === 'string') {\n          metadata.newString = input.new_string;\n          metadata.addedLines = input.new_string.split('\\n').length;\n        }\n        if (typeof input.replace_all === 'boolean') {\n          metadata.replaceAll = input.replace_all;\n        }\n      }\n\n      // Write tool - content preview\n      if (normalizedName.includes('write') || normalizedName === 'create_file') {\n        if (typeof input.content === 'string') {\n          metadata.contentPreview = input.content.slice(0, 200);\n          metadata.totalLines = input.content.split('\\n').length;\n        }\n      }\n\n      // Read tool - offset/limit\n      if (normalizedName.includes('read')) {\n        if (typeof input.offset === 'number') metadata.offset = input.offset;\n        if (typeof input.limit === 'number') metadata.limit = input.limit;\n      }\n\n      // Bash/shell - command\n      if (\n        normalizedName === 'bash' ||\n        normalizedName.includes('shell') ||\n        normalizedName === 'run'\n      ) {\n        if (typeof input.command === 'string') {\n          metadata.command = input.command;\n        }\n        if (typeof input.description === 'string') {\n          metadata.commandDescription = input.description;\n        }\n      }\n\n      // Search tools (grep, glob)\n      if (normalizedName === 'grep' || normalizedName.includes('search')) {\n        if (typeof input.pattern === 'string') metadata.pattern = input.pattern;\n        if (typeof input.path === 'string') metadata.searchPath = input.path;\n        if (typeof input.glob === 'string') metadata.glob = input.glob;\n        if (typeof input.output_mode === 'string') metadata.outputMode = input.output_mode;\n      }\n\n      if (normalizedName === 'glob' || normalizedName === 'glob_files') {\n        if (typeof input.pattern === 'string') metadata.pattern = input.pattern;\n        if (typeof input.path === 'string') metadata.searchPath = input.path;\n      }\n\n      // TodoWrite\n      if (normalizedName === 'todo_write' || normalizedName === 'todowrite') {\n        if (Array.isArray(input.todos)) {\n          metadata.todoCount = input.todos.length;\n          metadata.todos = input.todos;\n        }\n      }\n\n      // Store raw input for debugging (truncated)\n      metadata.rawInput = JSON.stringify(input).slice(0, 1000);\n\n      return metadata;\n    };\n\n    // State for temp file cleanup\n    const tempFiles: string[] = [];\n    const cleanupTempFiles = async (): Promise<void> => {\n      if (tempFiles.length === 0) return;\n\n      try {\n        const fs = await import('node:fs/promises');\n        for (const filePath of tempFiles) {\n          try {\n            await fs.unlink(filePath);\n            console.error(`[ClaudeEngine] Cleaned up temp file: ${filePath}`);\n          } catch (err) {\n            // Best-effort cleanup; ignore failures (file may already be deleted)\n            console.error(`[ClaudeEngine] Failed to cleanup temp file ${filePath}:`, err);\n          }\n        }\n      } catch (err) {\n        console.error('[ClaudeEngine] Failed to cleanup temp files:', err);\n      }\n    };\n\n    // Build prompt instruction (may be modified if images are attached)\n    let promptInstruction = normalizedInstruction;\n\n    try {\n      // Use console.error for logging to avoid polluting stdout (Native Messaging protocol)\n      console.error(`[ClaudeEngine] Starting query with model: ${resolvedModel}`);\n      console.error(`[ClaudeEngine] Working directory: ${repoPath}`);\n\n      // Check for image attachments - prefer resolvedImagePaths (persisted), fallback to temp files\n      const hasResolvedPaths = resolvedImagePaths && resolvedImagePaths.length > 0;\n      const imageAttachments = (attachments ?? []).filter((a) => a.type === 'image');\n      const hasImages = hasResolvedPaths || imageAttachments.length > 0;\n\n      if (hasImages) {\n        // Strip any legacy \"Image #N path:\" lines to avoid duplicating references\n        const instructionWithoutLegacyPaths = normalizedInstruction\n          .replace(/\\n*Image #\\d+ path: [^\\n]+/g, '')\n          .trim();\n\n        const imageLines: string[] = [];\n\n        if (hasResolvedPaths) {\n          // Use pre-resolved persistent paths (preferred - no temp files needed)\n          console.error(\n            `[ClaudeEngine] Using ${resolvedImagePaths.length} pre-resolved image path(s)`,\n          );\n          for (let index = 0; index < resolvedImagePaths.length; index++) {\n            imageLines.push(`Image #${index + 1} path: ${resolvedImagePaths[index]}`);\n          }\n        } else {\n          // Fallback: write base64 to temp files (legacy behavior)\n          console.error(\n            `[ClaudeEngine] Writing ${imageAttachments.length} image attachment(s) to temp files (fallback)`,\n          );\n          for (let index = 0; index < imageAttachments.length; index++) {\n            const attachment = imageAttachments[index];\n            const tempFilePath = await this.writeAttachmentToTemp(attachment);\n            tempFiles.push(tempFilePath);\n            imageLines.push(`Image #${index + 1} path: ${tempFilePath}`);\n          }\n        }\n\n        // Build final instruction with image paths appended\n        promptInstruction = [instructionWithoutLegacyPaths, imageLines.join('\\n')]\n          .filter((segment) => segment && segment.trim().length > 0)\n          .join('\\n\\n')\n          .trim();\n\n        console.error(\n          `[ClaudeEngine] Prompt with image paths: ${promptInstruction.slice(0, 200)}...`,\n        );\n      }\n\n      // Start Claude Agent SDK query\n      // Session resumption: if resumeClaudeSessionId is provided (from sessions.engineSessionId or legacy project),\n      // pass it as 'resume' to continue a previous Claude conversation.\n      // If not provided, SDK will create a new session.\n\n      // Build environment for Claude Code Router support\n      // SDK treats options.env as a complete replacement, so we must merge with process.env\n      // Reference: https://github.com/musistudio/claude-code-router/issues/855\n      const claudeEnv = await this.buildClaudeEnv(useCcr);\n\n      // Validate CCR configuration and emit friendly warning before calling into CCR\n      // This prevents users from seeing cryptic \"includes of undefined\" errors\n      if (useCcr) {\n        await this.validateAndWarnCcrConfig(sessionId, requestId, ctx);\n      }\n\n      // Resolve permission mode from session config or use default\n      // SDK default is 'default', but AgentChat defaults to 'bypassPermissions' for headless operation\n      const allowedPermissionModes = new Set([\n        'default',\n        'acceptEdits',\n        'bypassPermissions',\n        'plan',\n        'dontAsk',\n      ]);\n      const normalizedPermissionMode =\n        typeof permissionMode === 'string' ? permissionMode.trim() : '';\n\n      let resolvedPermissionMode: string;\n      if (normalizedPermissionMode === '') {\n        // No permission mode specified - use AgentChat default for headless operation\n        resolvedPermissionMode = 'bypassPermissions';\n      } else if (allowedPermissionModes.has(normalizedPermissionMode)) {\n        // Valid permission mode - use as specified\n        resolvedPermissionMode = normalizedPermissionMode;\n      } else {\n        // Invalid permission mode - fall back to SDK default and warn\n        console.error(\n          `[ClaudeEngine] Invalid permissionMode \"${normalizedPermissionMode}\", falling back to SDK default \"default\"`,\n        );\n        resolvedPermissionMode = 'default';\n      }\n\n      // allowDangerouslySkipPermissions must be true when using bypassPermissions mode\n      // SDK requirement: bypass mode requires explicit acknowledgment via allowDangerouslySkipPermissions=true\n      const resolvedAllowDangerouslySkipPermissions = (() => {\n        const explicitValue =\n          typeof allowDangerouslySkipPermissions === 'boolean'\n            ? allowDangerouslySkipPermissions\n            : undefined;\n\n        if (resolvedPermissionMode === 'bypassPermissions') {\n          // Force true for bypassPermissions mode - SDK requirement\n          if (explicitValue === false) {\n            console.error(\n              '[ClaudeEngine] Warning: allowDangerouslySkipPermissions=false is incompatible with bypassPermissions mode, forcing to true',\n            );\n          }\n          return true;\n        }\n\n        // For non-bypass modes, use explicit value or default to false\n        return explicitValue ?? false;\n      })();\n\n      // Parse optionsConfig for additional SDK options\n      const optionsRecord =\n        optionsConfig && typeof optionsConfig === 'object' && !Array.isArray(optionsConfig)\n          ? (optionsConfig as Record<string, unknown>)\n          : undefined;\n\n      // Resolve project-scoped Chrome MCP toggle (default: enabled)\n      const enableChromeMcp = await (async (): Promise<boolean> => {\n        if (!projectId) return true;\n        try {\n          const project = await getProject(projectId);\n          return project?.enableChromeMcp !== false;\n        } catch (err) {\n          const message = err instanceof Error ? err.message : String(err);\n          console.error(\n            `[ClaudeEngine] Failed to load project enableChromeMcp, defaulting to enabled: ${message}`,\n          );\n          return true;\n        }\n      })();\n\n      // Resolve setting sources\n      // SDK isolation mode: settingSources=[] prevents loading any filesystem settings\n      // Default behavior: include 'project' to load CLAUDE.md\n      const resolvedSettingSources = (() => {\n        const allowedSettingSources = new Set(['user', 'project', 'local']);\n        const raw = optionsRecord?.settingSources;\n\n        // Check for explicit isolation mode (empty array)\n        if (Array.isArray(raw) && raw.length === 0) {\n          console.error('[ClaudeEngine] Isolation mode enabled: settingSources=[]');\n          return [];\n        }\n\n        // Parse provided sources\n        if (Array.isArray(raw)) {\n          const sources: string[] = [];\n          for (const entry of raw) {\n            if (typeof entry === 'string' && allowedSettingSources.has(entry)) {\n              sources.push(entry);\n            }\n          }\n          // If valid sources were provided, use them as-is (trust user config)\n          if (sources.length > 0) {\n            return sources;\n          }\n        }\n\n        // Default: include 'project' to load CLAUDE.md\n        return ['project'];\n      })();\n\n      // Resolve system prompt from session config\n      const resolvedSystemPrompt = (() => {\n        if (typeof systemPromptConfig === 'string') {\n          const trimmed = systemPromptConfig.trim();\n          return trimmed.length > 0 ? trimmed : undefined;\n        }\n        if (\n          !systemPromptConfig ||\n          typeof systemPromptConfig !== 'object' ||\n          Array.isArray(systemPromptConfig)\n        ) {\n          return undefined;\n        }\n        const record = systemPromptConfig as Record<string, unknown>;\n        const type = record.type;\n        if (type === 'custom' && typeof record.text === 'string') {\n          const trimmed = record.text.trim();\n          return trimmed.length > 0 ? trimmed : undefined;\n        }\n        if (type === 'preset' && record.preset === 'claude_code') {\n          // Trim append and ignore empty strings to avoid \"append is empty but object is passed\" edge case\n          const rawAppend = typeof record.append === 'string' ? record.append.trim() : '';\n          const append = rawAppend.length > 0 ? rawAppend : undefined;\n          return append\n            ? { type: 'preset' as const, preset: 'claude_code' as const, append }\n            : { type: 'preset' as const, preset: 'claude_code' as const };\n        }\n        return undefined;\n      })();\n\n      // Create internal AbortController that mirrors the external signal\n      // SDK expects abortController option, not raw AbortSignal\n      const internalAbortController = new AbortController();\n      if (signal) {\n        // Propagate external abort to internal controller\n        if (signal.aborted) {\n          internalAbortController.abort();\n        } else {\n          signal.addEventListener(\n            'abort',\n            () => {\n              internalAbortController.abort();\n            },\n            { once: true },\n          );\n        }\n      }\n\n      const queryOptions: Record<string, unknown> = {\n        cwd: repoPath,\n        additionalDirectories: [repoPath],\n        model: resolvedModel,\n        // Permission settings are session-configurable (defaults preserve previous behavior)\n        permissionMode: resolvedPermissionMode,\n        allowDangerouslySkipPermissions: resolvedAllowDangerouslySkipPermissions,\n        // Enable streaming: emit stream_event with content_block_delta for real-time UI updates\n        // Without this, SDK only outputs aggregated assistant/result messages\n        includePartialMessages: true,\n        // Load CLAUDE.md / .claude/settings.json from the project root\n        settingSources: resolvedSettingSources,\n        // Custom system prompt if provided\n        systemPrompt: resolvedSystemPrompt,\n        // AbortController for cancellation support - SDK uses this to terminate underlying processes\n        abortController: internalAbortController,\n        // Pass merged env to support Claude Code Router (CCR)\n        // This allows users to set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN via:\n        // 1. eval \"$(ccr activate)\" before launching Chrome\n        // 2. Or setting env vars in shell profile\n        env: claudeEnv,\n        stderr: (data: string) => {\n          const line = String(data).trimEnd();\n          if (!line) return;\n          if (stderrBuffer.length > ClaudeEngine.MAX_STDERR_LINES) {\n            stderrBuffer.shift();\n          }\n          stderrBuffer.push(line);\n          console.error(`[ClaudeEngine][stderr] ${line}`);\n        },\n      };\n\n      // Apply additional SDK options from optionsConfig\n      if (optionsRecord) {\n        const isStringArray = (value: unknown): value is string[] =>\n          Array.isArray(value) && value.every((v) => typeof v === 'string');\n\n        if (isStringArray(optionsRecord.allowedTools)) {\n          queryOptions.allowedTools = optionsRecord.allowedTools;\n        }\n        if (isStringArray(optionsRecord.disallowedTools)) {\n          queryOptions.disallowedTools = optionsRecord.disallowedTools;\n        }\n\n        const tools = optionsRecord.tools;\n        if (isStringArray(tools)) {\n          queryOptions.tools = tools;\n        } else if (tools && typeof tools === 'object' && !Array.isArray(tools)) {\n          const toolsRecord = tools as Record<string, unknown>;\n          if (toolsRecord.type === 'preset' && toolsRecord.preset === 'claude_code') {\n            queryOptions.tools = { type: 'preset', preset: 'claude_code' };\n          }\n        }\n\n        if (isStringArray(optionsRecord.betas)) {\n          queryOptions.betas = optionsRecord.betas;\n        }\n\n        if (\n          typeof optionsRecord.maxThinkingTokens === 'number' &&\n          Number.isFinite(optionsRecord.maxThinkingTokens)\n        ) {\n          queryOptions.maxThinkingTokens = optionsRecord.maxThinkingTokens;\n        }\n        if (typeof optionsRecord.maxTurns === 'number' && Number.isFinite(optionsRecord.maxTurns)) {\n          queryOptions.maxTurns = optionsRecord.maxTurns;\n        }\n        if (\n          typeof optionsRecord.maxBudgetUsd === 'number' &&\n          Number.isFinite(optionsRecord.maxBudgetUsd)\n        ) {\n          queryOptions.maxBudgetUsd = optionsRecord.maxBudgetUsd;\n        }\n\n        if (\n          optionsRecord.mcpServers &&\n          typeof optionsRecord.mcpServers === 'object' &&\n          !Array.isArray(optionsRecord.mcpServers)\n        ) {\n          queryOptions.mcpServers = optionsRecord.mcpServers;\n        }\n        if (\n          optionsRecord.outputFormat &&\n          typeof optionsRecord.outputFormat === 'object' &&\n          !Array.isArray(optionsRecord.outputFormat)\n        ) {\n          queryOptions.outputFormat = optionsRecord.outputFormat;\n        }\n        if (typeof optionsRecord.enableFileCheckpointing === 'boolean') {\n          queryOptions.enableFileCheckpointing = optionsRecord.enableFileCheckpointing;\n        }\n        if (\n          optionsRecord.sandbox &&\n          typeof optionsRecord.sandbox === 'object' &&\n          !Array.isArray(optionsRecord.sandbox)\n        ) {\n          queryOptions.sandbox = optionsRecord.sandbox;\n        }\n\n        // Merge session-level env overrides with base claudeEnv\n        // Session env takes precedence over process env (useful for per-session API keys, etc.)\n        if (\n          optionsRecord.env &&\n          typeof optionsRecord.env === 'object' &&\n          !Array.isArray(optionsRecord.env)\n        ) {\n          const sessionEnv = optionsRecord.env as Record<string, unknown>;\n          const mergedEnv = { ...claudeEnv };\n          for (const [key, value] of Object.entries(sessionEnv)) {\n            if (typeof value === 'string') {\n              mergedEnv[key] = value;\n            }\n          }\n          // Ensure Node.js bin directory is still in PATH after merge\n          // Session may have overwritten PATH, which would break child processes\n          const nodeBinDir = path.dirname(process.execPath);\n          const mergedPath = mergedEnv.PATH || mergedEnv.Path || '';\n          if (!mergedPath.includes(nodeBinDir)) {\n            mergedEnv.PATH = [nodeBinDir, mergedPath].filter(Boolean).join(path.delimiter);\n          }\n          queryOptions.env = mergedEnv;\n        }\n      }\n\n      // Inject the local Chrome MCP server based on project preference.\n      // This only controls the built-in \"chrome-mcp\" entry; user-configured MCP servers remain untouched.\n      const CHROME_MCP_SERVER_NAME = 'chrome-mcp';\n      if (enableChromeMcp) {\n        const existingMcpServers =\n          queryOptions.mcpServers &&\n          typeof queryOptions.mcpServers === 'object' &&\n          !Array.isArray(queryOptions.mcpServers)\n            ? (queryOptions.mcpServers as Record<string, unknown>)\n            : {};\n\n        queryOptions.mcpServers = {\n          ...existingMcpServers,\n          [CHROME_MCP_SERVER_NAME]: {\n            type: 'http',\n            url: getChromeMcpUrl(),\n          },\n        };\n        console.error(`[ClaudeEngine] Chrome MCP server enabled: ${getChromeMcpUrl()}`);\n      } else if (\n        queryOptions.mcpServers &&\n        typeof queryOptions.mcpServers === 'object' &&\n        !Array.isArray(queryOptions.mcpServers)\n      ) {\n        // If Chrome MCP is disabled, remove it from existing mcpServers if present\n        const existing = queryOptions.mcpServers as Record<string, unknown>;\n        if (CHROME_MCP_SERVER_NAME in existing) {\n          const { [CHROME_MCP_SERVER_NAME]: _removed, ...rest } = existing;\n          if (Object.keys(rest).length > 0) {\n            queryOptions.mcpServers = rest;\n          } else {\n            delete (queryOptions as Record<string, unknown>).mcpServers;\n          }\n        }\n        console.error('[ClaudeEngine] Chrome MCP server disabled');\n      }\n\n      // Add resume option if we have a valid Claude session ID\n      if (resumeClaudeSessionId) {\n        queryOptions.resume = resumeClaudeSessionId;\n        console.error(`[ClaudeEngine] Resuming Claude session: ${resumeClaudeSessionId}`);\n      }\n\n      const response = query({\n        prompt: promptInstruction,\n        options: queryOptions,\n      });\n\n      // Process streaming response\n      for await (const message of response) {\n        // Check for cancellation before processing each message\n        if (signal?.aborted) {\n          console.error('[ClaudeEngine] Execution cancelled via abort signal');\n          throw new Error('ClaudeEngine: execution was cancelled');\n        }\n\n        console.error('[ClaudeEngine] Message type:', message.type);\n\n        if (message.type === 'stream_event') {\n          const event = (message as unknown as { event?: Record<string, unknown> }).event ?? {};\n          const eventType = this.pickFirstString(event.type);\n\n          switch (eventType) {\n            case 'message_start': {\n              // Reset assistant state for new message\n              assistantBuffer = '';\n              assistantMessageId = randomUUID();\n              assistantCreatedAt = new Date().toISOString();\n              lastAssistantEmitted = null;\n              break;\n            }\n\n            case 'content_block_start': {\n              const contentBlock = event.content_block as Record<string, unknown> | undefined;\n              const blockIndex =\n                typeof event.index === 'number' ? event.index : ++currentContentBlockIndex;\n              currentContentBlockIndex = blockIndex;\n\n              if (contentBlock && contentBlock.type === 'tool_use') {\n                const toolName = this.pickFirstString(contentBlock.name) || 'tool';\n                const toolId = this.pickFirstString(contentBlock.id) || '';\n\n                // Store pending tool input for accumulation\n                // Don't emit message here - wait for content_block_stop with complete input\n                pendingToolInputs.set(blockIndex, {\n                  toolName,\n                  toolId,\n                  inputJsonParts: [],\n                });\n              } else if (contentBlock && contentBlock.type === 'tool_result') {\n                // Handle tool_result in content_block_start\n                const metadata = this.buildToolResultMetadata(contentBlock);\n                const content = this.extractToolResultContent(contentBlock);\n                const isError = contentBlock.is_error === true;\n\n                dispatchToolMessage(\n                  isError\n                    ? `Error: ${content || 'Tool execution failed'}`\n                    : content || 'Tool completed',\n                  metadata,\n                  'tool_result',\n                  false,\n                );\n              }\n              break;\n            }\n\n            case 'content_block_stop': {\n              const blockIndex =\n                typeof event.index === 'number' ? event.index : currentContentBlockIndex;\n\n              // Check if we have accumulated tool input for this block\n              if (pendingToolInputs.has(blockIndex)) {\n                const pending = pendingToolInputs.get(blockIndex)!;\n                pendingToolInputs.delete(blockIndex);\n\n                // Parse the accumulated JSON\n                const fullJsonStr = pending.inputJsonParts.join('');\n                let input: Record<string, unknown> = {};\n                try {\n                  if (fullJsonStr) {\n                    input = JSON.parse(fullJsonStr);\n                  }\n                } catch (e) {\n                  console.error(`[ClaudeEngine] Failed to parse tool input JSON: ${e}`);\n                }\n\n                console.error(\n                  `[ClaudeEngine] content_block_stop - toolName: ${pending.toolName}, input: ${JSON.stringify(input).slice(0, 500)}`,\n                );\n\n                // Build metadata with full input\n                const metadata = buildToolMetadata({\n                  name: pending.toolName,\n                  id: pending.toolId,\n                  input,\n                });\n\n                // Build informative content\n                let content = `Using tool: ${pending.toolName}`;\n                if (input.command) content = `Running: ${input.command}`;\n                else if (input.file_path) content = `Operating on: ${input.file_path}`;\n                else if (input.pattern) content = `Searching: ${input.pattern}`;\n                else if (input.query) content = `Searching: ${input.query}`;\n\n                // Emit final tool_use message with complete metadata\n                dispatchToolMessage(content, metadata, 'tool_use', false);\n              }\n\n              // Check if this block was a tool_result\n              const contentBlock = event.content_block as Record<string, unknown> | undefined;\n              if (contentBlock && contentBlock.type === 'tool_result') {\n                const metadata = this.buildToolResultMetadata(contentBlock);\n                const content = this.extractToolResultContent(contentBlock);\n                const isError = contentBlock.is_error === true;\n\n                dispatchToolMessage(\n                  isError\n                    ? `Error: ${content || 'Tool execution failed'}`\n                    : content || 'Tool completed',\n                  metadata,\n                  'tool_result',\n                  false,\n                );\n              }\n              break;\n            }\n\n            case 'content_block_delta': {\n              const delta = event.delta as Record<string, unknown> | string | undefined;\n              const blockIndex =\n                typeof event.index === 'number' ? event.index : currentContentBlockIndex;\n\n              // Check if this is a tool_use input_json_delta\n              if (delta && typeof delta === 'object' && delta.type === 'input_json_delta') {\n                const partialJson = delta.partial_json as string | undefined;\n                if (partialJson && pendingToolInputs.has(blockIndex)) {\n                  pendingToolInputs.get(blockIndex)!.inputJsonParts.push(partialJson);\n                }\n                break;\n              }\n\n              // Handle text delta for assistant messages\n              let textChunk = '';\n\n              if (typeof delta === 'string') {\n                textChunk = delta;\n              } else if (delta && typeof delta === 'object') {\n                if (typeof delta.text === 'string') {\n                  textChunk = delta.text;\n                } else if (typeof delta.delta === 'string') {\n                  textChunk = delta.delta;\n                } else if (typeof delta.partial === 'string') {\n                  textChunk = delta.partial;\n                }\n              }\n\n              if (textChunk) {\n                assistantBuffer += textChunk;\n                emitAssistant(false);\n              }\n              break;\n            }\n\n            case 'message_delta': {\n              // message_delta usually contains metadata only (stop_reason, usage)\n              // Don't emit final here to avoid duplicate finals\n              break;\n            }\n\n            case 'message_stop': {\n              // Emit final assistant message only on message_stop\n              emitAssistant(true);\n              break;\n            }\n\n            default:\n              // Other stream events are ignored\n              break;\n          }\n        } else if (message.type === 'assistant') {\n          // Fallback for non-streaming assistant messages\n          const content = this.extractMessageContent(message);\n          if (content) {\n            assistantBuffer = content;\n            emitAssistant(true);\n          }\n        } else if (message.type === 'result') {\n          // Final result - check for errors first\n          const resultRecord = message as unknown as Record<string, unknown>;\n\n          // Log full result for debugging\n          console.error(`[ClaudeEngine] Result message: ${JSON.stringify(resultRecord, null, 2)}`);\n\n          // Extract and emit usage statistics\n          const usage = resultRecord.usage as Record<string, unknown> | undefined;\n          const totalCostUsd =\n            typeof resultRecord.total_cost_usd === 'number' ? resultRecord.total_cost_usd : 0;\n          const durationMs =\n            typeof resultRecord.duration_ms === 'number' ? resultRecord.duration_ms : 0;\n          const numTurns = typeof resultRecord.num_turns === 'number' ? resultRecord.num_turns : 0;\n\n          if (usage || totalCostUsd > 0) {\n            ctx.emit({\n              type: 'usage',\n              data: {\n                sessionId,\n                requestId,\n                inputTokens: typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,\n                outputTokens: typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,\n                cacheReadInputTokens:\n                  typeof usage?.cache_read_input_tokens === 'number'\n                    ? usage.cache_read_input_tokens\n                    : undefined,\n                cacheCreationInputTokens:\n                  typeof usage?.cache_creation_input_tokens === 'number'\n                    ? usage.cache_creation_input_tokens\n                    : undefined,\n                totalCostUsd,\n                durationMs,\n                numTurns,\n              },\n            });\n          }\n\n          // Check if result contains errors (SDK puts error details here)\n          // Note: is_error can be true even with empty errors array\n          if (resultRecord.is_error) {\n            const errors = resultRecord.errors as string[] | undefined;\n            const resultText = resultRecord.result as string | undefined;\n            const errorMsg = errors?.length\n              ? errors.join('; ')\n              : resultText || 'Unknown error from Claude Code';\n            console.error(`[ClaudeEngine] Result error: ${errorMsg}`);\n\n            // Check if this is a resume failure\n            const isResumeFailure =\n              errorMsg.includes('No conversation found') ||\n              errorMsg.includes('Failed to resume session') ||\n              errorMsg.includes('session ID');\n\n            if (isResumeFailure && resumeClaudeSessionId) {\n              // Clear the stored session ID so next request starts fresh\n              if (ctx.persistClaudeSessionId && projectId) {\n                try {\n                  // Pass empty string to clear the session\n                  await ctx.persistClaudeSessionId('');\n                  console.error('[ClaudeEngine] Cleared invalid session ID');\n                } catch {\n                  // Ignore clear errors\n                }\n              }\n              throw new Error(\n                `Resume failed: ${errorMsg}. Session has been cleared - please retry.`,\n              );\n            }\n\n            throw new Error(errorMsg);\n          }\n\n          // Extract content from successful result\n          const resultContent = this.extractMessageContent(message);\n          if (resultContent && resultContent !== assistantBuffer.trim()) {\n            assistantBuffer = resultContent;\n            emitAssistant(true);\n          }\n        } else if (message.type === 'system') {\n          // Handle system messages\n          const record = message as unknown as Record<string, unknown>;\n          const subtype = this.pickFirstString(record.subtype);\n\n          if (subtype === 'init') {\n            // system:init - contains session_id and management information\n            const claudeSessionId = record.session_id ? String(record.session_id) : undefined;\n\n            if (claudeSessionId) {\n              console.error(`[ClaudeEngine] Session initialized: ${claudeSessionId}`);\n\n              // Persist the session ID if callback is provided and projectId exists\n              if (ctx.persistClaudeSessionId && projectId) {\n                try {\n                  await ctx.persistClaudeSessionId(claudeSessionId);\n                  console.error(`[ClaudeEngine] Session ID persisted for project: ${projectId}`);\n                } catch (persistError) {\n                  console.error('[ClaudeEngine] Failed to persist session ID:', persistError);\n                }\n              }\n            }\n\n            // Extract and persist management information\n            if (ctx.persistManagementInfo) {\n              try {\n                const managementInfo = {\n                  tools: Array.isArray(record.tools)\n                    ? record.tools.filter((t): t is string => typeof t === 'string')\n                    : undefined,\n                  agents: Array.isArray(record.agents)\n                    ? record.agents.filter((a): a is string => typeof a === 'string')\n                    : undefined,\n                  // SDK returns plugins as { name, path }[] objects\n                  plugins: Array.isArray(record.plugins)\n                    ? (record.plugins as Array<{ name?: string; path?: string }>)\n                        .filter((p) => p && typeof p.name === 'string')\n                        .map((p) => ({\n                          name: String(p.name),\n                          path: p.path ? String(p.path) : undefined,\n                        }))\n                    : undefined,\n                  skills: Array.isArray(record.skills)\n                    ? record.skills.filter((s): s is string => typeof s === 'string')\n                    : undefined,\n                  mcpServers: Array.isArray(record.mcp_servers)\n                    ? (record.mcp_servers as Array<{ name?: string; status?: string }>)\n                        .filter((s) => s && typeof s.name === 'string')\n                        .map((s) => ({\n                          name: String(s.name),\n                          status: String(s.status || 'unknown'),\n                        }))\n                    : undefined,\n                  slashCommands: Array.isArray(record.slash_commands)\n                    ? record.slash_commands.filter((c): c is string => typeof c === 'string')\n                    : undefined,\n                  model: this.pickFirstString(record.model),\n                  permissionMode: this.pickFirstString(record.permissionMode),\n                  cwd: this.pickFirstString(record.cwd),\n                  outputStyle: this.pickFirstString(record.output_style),\n                  betas: Array.isArray(record.betas)\n                    ? record.betas.filter((b): b is string => typeof b === 'string')\n                    : undefined,\n                  claudeCodeVersion: this.pickFirstString(record.claude_code_version),\n                  apiKeySource: this.pickFirstString(record.apiKeySource),\n                };\n\n                await ctx.persistManagementInfo(managementInfo);\n                console.error('[ClaudeEngine] Management info persisted');\n              } catch (persistError) {\n                console.error('[ClaudeEngine] Failed to persist management info:', persistError);\n              }\n            }\n          } else if (subtype === 'status') {\n            // system:status - log for debugging (e.g., compacting)\n            const statusText = this.pickFirstString(record.status);\n            console.error(`[ClaudeEngine] System status: ${statusText || 'unknown'}`);\n          }\n        } else if (message.type === 'auth_status') {\n          // Handle authentication status - SDK fields: isAuthenticating, output, error\n          const record = message as unknown as Record<string, unknown>;\n          const isAuthenticating = record.isAuthenticating === true;\n          const output = Array.isArray(record.output)\n            ? record.output.filter((o): o is string => typeof o === 'string')\n            : [];\n          const authError = this.pickFirstString(record.error);\n\n          console.error(\n            `[ClaudeEngine] Auth status: isAuthenticating=${isAuthenticating}, hasError=${!!authError}`,\n          );\n\n          // Build content from output or error\n          const content = authError || output.join('\\n') || 'Authentication in progress...';\n\n          // Determine if login is required:\n          // - Not currently authenticating AND (has error OR output contains login keywords)\n          const outputText = output.join(' ').toLowerCase();\n          const requiresLogin =\n            !isAuthenticating &&\n            (!!authError ||\n              outputText.includes('login') ||\n              outputText.includes('authenticate') ||\n              outputText.includes('sign in'));\n\n          // Emit auth status as a system message so UI can display login prompts\n          const authSystemMessage: AgentMessage = {\n            id: randomUUID(),\n            sessionId,\n            role: 'system',\n            content,\n            messageType: 'status',\n            cliSource: this.name,\n            requestId,\n            isStreaming: false,\n            isFinal: !isAuthenticating,\n            createdAt: new Date().toISOString(),\n            metadata: {\n              cli_type: 'claude',\n              event_type: 'auth_status',\n              isAuthenticating,\n              output,\n              error: authError,\n              requires_login: requiresLogin,\n            },\n          };\n\n          ctx.emit({ type: 'message', data: authSystemMessage });\n        } else if (message.type === 'tool_progress') {\n          // Handle tool progress - SDK fields: tool_use_id, tool_name, parent_tool_use_id, elapsed_time_seconds\n          const record = message as unknown as Record<string, unknown>;\n          const toolUseId = this.pickFirstString(record.tool_use_id);\n          const toolName = this.pickFirstString(record.tool_name);\n          const parentToolUseId = this.pickFirstString(record.parent_tool_use_id);\n          const elapsedTimeSeconds =\n            typeof record.elapsed_time_seconds === 'number'\n              ? record.elapsed_time_seconds\n              : undefined;\n\n          if (toolName || toolUseId) {\n            const displayName = toolName || toolUseId || 'tool';\n            const elapsedStr =\n              elapsedTimeSeconds !== undefined ? ` (${elapsedTimeSeconds.toFixed(1)}s)` : '';\n            console.error(`[ClaudeEngine] Tool progress: ${displayName}${elapsedStr}`);\n\n            // Use tool_use_id as message id if available, so UI can update the same progress entry\n            const messageId = toolUseId ? `progress-${toolUseId}` : randomUUID();\n\n            // Emit tool progress as a tool message\n            const progressMessage: AgentMessage = {\n              id: messageId,\n              sessionId,\n              role: 'tool',\n              content: `${displayName} in progress${elapsedStr}`,\n              messageType: 'tool_use',\n              cliSource: this.name,\n              requestId,\n              isStreaming: true,\n              isFinal: false,\n              createdAt: new Date().toISOString(),\n              metadata: {\n                cli_type: 'claude',\n                event_type: 'tool_progress',\n                toolUseId,\n                toolName,\n                parentToolUseId,\n                elapsedTimeSeconds,\n              },\n            };\n\n            ctx.emit({ type: 'message', data: progressMessage });\n          }\n        }\n      }\n\n      // Ensure final message is emitted\n      if (assistantBuffer.trim()) {\n        emitAssistant(true);\n      }\n\n      console.error('[ClaudeEngine] Query completed successfully');\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n\n      // Log full stderr for debugging\n      console.error(`[ClaudeEngine] Error: ${message}`);\n      if (stderrBuffer.length > 0) {\n        console.error(`[ClaudeEngine] Stderr (${stderrBuffer.length} lines):`);\n        stderrBuffer.slice(-10).forEach((line) => console.error(`  ${line}`));\n      }\n\n      // Check if this is a resume failure from stderr\n      const stderrText = stderrBuffer.join('\\n');\n      const isResumeFailure =\n        stderrText.includes('No conversation found') ||\n        stderrText.includes('Failed to resume session') ||\n        stderrText.includes('session ID') ||\n        message.includes('Resume failed');\n\n      if (isResumeFailure && resumeClaudeSessionId && ctx.persistClaudeSessionId && projectId) {\n        // Clear the stored session ID so next request starts fresh\n        try {\n          await ctx.persistClaudeSessionId('');\n          console.error('[ClaudeEngine] Cleared invalid session ID due to resume failure');\n        } catch {\n          // Ignore clear errors\n        }\n      }\n\n      // Enhance error message for CCR-related errors\n      const enhancedMessage = await this.enhanceCcrErrorMessage(message, stderrText);\n\n      // Classify errors for better UX\n      const errorMessage = this.classifyError(enhancedMessage, stderrBuffer);\n      throw new Error(`ClaudeEngine: ${errorMessage}`);\n    } finally {\n      // Always cleanup temp files, even on error\n      await cleanupTempFiles();\n    }\n  }\n\n  /**\n   * Build environment variables for Claude Code.\n   * Supports Claude Code Router (CCR) when useCcr is true:\n   * 1. Auto-detecting CCR from config file (~/.claude-code-router/config.json)\n   * 2. Passing through env vars if already set (via `eval \"$(ccr activate)\"`)\n   *\n   * SDK treats options.env as a complete replacement (not merged with process.env),\n   * so we must explicitly include all necessary variables.\n   *\n   * @param useCcr - Whether CCR is enabled for this project. When false/undefined, CCR detection is skipped.\n   */\n  private async buildClaudeEnv(useCcr?: boolean): Promise<NodeJS.ProcessEnv> {\n    const env: NodeJS.ProcessEnv = { ...process.env };\n\n    // Ensure Node.js bin directory is in PATH (for child processes)\n    const nodeBinDir = path.dirname(process.execPath);\n    const currentPath = env.PATH || env.Path || '';\n    if (!currentPath.includes(nodeBinDir)) {\n      env.PATH = [nodeBinDir, currentPath].filter(Boolean).join(path.delimiter);\n    }\n\n    // Only detect CCR if explicitly enabled for this project\n    if (useCcr && !env.ANTHROPIC_BASE_URL) {\n      try {\n        const ccrResult = await detectCcr();\n        if (ccrResult.detected && ccrResult.baseUrl && ccrResult.authToken) {\n          env.ANTHROPIC_BASE_URL = ccrResult.baseUrl;\n          env.ANTHROPIC_AUTH_TOKEN = ccrResult.authToken;\n          console.error(`[ClaudeEngine] CCR auto-detected (source: ${ccrResult.source})`);\n        } else if (ccrResult.error) {\n          console.error(`[ClaudeEngine] CCR detection failed: ${ccrResult.error}`);\n        } else {\n          console.error(\n            '[ClaudeEngine] CCR enabled but not detected (config not found or service not running)',\n          );\n        }\n      } catch (err) {\n        // CCR detection is best-effort, don't fail the request\n        console.error(`[ClaudeEngine] CCR detection error: ${err}`);\n      }\n    }\n\n    // Log CCR-related env vars for debugging (without exposing full token)\n    const baseUrl = env.ANTHROPIC_BASE_URL;\n    const authToken = env.ANTHROPIC_AUTH_TOKEN;\n    if (baseUrl) {\n      console.error(`[ClaudeEngine] Using ANTHROPIC_BASE_URL: ${baseUrl}`);\n    }\n    if (authToken) {\n      const preview =\n        authToken.length > 8 ? `${authToken.slice(0, 4)}...${authToken.slice(-4)}` : '****';\n      console.error(`[ClaudeEngine] Using ANTHROPIC_AUTH_TOKEN: ${preview}`);\n    }\n\n    return env;\n  }\n\n  /**\n   * Resolve project root path.\n   */\n  private resolveRepoPath(projectRoot?: string): string {\n    const base =\n      (projectRoot && projectRoot.trim()) || process.env.MCP_AGENT_PROJECT_ROOT || process.cwd();\n    return path.resolve(base);\n  }\n\n  /**\n   * Pick first string value from unknown input.\n   */\n  private pickFirstString(value: unknown): string | undefined {\n    if (typeof value === 'string') {\n      const trimmed = value.trim();\n      return trimmed.length > 0 ? trimmed : undefined;\n    }\n    if (typeof value === 'number' || typeof value === 'boolean') {\n      return String(value);\n    }\n    if (Array.isArray(value)) {\n      for (const entry of value) {\n        const candidate = this.pickFirstString(entry);\n        if (candidate) return candidate;\n      }\n      return undefined;\n    }\n    return undefined;\n  }\n\n  /**\n   * Extract content from SDK message.\n   * Handles various message structures from Claude Agent SDK:\n   * - result.result (final result text)\n   * - assistant.message (nested message content)\n   * - content/text (direct content fields)\n   * - content[] (array of content blocks)\n   *\n   * @param message - The message object to extract content from\n   * @param depth - Current recursion depth (max 3 to prevent infinite loops)\n   */\n  private extractMessageContent(message: unknown, depth = 0): string | undefined {\n    // Prevent infinite recursion\n    if (depth > 3 || !message || typeof message !== 'object') return undefined;\n    const record = message as Record<string, unknown>;\n\n    // Handle result message: result field contains final text\n    if (typeof record.result === 'string') {\n      return record.result.trim();\n    }\n\n    // Handle assistant message: message field may contain nested content\n    if (record.message && typeof record.message === 'object') {\n      const nested = this.extractMessageContent(record.message, depth + 1);\n      if (nested) return nested;\n    }\n\n    // Try common content fields\n    if (typeof record.content === 'string') {\n      return record.content.trim();\n    }\n    if (typeof record.text === 'string') {\n      return record.text.trim();\n    }\n    if (Array.isArray(record.content)) {\n      const textParts: string[] = [];\n      for (const part of record.content) {\n        if (part && typeof part === 'object' && (part as Record<string, unknown>).type === 'text') {\n          const text = (part as Record<string, unknown>).text;\n          if (typeof text === 'string') {\n            textParts.push(text);\n          }\n        }\n      }\n      if (textParts.length > 0) {\n        return textParts.join('').trim();\n      }\n    }\n\n    return undefined;\n  }\n\n  /**\n   * Format error message for user display.\n   * Preserves the original error message and only appends stderr context if useful.\n   */\n  private classifyError(message: string, stderrBuffer: string[]): string {\n    // Always preserve the original error message\n    // Only append stderr context if it contains useful information beyond the spawn line\n    const usefulStderr = stderrBuffer.filter(\n      (line) => !line.includes('Spawning Claude Code:') && line.trim().length > 0,\n    );\n\n    if (usefulStderr.length > 0) {\n      const lastLines = usefulStderr.slice(-3).join(' | ');\n      return `${message} (stderr: ${lastLines})`;\n    }\n\n    return message;\n  }\n\n  /**\n   * Validate CCR configuration and emit a warning message if issues are found.\n   * This is a best-effort check to provide actionable guidance before CCR crashes.\n   */\n  private async validateAndWarnCcrConfig(\n    sessionId: string,\n    requestId: string | undefined,\n    ctx: EngineExecutionContext,\n  ): Promise<void> {\n    try {\n      const validation = await validateCcrConfig();\n\n      if (!validation.checked || validation.valid) {\n        return;\n      }\n\n      // Build user-friendly warning message\n      const lines = [\n        '⚠️ Claude Code Router (CCR) configuration issue detected:',\n        validation.issue ?? 'CCR configuration appears invalid.',\n        '',\n        validation.suggestion ?? 'Please check your CCR configuration.',\n      ];\n\n      if (validation.suggestedFix) {\n        lines.push('', `Suggested fix: Router.default = \"${validation.suggestedFix}\"`);\n      }\n\n      const content = lines.join('\\n');\n      console.error(`[ClaudeEngine] CCR config warning: ${validation.issue}`);\n\n      const warningMessage: AgentMessage = {\n        id: randomUUID(),\n        sessionId,\n        role: 'system',\n        content,\n        messageType: 'status',\n        cliSource: this.name,\n        requestId,\n        isStreaming: false,\n        isFinal: true,\n        createdAt: new Date().toISOString(),\n        metadata: {\n          cli_type: 'claude',\n          warning_type: 'ccr_config',\n          ccr_issue: validation.issue,\n          ccr_suggested_fix: validation.suggestedFix,\n        },\n      };\n\n      ctx.emit({ type: 'message', data: warningMessage });\n    } catch (err) {\n      // CCR config validation is best-effort, don't fail the request\n      console.error('[ClaudeEngine] CCR config validation error:', err);\n    }\n  }\n\n  /**\n   * Enhance error messages for CCR-related errors.\n   * Detects the common \"includes of undefined\" crash and provides actionable guidance.\n   */\n  private async enhanceCcrErrorMessage(message: string, stderrText: string): Promise<string> {\n    const combinedText = `${message}\\n${stderrText}`;\n\n    // Detect CCR's \"includes of undefined\" error pattern\n    const isCcrIncludesError =\n      combinedText.includes('claude-code-router') &&\n      (combinedText.includes(\"reading 'includes'\") || combinedText.includes('transformRequestIn'));\n\n    if (!isCcrIncludesError) {\n      return message;\n    }\n\n    // Try to get specific fix suggestion from CCR config\n    let suggestion =\n      'Edit ~/.claude-code-router/config.json and set Router.default to \"provider,model\" format (e.g., \"venus,claude-4-5-sonnet-20250929\"), then restart CCR.';\n\n    try {\n      const validation = await validateCcrConfig();\n      if (validation.checked && !validation.valid && validation.suggestion) {\n        suggestion = validation.suggestion;\n      }\n    } catch {\n      // Use default suggestion if validation fails\n    }\n\n    return [\n      message,\n      '',\n      '💡 CCR Configuration Issue Detected:',\n      'This error is commonly caused by Router.default being set to only a provider name',\n      '(e.g., \"venus\") instead of the required \"provider,model\" format.',\n      '',\n      `Fix: ${suggestion}`,\n    ].join('\\n');\n  }\n\n  /**\n   * Build metadata for tool result events.\n   */\n  private buildToolResultMetadata(block: Record<string, unknown>): Record<string, unknown> {\n    const toolUseId = this.pickFirstString(block.tool_use_id);\n    const isError = block.is_error === true;\n\n    return {\n      toolUseId,\n      is_error: isError,\n      status: isError ? 'failed' : 'completed',\n      cli_type: 'claude',\n    };\n  }\n\n  /**\n   * Extract content from a tool_result block.\n   */\n  private extractToolResultContent(block: Record<string, unknown>): string | undefined {\n    const content = block.content;\n    if (typeof content === 'string') return content;\n    if (Array.isArray(content)) {\n      const textParts = content\n        .filter((c) => c && typeof c === 'object' && (c as Record<string, unknown>).type === 'text')\n        .map((c) => (c as Record<string, unknown>).text as string)\n        .filter(Boolean);\n      if (textParts.length > 0) {\n        return textParts.join('\\n');\n      }\n    }\n    return undefined;\n  }\n\n  /**\n   * Encode string to base64 for hashing.\n   */\n  private encodeHash(value: string): string {\n    return Buffer.from(value, 'utf-8').toString('base64');\n  }\n\n  /**\n   * Write an attachment to a temporary file and return its path.\n   */\n  private async writeAttachmentToTemp(attachment: {\n    type: string;\n    name: string;\n    mimeType: string;\n    dataBase64: string;\n  }): Promise<string> {\n    const os = await import('node:os');\n    const fs = await import('node:fs/promises');\n\n    const tempDir = os.tmpdir();\n    const ext = attachment.mimeType.split('/')[1] || 'bin';\n    const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9.-]/g, '_');\n    const fileName = `mcp-agent-${Date.now()}-${sanitizedName}.${ext}`;\n    const filePath = path.join(tempDir, fileName);\n\n    const buffer = Buffer.from(attachment.dataBase64, 'base64');\n    await fs.writeFile(filePath, buffer);\n\n    return filePath;\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/engines/codex.ts",
    "content": "import { spawn } from 'node:child_process';\nimport readline from 'node:readline';\nimport path from 'node:path';\nimport { randomUUID } from 'node:crypto';\nimport {\n  CODEX_AUTO_INSTRUCTIONS,\n  DEFAULT_CODEX_CONFIG,\n  type CodexEngineConfig,\n} from 'chrome-mcp-shared';\nimport type { AgentEngine, EngineExecutionContext, EngineInitOptions } from './types';\nimport type { AgentMessage, RealtimeEvent } from '../types';\nimport { AgentToolBridge } from '../tool-bridge';\nimport { getProject } from '../project-service';\nimport { getChromeMcpUrl } from '../../constant';\n\ntype TodoListPhase = 'started' | 'update' | 'completed';\n\ninterface TodoListItem {\n  text: string;\n  completed: boolean;\n  index: number;\n}\n\n/**\n * CodexEngine integrates the Codex CLI as an AgentEngine implementation.\n *\n * The implementation is intentionally self-contained and does not persist messages;\n * it focuses on streaming Codex JSON events into RealtimeEvent envelopes that the\n * sidepanel UI can consume.\n *\n * 中文说明：该引擎基于 other/cweb 中 Codex 适配器的事件协议，完整处理\n * item.started/item.delta/item.completed/item.failed/error 等事件，并\n * 通过 AgentStreamManager 将编码后的 RealtimeEvent 推送给 sidepanel，\n * 确保数据链路「Sidepanel → Native Server → Codex CLI → Sidepanel」闭环。\n */\nexport class CodexEngine implements AgentEngine {\n  public readonly name = 'codex' as const;\n  public readonly supportsMcp = false;\n  private readonly toolBridge: AgentToolBridge;\n\n  constructor(toolBridge?: AgentToolBridge) {\n    this.toolBridge = toolBridge ?? new AgentToolBridge();\n  }\n\n  /**\n   * Maximum number of stderr lines to keep in memory to avoid unbounded growth.\n   */\n  private static readonly MAX_STDERR_LINES = 200;\n\n  async initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void> {\n    const {\n      sessionId,\n      instruction,\n      model,\n      projectRoot,\n      projectId,\n      requestId,\n      signal,\n      attachments,\n      resolvedImagePaths,\n      codexConfig,\n    } = options;\n    const repoPath = this.resolveRepoPath(projectRoot);\n\n    // Check if already aborted\n    if (signal?.aborted) {\n      throw new Error('CodexEngine: execution was cancelled');\n    }\n\n    const normalizedInstruction = instruction.trim();\n    if (!normalizedInstruction) {\n      throw new Error('CodexEngine: instruction must not be empty');\n    }\n\n    // Merge user config with defaults\n    const resolvedConfig: CodexEngineConfig = {\n      ...DEFAULT_CODEX_CONFIG,\n      ...(codexConfig ?? {}),\n    };\n\n    // Ensure autoInstructions has a value\n    if (!resolvedConfig.autoInstructions?.trim()) {\n      resolvedConfig.autoInstructions = CODEX_AUTO_INSTRUCTIONS;\n    }\n\n    // Resolve project-scoped Chrome MCP toggle (default: enabled)\n    const enableChromeMcp = await (async (): Promise<boolean> => {\n      if (!projectId) return true;\n      try {\n        const project = await getProject(projectId);\n        return project?.enableChromeMcp !== false;\n      } catch (err) {\n        const message = err instanceof Error ? err.message : String(err);\n        console.error(\n          `[CodexEngine] Failed to load project enableChromeMcp, defaulting to enabled: ${message}`,\n        );\n        return true;\n      }\n    })();\n\n    // Optionally append project context to the prompt\n    const prompt = resolvedConfig.appendProjectContext\n      ? await this.appendProjectContext(normalizedInstruction, repoPath)\n      : normalizedInstruction;\n\n    const executable = process.platform === 'win32' ? 'codex.cmd' : 'codex';\n    const args: string[] = [\n      'exec',\n      '--json',\n      '--skip-git-repo-check',\n      '--dangerously-bypass-approvals-and-sandbox',\n      '--color',\n      'never',\n      '--cd',\n      repoPath,\n    ];\n\n    // Add Codex configuration arguments\n    args.push(...this.buildCodexConfigArgs(resolvedConfig));\n\n    // Inject local Chrome MCP server via runtime config override (no global codex config mutation)\n    // Use a unique server name to avoid collision with any existing global config\n    if (enableChromeMcp) {\n      const chromeMcpUrl = getChromeMcpUrl();\n      // Set both url and type for complete HTTP MCP server configuration\n      args.push('-c', `mcp_servers.chrome_mcp_http.url=${JSON.stringify(chromeMcpUrl)}`);\n      args.push('-c', `mcp_servers.chrome_mcp_http.type=\"http\"`);\n      console.error(`[CodexEngine] Chrome MCP server enabled: ${chromeMcpUrl}`);\n    } else {\n      console.error('[CodexEngine] Chrome MCP server disabled');\n    }\n\n    if (model && model.trim()) {\n      args.push('--model', model.trim());\n    }\n\n    // Process image attachments - prefer resolvedImagePaths (persisted), fallback to temp files\n    const tempFiles: string[] = [];\n    const hasResolvedPaths = resolvedImagePaths && resolvedImagePaths.length > 0;\n\n    if (hasResolvedPaths) {\n      // Use pre-resolved persistent paths (preferred - no temp files needed)\n      console.error(`[CodexEngine] Using ${resolvedImagePaths.length} pre-resolved image path(s)`);\n      for (const imagePath of resolvedImagePaths) {\n        args.push('--image', imagePath);\n      }\n    } else if (attachments && attachments.length > 0) {\n      // Fallback: write base64 to temp files (legacy behavior)\n      for (const attachment of attachments) {\n        if (attachment.type === 'image') {\n          try {\n            const tempFile = await this.writeAttachmentToTemp(attachment);\n            tempFiles.push(tempFile);\n            args.push('--image', tempFile);\n          } catch (err) {\n            console.error('[CodexEngine] Failed to write attachment to temp file:', err);\n          }\n        }\n      }\n    }\n\n    args.push(prompt);\n\n    // Use explicit Promise wrapping to ensure child process errors are properly rejected.\n    return new Promise<void>((resolve, reject) => {\n      const child = spawn(executable, args, {\n        cwd: repoPath,\n        env: this.buildCodexEnv(),\n        stdio: ['ignore', 'pipe', 'pipe'],\n      });\n\n      // State management\n      const stderrBuffer: string[] = [];\n      let hasCompleted = false;\n      let timedOut = false;\n      let settled = false;\n      let timeoutHandle: NodeJS.Timeout | null = null;\n\n      // Readline interface - declared early to avoid TDZ issues in finish()\n      let rl: readline.Interface | null = null;\n\n      // Assistant message state\n      let assistantBuffer = '';\n      let assistantMessageId: string | null = null;\n      let assistantCreatedAt: string | null = null;\n      const streamedToolHashes = new Set<string>();\n      const activeCommands = new Map<string, { command?: string }>();\n      const thinkingSegments: string[] = [];\n\n      /**\n       * Cleanup temporary files created for image attachments.\n       */\n      const cleanupTempFiles = async (): Promise<void> => {\n        if (tempFiles.length === 0) return;\n\n        const fs = await import('node:fs/promises');\n        for (const filePath of tempFiles) {\n          try {\n            await fs.unlink(filePath);\n            console.error(`[CodexEngine] Cleaned up temp file: ${filePath}`);\n          } catch (err) {\n            // Ignore errors during cleanup - file may already be deleted\n            console.error(`[CodexEngine] Failed to cleanup temp file ${filePath}:`, err);\n          }\n        }\n      };\n\n      /**\n       * Cleanup and settle the promise (resolve or reject).\n       * Waits for temp file cleanup to complete before settling.\n       */\n      const finish = async (error?: unknown): Promise<void> => {\n        if (settled) return;\n        settled = true;\n\n        // Clear timeout\n        if (timeoutHandle) {\n          clearTimeout(timeoutHandle);\n          timeoutHandle = null;\n        }\n\n        // Close readline interface\n        if (rl) {\n          try {\n            rl.close();\n          } catch {\n            // Ignore close errors during cleanup\n          }\n        }\n\n        // Kill child process if still running\n        if (!child.killed) {\n          try {\n            child.kill();\n          } catch {\n            // Ignore kill errors during cleanup\n          }\n        }\n\n        // Cleanup temp files after process is killed (wait for completion)\n        await cleanupTempFiles();\n\n        // Settle the promise\n        if (error) {\n          reject(error instanceof Error ? error : new Error(String(error)));\n        } else {\n          resolve();\n        }\n      };\n\n      // Handle child process error immediately after spawn (e.g., command not found)\n      child.on('error', (error) => {\n        const message =\n          error instanceof Error\n            ? error.message\n            : stderrBuffer.slice(-5).join('\\n') || 'Codex CLI failed to start';\n        void finish(new Error(`CodexEngine: ${message}`));\n      });\n\n      // Listen for abort signal to cancel execution\n      const abortHandler = signal\n        ? () => {\n            console.error('[CodexEngine] Execution cancelled via abort signal');\n            void finish(new Error('CodexEngine: execution was cancelled'));\n          }\n        : null;\n\n      if (signal && abortHandler) {\n        signal.addEventListener('abort', abortHandler, { once: true });\n      }\n\n      // Collect stderr with bounded buffer\n      child.stderr?.on('data', (chunk) => {\n        const text = String(chunk).trim();\n        if (!text) return;\n\n        stderrBuffer.push(text);\n        // Keep only the most recent lines to prevent memory growth\n        if (stderrBuffer.length > CodexEngine.MAX_STDERR_LINES) {\n          stderrBuffer.splice(0, stderrBuffer.length - CodexEngine.MAX_STDERR_LINES);\n        }\n\n        console.error('[CodexEngine][stderr]', text);\n      });\n\n      rl = readline.createInterface({ input: child.stdout });\n\n      /**\n       * Build the assistant message payload, combining thinking and agent content.\n       */\n      const buildAssistantPayload = (): string => {\n        const trimmedAssistant = assistantBuffer.trim();\n        const thinkingContent = thinkingSegments\n          .map((segment) => segment.trim())\n          .filter((segment) => segment.length > 0)\n          .map((segment) => `<thinking>${segment}</thinking>`)\n          .join('\\n\\n');\n\n        const parts: string[] = [];\n        if (thinkingContent) {\n          parts.push(thinkingContent);\n        }\n        if (trimmedAssistant) {\n          parts.push(trimmedAssistant);\n        }\n        return parts.join('\\n\\n').trim();\n      };\n\n      /**\n       * Reset assistant buffers after emitting a final message.\n       */\n      const resetAssistantBuffers = (): void => {\n        assistantBuffer = '';\n        thinkingSegments.length = 0;\n        assistantMessageId = null;\n        assistantCreatedAt = null;\n      };\n\n      // Helper: emit assistant message\n      const emitAssistant = (isFinal: boolean): void => {\n        const content = buildAssistantPayload();\n        if (!content) return;\n\n        if (!assistantMessageId) {\n          assistantMessageId = randomUUID();\n        }\n        if (!assistantCreatedAt) {\n          assistantCreatedAt = new Date().toISOString();\n        }\n\n        const message: AgentMessage = {\n          id: assistantMessageId,\n          sessionId,\n          role: 'assistant',\n          content,\n          messageType: 'chat',\n          cliSource: this.name,\n          requestId,\n          isStreaming: !isFinal,\n          isFinal,\n          createdAt: assistantCreatedAt,\n        };\n\n        ctx.emit({ type: 'message', data: message });\n      };\n\n      // Helper: emit tool message with deduplication\n      const dispatchToolMessage = (\n        content: string,\n        metadata: Record<string, unknown>,\n        messageType: 'tool_use' | 'tool_result',\n        isStreaming: boolean,\n      ): void => {\n        const trimmed = content.trim();\n        if (!trimmed) return;\n\n        const hash = this.encodeHash(\n          `${messageType}:${trimmed}:${JSON.stringify(metadata)}:${sessionId}:${requestId || ''}`,\n        ).slice(0, 16);\n        if (streamedToolHashes.has(hash)) return;\n        streamedToolHashes.add(hash);\n\n        const message: AgentMessage = {\n          id: randomUUID(),\n          sessionId,\n          role: 'tool',\n          content: trimmed,\n          messageType,\n          cliSource: this.name,\n          requestId,\n          isStreaming,\n          isFinal: !isStreaming,\n          createdAt: new Date().toISOString(),\n          metadata: { cli_type: 'codex', ...metadata },\n        };\n\n        ctx.emit({ type: 'message', data: message });\n      };\n\n      // Event handlers for specific item types\n      const emitCommandStart = (item: Record<string, unknown>): void => {\n        const id = this.pickFirstString(item.id) ?? randomUUID();\n        const command = this.pickFirstString(item.command);\n        activeCommands.set(id, { command });\n        dispatchToolMessage(\n          command ? `Running: ${command}` : 'Running command',\n          {\n            toolName: 'Bash',\n            tool_name: 'Bash',\n            command,\n            status: this.pickFirstString(item.status) ?? 'in_progress',\n          },\n          'tool_use',\n          true,\n        );\n      };\n\n      const emitCommandResult = (item: Record<string, unknown>): void => {\n        const id = this.pickFirstString(item.id);\n        const tracked = id ? activeCommands.get(id) : undefined;\n        if (id) {\n          activeCommands.delete(id);\n        }\n        const command = this.pickFirstString(item.command) ?? tracked?.command;\n        const output = this.pickFirstString(item.aggregated_output) ?? '';\n        const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;\n        const status = this.pickFirstString(item.status);\n        const isError = status === 'failed' || (typeof exitCode === 'number' && exitCode !== 0);\n\n        const summary = command ? `Ran: ${command}` : 'Executed shell command';\n        const exitSuffix = typeof exitCode === 'number' ? ` (exit ${exitCode})` : '';\n        const body = output.trim();\n        const fullContent = body ? `${summary}${exitSuffix}\\n\\n${body}` : `${summary}${exitSuffix}`;\n\n        dispatchToolMessage(\n          fullContent,\n          {\n            toolName: 'Bash',\n            tool_name: 'Bash',\n            command,\n            exitCode,\n            status,\n            output,\n            is_error: isError || undefined,\n          },\n          'tool_result',\n          false,\n        );\n      };\n\n      const emitFileChange = (item: Record<string, unknown>): void => {\n        const { content, metadata } = this.summarizeApplyPatch({\n          changes: item.changes as Record<string, unknown> | Array<Record<string, unknown>>,\n        });\n        const status = this.pickFirstString(item.status) ?? 'completed';\n        const isError = status === 'failed';\n        const toolName =\n          (metadata?.toolName as string) || (metadata?.tool_name as string) || 'Edit';\n\n        dispatchToolMessage(\n          isError ? `Failed: ${content}` : content,\n          { ...metadata, toolName, tool_name: toolName, status, is_error: isError || undefined },\n          'tool_result',\n          false,\n        );\n      };\n\n      const emitTodoListUpdate = (record: Record<string, unknown>, phase: TodoListPhase): void => {\n        const rawItems = this.extractTodoListItems(record);\n        const items = this.normalizeTodoListItems(rawItems);\n        const content = this.buildTodoListContent(items, phase);\n        const status =\n          this.pickFirstString(record.status) ??\n          (phase === 'completed' ? 'completed' : 'in_progress');\n        const metadata = this.createTodoListMetadata(items, phase, {\n          status,\n          planId: this.pickFirstString(record.id),\n        });\n\n        dispatchToolMessage(\n          content,\n          metadata,\n          phase === 'completed' ? 'tool_result' : 'tool_use',\n          phase === 'update',\n        );\n      };\n\n      // Item event handlers\n      const handleItemStarted = (item: unknown): void => {\n        if (!item || typeof item !== 'object') return;\n        const record = item as Record<string, unknown>;\n        const type = this.pickFirstString(record.type);\n        if (type === 'command_execution') {\n          emitCommandStart(record);\n        } else if (type === 'todo_list') {\n          emitTodoListUpdate(record, 'started');\n        }\n      };\n\n      const handleItemDelta = (delta: unknown): void => {\n        if (!delta || typeof delta !== 'object') return;\n        const record = delta as Record<string, unknown>;\n        const type = this.pickFirstString(record.type);\n\n        if (type === 'agent_message') {\n          const text = this.pickFirstString(record.text);\n          if (text) {\n            assistantBuffer += text;\n            emitAssistant(false);\n          }\n        } else if (type === 'reasoning') {\n          const text = this.pickFirstString(record.text);\n          if (text) {\n            thinkingSegments.push(text);\n            emitAssistant(false);\n          }\n        } else if (type === 'todo_list') {\n          emitTodoListUpdate(record, 'update');\n        }\n      };\n\n      const handleItemCompleted = (item: unknown): void => {\n        if (!item || typeof item !== 'object') return;\n        const record = item as Record<string, unknown>;\n        const type = this.pickFirstString(record.type);\n\n        switch (type) {\n          case 'command_execution':\n            emitCommandResult(record);\n            break;\n          case 'file_change':\n            emitFileChange(record);\n            break;\n          case 'todo_list':\n            emitTodoListUpdate(record, 'completed');\n            break;\n          case 'agent_message': {\n            const text = this.pickFirstString(record.text);\n            if (text) assistantBuffer = text;\n            emitAssistant(true);\n            resetAssistantBuffers();\n            break;\n          }\n          case 'reasoning': {\n            const text = this.pickFirstString(record.text);\n            if (text) {\n              thinkingSegments.push(text);\n              emitAssistant(false);\n            }\n            break;\n          }\n          default: {\n            const text = this.pickFirstString(record.text);\n            if (text) {\n              thinkingSegments.push(text);\n              emitAssistant(false);\n            }\n            break;\n          }\n        }\n      };\n\n      // Setup timeout\n      const timeoutMs =\n        Number.parseInt(process.env.CODEX_ENGINE_TIMEOUT_MS || '', 10) || 15 * 60 * 1000;\n      timeoutHandle = setTimeout(() => {\n        timedOut = true;\n        // Close readline to exit the loop\n        try {\n          rl.close();\n        } catch {\n          // Ignore\n        }\n        if (!child.killed) {\n          try {\n            child.kill();\n          } catch {\n            // Ignore\n          }\n        }\n      }, timeoutMs);\n      timeoutHandle.unref?.();\n\n      // Cleanup timeout and handle abnormal exit\n      child.on('close', (code: number | null, closeSignal: NodeJS.Signals | null) => {\n        if (timeoutHandle) {\n          clearTimeout(timeoutHandle);\n          timeoutHandle = null;\n        }\n\n        // If already timed out, settled, or completed normally, do nothing\n        if (timedOut || settled || hasCompleted) {\n          return;\n        }\n\n        // Build error detail from exit code and signal\n        const detailParts: string[] = [];\n        if (typeof code === 'number') {\n          detailParts.push(`exit code ${code}`);\n        }\n        if (closeSignal) {\n          detailParts.push(`signal ${closeSignal}`);\n        }\n        const detail = detailParts.length > 0 ? detailParts.join(', ') : 'unexpected shutdown';\n\n        // Emit final assistant message and mark as failed\n        emitAssistant(true);\n        resetAssistantBuffers();\n        hasCompleted = true;\n        void finish(new Error(`CodexEngine: process terminated (${detail})`));\n      });\n\n      // Main event processing loop (wrapped in IIFE to handle async properly)\n      void (async () => {\n        try {\n          for await (const line of rl) {\n            const trimmed = line.trim();\n            if (!trimmed) continue;\n\n            let event: Record<string, unknown>;\n            try {\n              event = JSON.parse(trimmed) as Record<string, unknown>;\n            } catch {\n              console.warn('[CodexEngine] Failed to parse Codex event line:', trimmed);\n              continue;\n            }\n\n            const eventType = this.pickFirstString(event.type);\n            switch (eventType) {\n              case 'item.started':\n                handleItemStarted((event as { item?: unknown }).item ?? null);\n                break;\n              case 'item.delta':\n                handleItemDelta((event as { delta?: unknown }).delta ?? null);\n                break;\n              case 'item.completed':\n                handleItemCompleted((event as { item?: unknown }).item ?? null);\n                break;\n              case 'item.failed': {\n                const item = (event as { item?: unknown }).item ?? null;\n                handleItemCompleted(item);\n                // Flush assistant message before throwing (aligned with other/cweb)\n                emitAssistant(true);\n                resetAssistantBuffers();\n                const msg =\n                  (item &&\n                    typeof item === 'object' &&\n                    this.pickFirstString((item as Record<string, unknown>).error)) ||\n                  'Codex execution failed';\n                hasCompleted = true;\n                throw new Error(msg);\n              }\n              case 'error': {\n                // Flush assistant message before throwing (aligned with other/cweb)\n                emitAssistant(true);\n                resetAssistantBuffers();\n                const msg =\n                  this.pickFirstString((event as { error?: unknown }).error) ||\n                  this.pickFirstString((event as { message?: unknown }).message) ||\n                  stderrBuffer.slice(-5).join('\\n') ||\n                  'Codex execution error';\n                hasCompleted = true;\n                throw new Error(msg);\n              }\n              case 'turn.completed':\n                emitAssistant(true);\n                resetAssistantBuffers();\n                hasCompleted = true;\n                break;\n              default:\n                // Non-critical events are ignored\n                break;\n            }\n          }\n\n          // Check for timeout after loop exits\n          if (timedOut) {\n            throw new Error('CodexEngine: execution timed out');\n          }\n\n          // Emit final assistant message if not already completed\n          if (!hasCompleted) {\n            emitAssistant(true);\n            resetAssistantBuffers();\n            hasCompleted = true;\n          }\n\n          await finish();\n        } catch (error) {\n          await finish(error);\n        }\n      })();\n    });\n  }\n\n  private resolveRepoPath(projectRoot?: string): string {\n    const base =\n      (projectRoot && projectRoot.trim()) || process.env.MCP_AGENT_PROJECT_ROOT || process.cwd();\n    return path.resolve(base);\n  }\n\n  /**\n   * Append project context (file listing) to the prompt.\n   * Aligned with other/cweb implementation.\n   */\n  private async appendProjectContext(baseInstruction: string, repoPath: string): Promise<string> {\n    try {\n      const fs = await import('node:fs/promises');\n      const entries = await fs.readdir(repoPath, { withFileTypes: true });\n      const visible = entries\n        .filter((entry) => !entry.name.startsWith('.git') && entry.name !== 'AGENTS.md')\n        .map((entry) => entry.name);\n\n      if (visible.length === 0) {\n        return `${baseInstruction}\n\n<current_project_context>\nThis is an empty project directory. Work directly in the current folder without creating extra subdirectories.\n</current_project_context>`;\n      }\n\n      return `${baseInstruction}\n\n<current_project_context>\nCurrent files in project directory: ${visible.sort().join(', ')}\nWork directly in the current directory. Do not create subdirectories unless specifically requested.\n</current_project_context>`;\n    } catch (error) {\n      console.warn('[CodexEngine] Failed to append project context:', error);\n      return baseInstruction;\n    }\n  }\n\n  /**\n   * Build Codex CLI configuration arguments from the resolved config.\n   * Aligned with other/cweb implementation for feature parity.\n   */\n  private buildCodexConfigArgs(config: CodexEngineConfig): string[] {\n    const args: string[] = [];\n\n    const pushConfig = (key: string, value: string | number | boolean): void => {\n      args.push('-c', `${key}=${String(value)}`);\n    };\n\n    pushConfig('include_apply_patch_tool', config.includeApplyPatchTool);\n    pushConfig('include_plan_tool', config.includePlanTool);\n    pushConfig('tools.web_search_request', config.enableWebSearch);\n    pushConfig('use_experimental_streamable_shell_tool', config.useStreamableShell);\n    pushConfig('sandbox_mode', config.sandboxMode);\n    pushConfig('max_turns', config.maxTurns);\n    pushConfig('max_thinking_tokens', config.maxThinkingTokens);\n    pushConfig('reasoning_effort', config.reasoningEffort);\n    args.push('-c', `instructions=${JSON.stringify(config.autoInstructions)}`);\n\n    return args;\n  }\n\n  /**\n   * Write an attachment to a temporary file and return its path.\n   */\n  private async writeAttachmentToTemp(attachment: {\n    type: string;\n    name: string;\n    mimeType: string;\n    dataBase64: string;\n  }): Promise<string> {\n    const os = await import('node:os');\n    const fs = await import('node:fs/promises');\n\n    const tempDir = os.tmpdir();\n    const ext = attachment.mimeType.split('/')[1] || 'bin';\n    const sanitizedName = attachment.name.replace(/[^a-zA-Z0-9.-]/g, '_');\n    const fileName = `mcp-agent-${Date.now()}-${sanitizedName}.${ext}`;\n    const filePath = path.join(tempDir, fileName);\n\n    const buffer = Buffer.from(attachment.dataBase64, 'base64');\n    await fs.writeFile(filePath, buffer);\n\n    return filePath;\n  }\n\n  private buildCodexEnv(): NodeJS.ProcessEnv {\n    const env: NodeJS.ProcessEnv = { ...process.env };\n    const extraPaths: string[] = [];\n    const globalPath = process.env.NPM_GLOBAL_PATH;\n    if (globalPath) {\n      extraPaths.push(globalPath);\n    }\n    // Enhanced Windows PATH handling (aligned with other/cweb)\n    if (process.platform === 'win32') {\n      const appData = process.env.APPDATA;\n      const localApp = process.env.LOCALAPPDATA;\n      if (appData) {\n        extraPaths.push(path.join(appData, 'npm'));\n      }\n      if (localApp) {\n        extraPaths.push(path.join(localApp, 'Programs', 'nodejs'));\n      }\n    }\n    if (extraPaths.length > 0) {\n      const currentPath = env.PATH || env.Path || '';\n      env.PATH = [...extraPaths, currentPath].filter(Boolean).join(path.delimiter);\n    }\n    return env;\n  }\n\n  private pickFirstString(value: unknown): string | undefined {\n    if (typeof value === 'string') {\n      const trimmed = value.trim();\n      return trimmed.length > 0 ? trimmed : undefined;\n    }\n    if (typeof value === 'number' || typeof value === 'boolean') {\n      return String(value);\n    }\n    if (Array.isArray(value)) {\n      for (const entry of value) {\n        const candidate = this.pickFirstString(entry);\n        if (candidate) {\n          return candidate;\n        }\n      }\n      return undefined;\n    }\n    if (value && typeof value === 'object') {\n      const record = value as Record<string, unknown>;\n      for (const key of Object.keys(record)) {\n        const candidate = this.pickFirstString(record[key]);\n        if (candidate) {\n          return candidate;\n        }\n      }\n    }\n    return undefined;\n  }\n\n  private summarizeApplyPatch(payload: {\n    changes?: Record<string, unknown> | Array<Record<string, unknown>>;\n  }): { content: string; metadata: Record<string, unknown> } {\n    const changes = payload?.changes;\n    const files: string[] = [];\n    if (Array.isArray(changes)) {\n      for (const entry of changes) {\n        const file =\n          entry && typeof entry === 'object'\n            ? ((entry as Record<string, unknown>).path as string) ||\n              ((entry as Record<string, unknown>).file as string)\n            : undefined;\n        if (file && typeof file === 'string') {\n          files.push(file);\n        }\n      }\n    } else if (changes && typeof changes === 'object') {\n      for (const key of Object.keys(changes)) {\n        files.push(key);\n      }\n    }\n\n    const unique = Array.from(new Set(files));\n    const summary =\n      unique.length === 0\n        ? 'Applied file changes'\n        : unique.length === 1\n          ? `Updated ${unique[0]}`\n          : `Updated ${unique.length} files (${unique\n              .slice(0, 3)\n              .join(', ')}${unique.length > 3 ? ', ...' : ''})`;\n\n    return {\n      content: summary,\n      metadata: {\n        files: unique,\n      },\n    };\n  }\n\n  private extractTodoListItems(record: Record<string, unknown>): unknown {\n    if (Array.isArray(record.items)) {\n      return record.items;\n    }\n    const nestedItem = record.item;\n    if (\n      nestedItem &&\n      typeof nestedItem === 'object' &&\n      Array.isArray((nestedItem as Record<string, unknown>).items)\n    ) {\n      return (nestedItem as Record<string, unknown>).items;\n    }\n    const delta = record.delta;\n    if (\n      delta &&\n      typeof delta === 'object' &&\n      Array.isArray((delta as Record<string, unknown>).items)\n    ) {\n      return (delta as Record<string, unknown>).items;\n    }\n    return [];\n  }\n\n  private normalizeTodoListItems(input: unknown): TodoListItem[] {\n    if (!Array.isArray(input)) {\n      return [];\n    }\n\n    const result: TodoListItem[] = [];\n\n    input.forEach((entry, index) => {\n      if (!entry || typeof entry !== 'object') {\n        return;\n      }\n      const record = entry as Record<string, unknown>;\n      const text = this.pickFirstString(record.text) ?? `Step ${index + 1}`;\n      const completed = record.completed === true || record.done === true;\n      result.push({\n        text,\n        completed,\n        index,\n      });\n    });\n\n    return result;\n  }\n\n  private buildTodoListContent(items: TodoListItem[], phase: TodoListPhase): string {\n    if (items.length === 0) {\n      switch (phase) {\n        case 'started':\n          return 'Started plan with no explicit steps.';\n        case 'completed':\n          return 'Plan completed.';\n        default:\n          return 'Plan updated.';\n      }\n    }\n\n    const header =\n      phase === 'completed'\n        ? 'Plan completed:'\n        : phase === 'started'\n          ? 'Plan generated:'\n          : 'Plan updated:';\n\n    const stepLines = items.map((item, idx) => {\n      const bullet = item.completed ? '✅' : '⬜️';\n      const label = `Step ${idx + 1}`;\n      return `${bullet} ${label}: ${item.text}`;\n    });\n\n    return [header, ...stepLines].join('\\n');\n  }\n\n  private createTodoListMetadata(\n    items: TodoListItem[],\n    phase: TodoListPhase,\n    extra?: Record<string, unknown>,\n  ): Record<string, unknown> {\n    const totalSteps = items.length;\n    const completedSteps = items.filter((item) => item.completed).length;\n    return {\n      toolName: 'Plan',\n      tool_name: 'Plan',\n      planPhase: phase,\n      planStatus: phase === 'completed' ? 'completed' : 'in_progress',\n      totalSteps,\n      completedSteps,\n      items: items.map(({ text, completed, index }) => ({\n        text,\n        completed,\n        index,\n      })),\n      ...(extra ?? {}),\n    };\n  }\n\n  private encodeHash(value: string): string {\n    return Buffer.from(value, 'utf-8').toString('base64');\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/engines/types.ts",
    "content": "import type { AgentAttachment, RealtimeEvent } from '../types';\nimport type { CodexEngineConfig } from 'chrome-mcp-shared';\n\nexport interface EngineInitOptions {\n  sessionId: string;\n  instruction: string;\n  model?: string;\n  projectRoot?: string;\n  requestId: string;\n  /**\n   * AbortSignal for cancellation support.\n   */\n  signal?: AbortSignal;\n  /**\n   * Optional attachments (images/files) to include with the instruction.\n   * Note: When using persisted attachments, use resolvedImagePaths instead.\n   */\n  attachments?: AgentAttachment[];\n  /**\n   * Resolved absolute paths to persisted image files.\n   * These are used by engines instead of writing temp files from base64.\n   * Set by chat-service after saving attachments to persistent storage.\n   */\n  resolvedImagePaths?: string[];\n  /**\n   * Optional project ID for session persistence.\n   * When provided, engines can use this to save/load session state.\n   */\n  projectId?: string;\n  /**\n   * Optional database session ID (sessions.id) for session-scoped configuration and persistence.\n   */\n  dbSessionId?: string;\n  /**\n   * Optional session-scoped permission mode override (Claude SDK option).\n   */\n  permissionMode?: string;\n  /**\n   * Optional session-scoped permission bypass override (Claude SDK option).\n   */\n  allowDangerouslySkipPermissions?: boolean;\n  /**\n   * Optional session-scoped system prompt configuration.\n   */\n  systemPromptConfig?: unknown;\n  /**\n   * Optional session-scoped engine option overrides.\n   */\n  optionsConfig?: unknown;\n  /**\n   * Optional Claude session ID (UUID) for resuming a previous session.\n   * Only applicable to ClaudeEngine; retrieved from sessions.engineSessionId (preferred)\n   * or project's activeClaudeSessionId (legacy fallback).\n   */\n  resumeClaudeSessionId?: string;\n  /**\n   * Whether to use Claude Code Router (CCR) for this request.\n   * Only applicable to ClaudeEngine; when true, CCR will be auto-detected.\n   */\n  useCcr?: boolean;\n  /**\n   * Optional Codex-specific configuration overrides.\n   * Only applicable to CodexEngine; merged with DEFAULT_CODEX_CONFIG.\n   */\n  codexConfig?: Partial<CodexEngineConfig>;\n}\n\n/**\n * Callback to persist Claude session ID after initialization.\n */\nexport type ClaudeSessionPersistCallback = (sessionId: string) => Promise<void>;\n\n/**\n * Management information extracted from Claude SDK system:init message.\n */\nexport interface ClaudeManagementInfo {\n  tools?: string[];\n  agents?: string[];\n  /** Plugins with name and path (SDK returns { name, path }[]) */\n  plugins?: Array<{ name: string; path?: string }>;\n  skills?: string[];\n  mcpServers?: Array<{ name: string; status: string }>;\n  slashCommands?: string[];\n  model?: string;\n  permissionMode?: string;\n  cwd?: string;\n  outputStyle?: string;\n  betas?: string[];\n  claudeCodeVersion?: string;\n  apiKeySource?: string;\n}\n\n/**\n * Callback to persist management information after SDK initialization.\n */\nexport type ManagementInfoPersistCallback = (info: ClaudeManagementInfo) => Promise<void>;\n\nexport type EngineName = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';\n\nexport interface EngineExecutionContext {\n  /**\n   * Emit a realtime event to all connected clients for the current session.\n   */\n  emit(event: RealtimeEvent): void;\n  /**\n   * Optional callback to persist Claude session ID after SDK initialization.\n   * Only called by ClaudeEngine when projectId is provided.\n   */\n  persistClaudeSessionId?: ClaudeSessionPersistCallback;\n  /**\n   * Optional callback to persist management information after SDK initialization.\n   * Only called by ClaudeEngine when dbSessionId is provided.\n   */\n  persistManagementInfo?: ManagementInfoPersistCallback;\n}\n\nexport interface AgentEngine {\n  name: EngineName;\n  /**\n   * Whether this engine can act as an MCP client natively.\n   */\n  supportsMcp?: boolean;\n  initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise<void>;\n}\n\n/**\n * Represents a running engine execution that can be cancelled.\n */\nexport interface RunningExecution {\n  requestId: string;\n  sessionId: string;\n  engineName: EngineName;\n  abortController: AbortController;\n  startedAt: Date;\n}\n"
  },
  {
    "path": "app/native-server/src/agent/message-service.ts",
    "content": "/**\n * Message Service - Database-backed implementation using Drizzle ORM.\n *\n * Provides CRUD operations for agent chat messages with:\n * - Type-safe database queries\n * - Efficient indexed queries\n * - Consistent with AgentStoredMessage interface from shared types\n */\nimport { randomUUID } from 'node:crypto';\nimport { eq, asc, and, count } from 'drizzle-orm';\nimport type { AgentRole, AgentStoredMessage } from 'chrome-mcp-shared';\nimport { getDb, messages, type MessageRow } from './db';\n\n// ============================================================\n// Types\n// ============================================================\n\nexport type { AgentStoredMessage };\n\nexport interface CreateAgentStoredMessageInput {\n  projectId: string;\n  role: AgentRole;\n  messageType: AgentStoredMessage['messageType'];\n  content: string;\n  metadata?: Record<string, unknown>;\n  sessionId?: string;\n  conversationId?: string | null;\n  cliSource?: string;\n  requestId?: string;\n  id?: string;\n  createdAt?: string;\n}\n\n// ============================================================\n// Type Conversion\n// ============================================================\n\n/**\n * Convert database row to AgentStoredMessage interface.\n */\nfunction rowToMessage(row: MessageRow): AgentStoredMessage {\n  return {\n    id: row.id,\n    projectId: row.projectId,\n    sessionId: row.sessionId,\n    conversationId: row.conversationId,\n    role: row.role as AgentRole,\n    content: row.content,\n    messageType: row.messageType as AgentStoredMessage['messageType'],\n    metadata: row.metadata ? JSON.parse(row.metadata) : undefined,\n    cliSource: row.cliSource,\n    requestId: row.requestId ?? undefined,\n    createdAt: row.createdAt,\n  };\n}\n\n// ============================================================\n// Public API\n// ============================================================\n\n/**\n * Get messages by project ID with pagination.\n * Returns messages sorted by creation time (oldest first).\n */\nexport async function getMessagesByProjectId(\n  projectId: string,\n  limit = 50,\n  offset = 0,\n): Promise<AgentStoredMessage[]> {\n  const db = getDb();\n\n  const query = db\n    .select()\n    .from(messages)\n    .where(eq(messages.projectId, projectId))\n    .orderBy(asc(messages.createdAt));\n\n  // Apply pagination if specified\n  if (limit > 0) {\n    query.limit(limit);\n  }\n  if (offset > 0) {\n    query.offset(offset);\n  }\n\n  const rows = await query;\n  return rows.map(rowToMessage);\n}\n\n/**\n * Get the total count of messages for a project.\n */\nexport async function getMessagesCountByProjectId(projectId: string): Promise<number> {\n  const db = getDb();\n  const result = await db\n    .select({ count: count() })\n    .from(messages)\n    .where(eq(messages.projectId, projectId));\n  return result[0]?.count ?? 0;\n}\n\n/**\n * Create a new message.\n */\nexport async function createMessage(\n  input: CreateAgentStoredMessageInput,\n): Promise<AgentStoredMessage> {\n  const db = getDb();\n  const now = new Date().toISOString();\n\n  const messageData: MessageRow = {\n    id: input.id?.trim() || randomUUID(),\n    projectId: input.projectId,\n    sessionId: input.sessionId || '',\n    conversationId: input.conversationId ?? null,\n    role: input.role,\n    content: input.content,\n    messageType: input.messageType,\n    metadata: input.metadata ? JSON.stringify(input.metadata) : null,\n    cliSource: input.cliSource ?? null,\n    requestId: input.requestId ?? null,\n    createdAt: input.createdAt || now,\n  };\n\n  await db\n    .insert(messages)\n    .values(messageData)\n    .onConflictDoUpdate({\n      target: messages.id,\n      set: {\n        role: messageData.role,\n        messageType: messageData.messageType,\n        content: messageData.content,\n        metadata: messageData.metadata,\n        sessionId: messageData.sessionId,\n        conversationId: messageData.conversationId,\n        cliSource: messageData.cliSource,\n        requestId: messageData.requestId,\n      },\n    });\n\n  return rowToMessage(messageData);\n}\n\n/**\n * Delete messages by project ID.\n * Optionally filter by conversation ID.\n * Returns the number of deleted messages.\n */\nexport async function deleteMessagesByProjectId(\n  projectId: string,\n  conversationId?: string,\n): Promise<number> {\n  const db = getDb();\n\n  // Get count before deletion\n  const beforeCount = await getMessagesCountByProjectId(projectId);\n\n  if (conversationId) {\n    await db\n      .delete(messages)\n      .where(and(eq(messages.projectId, projectId), eq(messages.conversationId, conversationId)));\n  } else {\n    await db.delete(messages).where(eq(messages.projectId, projectId));\n  }\n\n  // Get count after deletion to calculate deleted count\n  const afterCount = await getMessagesCountByProjectId(projectId);\n  return beforeCount - afterCount;\n}\n\n/**\n * Get messages by session ID with optional pagination.\n * Returns messages sorted by creation time (oldest first).\n *\n * @param sessionId - The session ID to filter by\n * @param limit - Maximum number of messages to return (0 = no limit)\n * @param offset - Number of messages to skip\n */\nexport async function getMessagesBySessionId(\n  sessionId: string,\n  limit = 0,\n  offset = 0,\n): Promise<AgentStoredMessage[]> {\n  const db = getDb();\n\n  const query = db\n    .select()\n    .from(messages)\n    .where(eq(messages.sessionId, sessionId))\n    .orderBy(asc(messages.createdAt));\n\n  if (limit > 0) {\n    query.limit(limit);\n  }\n  if (offset > 0) {\n    query.offset(offset);\n  }\n\n  const rows = await query;\n  return rows.map(rowToMessage);\n}\n\n/**\n * Get count of messages by session ID.\n */\nexport async function getMessagesCountBySessionId(sessionId: string): Promise<number> {\n  const db = getDb();\n  const result = await db\n    .select({ count: count() })\n    .from(messages)\n    .where(eq(messages.sessionId, sessionId));\n  return result[0]?.count ?? 0;\n}\n\n/**\n * Delete all messages for a session.\n * Returns the number of deleted messages.\n */\nexport async function deleteMessagesBySessionId(sessionId: string): Promise<number> {\n  const db = getDb();\n\n  const beforeCount = await getMessagesCountBySessionId(sessionId);\n  await db.delete(messages).where(eq(messages.sessionId, sessionId));\n  const afterCount = await getMessagesCountBySessionId(sessionId);\n\n  return beforeCount - afterCount;\n}\n\n/**\n * Get messages by request ID.\n */\nexport async function getMessagesByRequestId(requestId: string): Promise<AgentStoredMessage[]> {\n  const db = getDb();\n  const rows = await db\n    .select()\n    .from(messages)\n    .where(eq(messages.requestId, requestId))\n    .orderBy(asc(messages.createdAt));\n  return rows.map(rowToMessage);\n}\n"
  },
  {
    "path": "app/native-server/src/agent/open-project.ts",
    "content": "/**\n * Open Project Service.\n *\n * Provides cross-platform functionality to open a project directory in:\n * - VS Code (or compatible editors)\n * - System terminal\n *\n * Security:\n * - Uses validateRootPath() for path validation (allowed directories check)\n * - Uses spawn() with args array (shell: false) to prevent command injection\n *\n * Platform Support:\n * - macOS: Terminal.app, VS Code via 'code' or 'open -b'\n * - Windows: Windows Terminal, PowerShell, VS Code\n * - Linux: gnome-terminal, konsole, xfce4-terminal, xterm\n */\nimport os from 'node:os';\nimport path from 'node:path';\nimport { stat } from 'node:fs/promises';\nimport { spawn } from 'node:child_process';\nimport type { OpenProjectResponse, OpenProjectTarget } from 'chrome-mcp-shared';\nimport { validateRootPath } from './project-service';\n\n// ============================================================\n// Types\n// ============================================================\n\ntype LaunchResult = { success: true } | { success: false; error: string };\n\ninterface LaunchAttempt {\n  /** Human-readable label for error messages */\n  label: string;\n  /** Command to execute */\n  cmd: string;\n  /** Arguments array (no shell interpolation) */\n  args: string[];\n  /**\n   * Time to wait before considering launch successful.\n   * Terminal processes are long-lived, so we don't wait for exit.\n   */\n  successAfterMs?: number;\n  /** Whether to detach the process (default: true) */\n  detached?: boolean;\n}\n\n// ============================================================\n// Utility Functions\n// ============================================================\n\n/**\n * Convert spawn error to human-readable string.\n */\nfunction formatSpawnError(err: unknown): string {\n  if (err instanceof Error) {\n    const errnoErr = err as NodeJS.ErrnoException;\n    if (errnoErr.code) {\n      return `${errnoErr.code}: ${err.message}`;\n    }\n    return err.message;\n  }\n  return String(err);\n}\n\n/**\n * Format process exit information.\n */\nfunction formatExitFailure(code: number | null, signal: NodeJS.Signals | null): string {\n  if (typeof code === 'number') {\n    return `Exit code ${code}`;\n  }\n  if (signal) {\n    return `Terminated by signal ${signal}`;\n  }\n  return 'Exited with unknown status';\n}\n\n// ============================================================\n// Launch Logic\n// ============================================================\n\n/**\n * Attempt to launch a process.\n *\n * Strategy:\n * - If spawn fails immediately (e.g., ENOENT): return failure\n * - If process exits quickly with code 0: return success\n * - If process exits quickly with non-zero: return failure\n * - If process is still running after successAfterMs: return success\n *   (for long-lived terminal processes)\n */\nasync function tryLaunch(attempt: LaunchAttempt): Promise<LaunchResult> {\n  const successAfterMs = attempt.successAfterMs ?? 1500;\n  const detached = attempt.detached !== false;\n\n  return new Promise<LaunchResult>((resolve) => {\n    let settled = false;\n    let timer: NodeJS.Timeout | null = null;\n\n    const cleanup = () => {\n      if (timer) {\n        clearTimeout(timer);\n        timer = null;\n      }\n      child.removeAllListeners('error');\n      child.removeAllListeners('exit');\n    };\n\n    const child = spawn(attempt.cmd, attempt.args, {\n      shell: false,\n      stdio: 'ignore',\n      detached,\n    });\n\n    if (detached) {\n      // Let the child process continue independently\n      child.unref();\n    }\n\n    child.once('error', (err) => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n      resolve({ success: false, error: formatSpawnError(err) });\n    });\n\n    child.once('exit', (code, signal) => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n\n      if (code === 0) {\n        resolve({ success: true });\n      } else {\n        resolve({ success: false, error: formatExitFailure(code, signal) });\n      }\n    });\n\n    // If process is still running after timeout, consider it successful\n    timer = setTimeout(() => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n      resolve({ success: true });\n    }, successAfterMs);\n  });\n}\n\n/**\n * Try multiple launch attempts in sequence until one succeeds.\n */\nasync function runFallbackSequence(errorTitle: string, attempts: LaunchAttempt[]): Promise<void> {\n  const errors: string[] = [];\n\n  for (const attempt of attempts) {\n    const result = await tryLaunch(attempt);\n    if (result.success) {\n      return;\n    }\n    errors.push(`${attempt.label}: ${result.error}`);\n  }\n\n  throw new Error(`${errorTitle}\\n${errors.map((e) => `  - ${e}`).join('\\n')}`);\n}\n\n// ============================================================\n// VS Code\n// ============================================================\n\n/**\n * Open directory in VS Code.\n *\n * Strategy:\n * - All platforms: try 'code' command first\n * - Windows: also try 'code.cmd'\n * - macOS: fallback to 'open -b com.microsoft.VSCode'\n */\nasync function openInVSCode(absolutePath: string): Promise<void> {\n  const platform = os.platform();\n\n  const attempts: LaunchAttempt[] = [\n    {\n      label: 'code',\n      cmd: 'code',\n      args: [absolutePath],\n      successAfterMs: 8000, // VS Code takes time to start\n    },\n  ];\n\n  // Windows: code.cmd is the batch wrapper\n  if (platform === 'win32') {\n    attempts.push({\n      label: 'code.cmd',\n      cmd: 'code.cmd',\n      args: [absolutePath],\n      successAfterMs: 8000,\n    });\n  }\n\n  // macOS: fallback to bundle identifier\n  if (platform === 'darwin') {\n    attempts.push({\n      label: 'open -b com.microsoft.VSCode',\n      cmd: 'open',\n      args: ['-b', 'com.microsoft.VSCode', absolutePath],\n      successAfterMs: 3000,\n    });\n  }\n\n  await runFallbackSequence(`Failed to open VS Code for: ${absolutePath}`, attempts);\n}\n\n/**\n * Open a file in VS Code at a specific line/column.\n *\n * Uses 'code -g file:line:col' syntax for goto functionality.\n * Also opens the project root with -r to reuse existing window.\n *\n * Security:\n * - Validates that file path stays within project root\n * - Uses spawn with args array (no shell interpolation)\n *\n * @param projectRoot - Project root directory (for security validation and -r flag)\n * @param filePath - File path (relative or absolute)\n * @param line - Optional line number (1-based)\n * @param column - Optional column number (1-based)\n */\nexport async function openFileInVSCode(\n  projectRoot: string,\n  filePath: string,\n  line?: number,\n  column?: number,\n): Promise<OpenProjectResponse> {\n  try {\n    // Validate project root\n    const projectValidation = await validateRootPath(projectRoot);\n    if (!projectValidation.valid) {\n      return {\n        success: false,\n        error: projectValidation.error ?? 'Invalid project rootPath',\n      };\n    }\n    if (!projectValidation.exists) {\n      return {\n        success: false,\n        error: `Project directory does not exist: ${projectValidation.absolute}`,\n      };\n    }\n\n    const rootAbs = projectValidation.absolute;\n\n    // Validate file path\n    const trimmedFile = String(filePath ?? '').trim();\n    if (!trimmedFile) {\n      return { success: false, error: 'filePath is required' };\n    }\n\n    // Resolve file path with smart fallback\n    // Some frameworks (Vue/Vite) return paths like \"/src/components/Foo.vue\" which\n    // look absolute but are actually relative to project root. We try multiple strategies:\n    // 1. If path looks absolute and exists as-is, use it\n    // 2. Otherwise, strip leading slash and try as relative path\n    // 3. Finally, try as relative path directly\n    let absoluteFile: string = '';\n    let fileExists = false;\n\n    if (path.isAbsolute(trimmedFile)) {\n      // Try as true absolute path first\n      const asAbsolute = path.resolve(trimmedFile);\n      try {\n        const fileStat = await stat(asAbsolute);\n        if (fileStat.isFile()) {\n          absoluteFile = asAbsolute;\n          fileExists = true;\n        }\n      } catch {\n        // Not found as absolute path\n      }\n\n      // If not found and path starts with /, try stripping it and treating as relative\n      if (!fileExists && trimmedFile.startsWith('/')) {\n        const strippedPath = trimmedFile.slice(1);\n        const asRelative = path.resolve(rootAbs, strippedPath);\n        try {\n          const fileStat = await stat(asRelative);\n          if (fileStat.isFile()) {\n            absoluteFile = asRelative;\n            fileExists = true;\n          }\n        } catch {\n          // Not found as relative path either\n        }\n      }\n\n      // Default to absolute interpretation if nothing found\n      if (!absoluteFile) {\n        absoluteFile = path.resolve(trimmedFile);\n      }\n    } else {\n      // Relative path - resolve against project root\n      absoluteFile = path.resolve(rootAbs, trimmedFile);\n    }\n\n    // Security: ensure file stays within project root\n    const relativeToRoot = path.relative(rootAbs, absoluteFile);\n    if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {\n      return { success: false, error: 'File path must be within project directory' };\n    }\n\n    // Check file exists\n    if (!fileExists) {\n      try {\n        const fileStat = await stat(absoluteFile);\n        if (!fileStat.isFile()) {\n          return { success: false, error: `Not a file: ${absoluteFile}` };\n        }\n      } catch {\n        return { success: false, error: `File does not exist: ${absoluteFile}` };\n      }\n    }\n\n    // Validate and sanitize line/column\n    const safeLine =\n      typeof line === 'number' && Number.isFinite(line) && line > 0 ? Math.floor(line) : undefined;\n    const safeColumn =\n      typeof column === 'number' && Number.isFinite(column) && column > 0\n        ? Math.floor(column)\n        : undefined;\n\n    // Build goto argument: file:line:col\n    let gotoArg = absoluteFile;\n    if (safeLine) {\n      gotoArg += `:${safeLine}`;\n      if (safeColumn) {\n        gotoArg += `:${safeColumn}`;\n      }\n    }\n\n    const platform = os.platform();\n\n    // Build launch attempts\n    // Use -r to reuse existing window, -g for goto\n    const attempts: LaunchAttempt[] = [\n      {\n        label: 'code -r -g',\n        cmd: 'code',\n        args: ['-r', rootAbs, '-g', gotoArg],\n        successAfterMs: 8000,\n      },\n    ];\n\n    if (platform === 'win32') {\n      attempts.push({\n        label: 'code.cmd -r -g',\n        cmd: 'code.cmd',\n        args: ['-r', rootAbs, '-g', gotoArg],\n        successAfterMs: 8000,\n      });\n    }\n\n    if (platform === 'darwin') {\n      // macOS: use --args to pass flags to VS Code\n      attempts.push({\n        label: 'open -b com.microsoft.VSCode --args',\n        cmd: 'open',\n        args: ['-b', 'com.microsoft.VSCode', '--args', '-r', rootAbs, '-g', gotoArg],\n        successAfterMs: 3000,\n      });\n    }\n\n    await runFallbackSequence(`Failed to open VS Code for: ${gotoArg}`, attempts);\n    return { success: true };\n  } catch (error) {\n    return { success: false, error: formatSpawnError(error) };\n  }\n}\n\n// ============================================================\n// Terminal\n// ============================================================\n\n/**\n * Open directory in system terminal.\n */\nasync function openInTerminal(absolutePath: string): Promise<void> {\n  const platform = os.platform();\n\n  switch (platform) {\n    case 'darwin':\n      return openTerminalDarwin(absolutePath);\n    case 'win32':\n      return openTerminalWindows(absolutePath);\n    case 'linux':\n      return openTerminalLinux(absolutePath);\n    default:\n      throw new Error(`Unsupported platform: ${platform}`);\n  }\n}\n\n/**\n * macOS: Open Terminal.app with directory.\n */\nasync function openTerminalDarwin(absolutePath: string): Promise<void> {\n  await runFallbackSequence(`Failed to open Terminal for: ${absolutePath}`, [\n    {\n      label: 'open -a Terminal',\n      cmd: 'open',\n      args: ['-a', 'Terminal', absolutePath],\n      successAfterMs: 3000,\n    },\n  ]);\n}\n\n/**\n * Windows: Open Windows Terminal or PowerShell.\n */\nasync function openTerminalWindows(absolutePath: string): Promise<void> {\n  await runFallbackSequence(`Failed to open terminal for: ${absolutePath}`, [\n    // Windows Terminal (wt)\n    {\n      label: 'wt -d',\n      cmd: 'wt',\n      args: ['-d', absolutePath],\n      successAfterMs: 3000,\n    },\n    // PowerShell fallback - using -LiteralPath to handle special characters\n    // Use powershell.exe for better PATH compatibility\n    {\n      label: 'powershell.exe Set-Location',\n      cmd: 'powershell.exe',\n      args: ['-NoExit', '-Command', 'Set-Location -LiteralPath $args[0]', absolutePath],\n      successAfterMs: 1500,\n    },\n  ]);\n}\n\n/**\n * Linux: Try common terminal emulators in sequence.\n */\nasync function openTerminalLinux(absolutePath: string): Promise<void> {\n  await runFallbackSequence(\n    `Failed to open terminal for: ${absolutePath}. Please install gnome-terminal, konsole, xfce4-terminal, or xterm.`,\n    [\n      // GNOME Terminal\n      {\n        label: 'gnome-terminal',\n        cmd: 'gnome-terminal',\n        args: ['--working-directory', absolutePath],\n        successAfterMs: 3000,\n      },\n      // KDE Konsole\n      {\n        label: 'konsole',\n        cmd: 'konsole',\n        args: ['--workdir', absolutePath],\n        successAfterMs: 3000,\n      },\n      // XFCE Terminal\n      {\n        label: 'xfce4-terminal',\n        cmd: 'xfce4-terminal',\n        args: ['--working-directory', absolutePath],\n        successAfterMs: 3000,\n      },\n      // xterm (last resort)\n      {\n        label: 'xterm',\n        cmd: 'xterm',\n        // Use bash with positional parameter to safely pass the path\n        args: ['-e', 'bash', '-lc', 'cd -- \"$1\" && exec \"${SHELL:-bash}\"', '_', absolutePath],\n        successAfterMs: 3000,\n      },\n    ],\n  );\n}\n\n// ============================================================\n// Public API\n// ============================================================\n\n/**\n * Open a project directory in the specified target application.\n *\n * @param rootPath - The project directory path\n * @param target - 'vscode' or 'terminal'\n * @returns Response indicating success or failure with error message\n */\nexport async function openProjectDirectory(\n  rootPath: string,\n  target: OpenProjectTarget,\n): Promise<OpenProjectResponse> {\n  try {\n    // Validate path security and existence\n    const validation = await validateRootPath(rootPath);\n\n    if (!validation.valid) {\n      return {\n        success: false,\n        error: validation.error ?? 'Invalid project rootPath',\n      };\n    }\n\n    if (!validation.exists) {\n      return {\n        success: false,\n        error: `Directory does not exist: ${validation.absolute}`,\n      };\n    }\n\n    const absolutePath = validation.absolute;\n\n    // Open in target application\n    switch (target) {\n      case 'vscode':\n        await openInVSCode(absolutePath);\n        return { success: true };\n\n      case 'terminal':\n        await openInTerminal(absolutePath);\n        return { success: true };\n\n      default: {\n        // Type guard for exhaustive check\n        const _exhaustive: never = target;\n        return {\n          success: false,\n          error: `Unsupported target: ${String(_exhaustive)}`,\n        };\n      }\n    }\n  } catch (error) {\n    return {\n      success: false,\n      error: formatSpawnError(error),\n    };\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/project-service.ts",
    "content": "/**\n * Project Service - Database-backed implementation using Drizzle ORM.\n *\n * Provides CRUD operations for agent projects with:\n * - Type-safe database queries\n * - Path validation with security checks\n * - Consistent with AgentProject interface from shared types\n */\nimport { randomUUID } from 'node:crypto';\nimport { mkdir, stat } from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { eq, desc } from 'drizzle-orm';\nimport type { AgentProject } from 'chrome-mcp-shared';\nimport type { CreateOrUpdateProjectInput } from './project-types';\nimport { getDb, projects, type ProjectRow } from './db';\n\n// ============================================================\n// Security Configuration\n// ============================================================\n\n/**\n * Allowed base directories for project roots.\n * Only paths under these directories are considered safe.\n */\nconst ALLOWED_BASE_DIRS: string[] = [\n  os.homedir(),\n  process.env.USERPROFILE,\n  process.env.MCP_ALLOWED_WORKSPACE_BASE,\n].filter((dir): dir is string => typeof dir === 'string' && dir.length > 0);\n\n// ============================================================\n// Path Validation\n// ============================================================\n\n/**\n * Result of path validation.\n */\nexport interface PathValidationResult {\n  valid: boolean;\n  absolute: string;\n  exists: boolean;\n  needsCreation: boolean;\n  error?: string;\n}\n\n/**\n * Validate a root path without creating it.\n * Returns validation result including whether directory needs creation.\n */\nexport async function validateRootPath(rootPath: string): Promise<PathValidationResult> {\n  const trimmed = rootPath.trim();\n  if (!trimmed) {\n    return {\n      valid: false,\n      absolute: '',\n      exists: false,\n      needsCreation: false,\n      error: 'Project rootPath must not be empty',\n    };\n  }\n\n  const absolute = path.isAbsolute(trimmed)\n    ? path.resolve(trimmed)\n    : path.resolve(process.cwd(), trimmed);\n\n  // Security check: ensure path is under allowed base directories\n  const isAllowed = ALLOWED_BASE_DIRS.some((base) => absolute.startsWith(path.resolve(base)));\n\n  if (!isAllowed) {\n    return {\n      valid: false,\n      absolute,\n      exists: false,\n      needsCreation: false,\n      error: `Project rootPath must be under allowed directories: ${ALLOWED_BASE_DIRS.join(', ')}`,\n    };\n  }\n\n  // Check if path exists\n  try {\n    const s = await stat(absolute);\n    if (!s.isDirectory()) {\n      return {\n        valid: false,\n        absolute,\n        exists: true,\n        needsCreation: false,\n        error: `Path exists but is not a directory: ${absolute}`,\n      };\n    }\n    return { valid: true, absolute, exists: true, needsCreation: false };\n  } catch (err: unknown) {\n    const error = err as NodeJS.ErrnoException;\n    if (error.code === 'ENOENT') {\n      // Path doesn't exist but is valid - can be created\n      return { valid: true, absolute, exists: false, needsCreation: true };\n    }\n    return {\n      valid: false,\n      absolute,\n      exists: false,\n      needsCreation: false,\n      error: error.message || 'Unknown error validating path',\n    };\n  }\n}\n\n/**\n * Create a project directory after user confirmation.\n * This should only be called after validateRootPath returns needsCreation: true.\n */\nexport async function createProjectDirectory(absolutePath: string): Promise<void> {\n  // Re-validate for safety\n  const validation = await validateRootPath(absolutePath);\n  if (!validation.valid) {\n    throw new Error(validation.error || 'Invalid path');\n  }\n  if (validation.exists) {\n    throw new Error('Directory already exists');\n  }\n  await mkdir(absolutePath, { recursive: true });\n}\n\n/**\n * Normalize and validate root path.\n * @param rootPath - The path to normalize\n * @param allowCreate - If true, create directory if it doesn't exist\n */\nasync function normalizeRootPath(rootPath: string, allowCreate = false): Promise<string> {\n  const result = await validateRootPath(rootPath);\n\n  if (!result.valid) {\n    throw new Error(result.error || 'Invalid path');\n  }\n\n  if (result.needsCreation) {\n    if (allowCreate) {\n      await mkdir(result.absolute, { recursive: true });\n    } else {\n      throw new Error(\n        `Directory does not exist: ${result.absolute}. Use the validate-path API first and confirm creation with the user.`,\n      );\n    }\n  }\n\n  return result.absolute;\n}\n\n// ============================================================\n// Type Conversion\n// ============================================================\n\n/**\n * Convert database row to AgentProject interface.\n */\nfunction rowToProject(row: ProjectRow): AgentProject {\n  return {\n    id: row.id,\n    name: row.name,\n    description: row.description ?? undefined,\n    rootPath: row.rootPath,\n    preferredCli: row.preferredCli as AgentProject['preferredCli'],\n    selectedModel: row.selectedModel ?? undefined,\n    activeClaudeSessionId: row.activeClaudeSessionId ?? undefined,\n    useCcr: row.useCcr === '1',\n    enableChromeMcp: row.enableChromeMcp !== '0',\n    createdAt: row.createdAt,\n    updatedAt: row.updatedAt,\n    lastActiveAt: row.lastActiveAt ?? undefined,\n  };\n}\n\n// ============================================================\n// Public API\n// ============================================================\n\n/**\n * List all projects, sorted by last activity (most recent first).\n */\nexport async function listProjects(): Promise<AgentProject[]> {\n  const db = getDb();\n  const rows = await db.select().from(projects).orderBy(desc(projects.lastActiveAt));\n  return rows.map(rowToProject);\n}\n\n/**\n * Get a single project by ID.\n */\nexport async function getProject(id: string): Promise<AgentProject | undefined> {\n  const db = getDb();\n  const rows = await db.select().from(projects).where(eq(projects.id, id)).limit(1);\n  return rows.length > 0 ? rowToProject(rows[0]) : undefined;\n}\n\n/**\n * Create or update a project.\n */\nexport async function upsertProject(input: CreateOrUpdateProjectInput): Promise<AgentProject> {\n  const db = getDb();\n  const now = new Date().toISOString();\n  const rootPath = await normalizeRootPath(input.rootPath, input.allowCreate ?? false);\n\n  const id = input.id?.trim() || randomUUID();\n  const existing = await getProject(id);\n\n  // Convert booleans to strings for SQLite storage:\n  // - useCcr: '1' or null (legacy)\n  // - enableChromeMcp: '1' or '0' (non-null; defaults to enabled)\n  const useCcrValue =\n    input.useCcr !== undefined ? (input.useCcr ? '1' : null) : existing?.useCcr ? '1' : null;\n\n  let enableChromeMcpValue: '1' | '0';\n  if (typeof input.enableChromeMcp === 'boolean') {\n    enableChromeMcpValue = input.enableChromeMcp ? '1' : '0';\n  } else {\n    enableChromeMcpValue = existing?.enableChromeMcp === false ? '0' : '1';\n  }\n\n  const projectData = {\n    id,\n    name: input.name.trim(),\n    description: input.description?.trim() || existing?.description || null,\n    rootPath,\n    preferredCli: input.preferredCli ?? existing?.preferredCli ?? null,\n    selectedModel: input.selectedModel ?? existing?.selectedModel ?? null,\n    // Preserve activeClaudeSessionId from existing project (not settable via upsert)\n    activeClaudeSessionId: existing?.activeClaudeSessionId ?? null,\n    useCcr: useCcrValue,\n    enableChromeMcp: enableChromeMcpValue,\n    createdAt: existing?.createdAt || now,\n    updatedAt: now,\n    lastActiveAt: now,\n  };\n\n  if (existing) {\n    // Update existing project\n    await db.update(projects).set(projectData).where(eq(projects.id, id));\n  } else {\n    // Insert new project\n    await db.insert(projects).values(projectData);\n  }\n\n  return rowToProject(projectData as ProjectRow);\n}\n\n/**\n * Delete a project by ID.\n * Messages are automatically deleted via cascade.\n */\nexport async function deleteProject(id: string): Promise<void> {\n  const db = getDb();\n  await db.delete(projects).where(eq(projects.id, id));\n}\n\n/**\n * Update the last activity timestamp for a project.\n */\nexport async function touchProjectActivity(id: string): Promise<void> {\n  const db = getDb();\n  const now = new Date().toISOString();\n  await db.update(projects).set({ lastActiveAt: now, updatedAt: now }).where(eq(projects.id, id));\n}\n\n/**\n * Update the active Claude session ID for a project.\n * This is called when the SDK returns a system/init message with a new session_id.\n * Pass empty string or null to clear the session ID.\n */\nexport async function updateProjectClaudeSessionId(\n  id: string,\n  claudeSessionId: string | null,\n): Promise<void> {\n  const db = getDb();\n  const now = new Date().toISOString();\n  await db\n    .update(projects)\n    .set({\n      // Store null if empty string is passed (to clear the session)\n      activeClaudeSessionId: claudeSessionId?.trim() || null,\n      updatedAt: now,\n    })\n    .where(eq(projects.id, id));\n}\n"
  },
  {
    "path": "app/native-server/src/agent/project-types.ts",
    "content": "/**\n * Re-export AgentProject from shared package and define local input types.\n */\nimport type { AgentCliPreference, AgentProject } from 'chrome-mcp-shared';\n\n// Re-export for backward compatibility\nexport type { AgentProject };\n\nexport interface CreateOrUpdateProjectInput {\n  id?: string;\n  name: string;\n  description?: string;\n  rootPath: string;\n  preferredCli?: AgentCliPreference;\n  selectedModel?: string;\n  /**\n   * Whether to use Claude Code Router (CCR) for this project.\n   */\n  useCcr?: boolean;\n  /**\n   * Whether to enable the local Chrome MCP server integration for this project.\n   * Defaults to true when omitted.\n   */\n  enableChromeMcp?: boolean;\n  /**\n   * If true, create the directory if it doesn't exist.\n   * Should only be set after user confirmation.\n   */\n  allowCreate?: boolean;\n}\n"
  },
  {
    "path": "app/native-server/src/agent/session-service.ts",
    "content": "/**\n * Session Service - Database-backed implementation using Drizzle ORM.\n *\n * Provides CRUD operations for agent sessions with:\n * - Type-safe database queries\n * - Engine-agnostic session configuration storage\n * - JSON config and management info caching\n */\nimport { randomUUID } from 'node:crypto';\nimport { eq, desc, and, asc } from 'drizzle-orm';\nimport { getDb, sessions, messages, type SessionRow } from './db';\nimport type { EngineName } from './engines/types';\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * System prompt configuration options.\n */\nexport type SystemPromptConfig =\n  | { type: 'custom'; text: string }\n  | { type: 'preset'; preset: 'claude_code'; append?: string };\n\n/**\n * Tools configuration - can be a list of tool names or a preset.\n */\nexport type ToolsConfig = string[] | { type: 'preset'; preset: 'claude_code' };\n\n/**\n * Session options configuration (stored as JSON).\n */\nexport interface SessionOptionsConfig {\n  settingSources?: string[];\n  allowedTools?: string[];\n  disallowedTools?: string[];\n  tools?: ToolsConfig;\n  betas?: string[];\n  maxThinkingTokens?: number;\n  maxTurns?: number;\n  maxBudgetUsd?: number;\n  mcpServers?: Record<string, unknown>;\n  outputFormat?: Record<string, unknown>;\n  enableFileCheckpointing?: boolean;\n  sandbox?: Record<string, unknown>;\n  env?: Record<string, string>;\n  /**\n   * Optional Codex-specific configuration overrides.\n   * Only applicable when using CodexEngine.\n   */\n  codexConfig?: Partial<import('chrome-mcp-shared').CodexEngineConfig>;\n}\n\n/**\n * Cached management information from Claude SDK.\n */\nexport interface ManagementInfo {\n  models?: Array<{ value: string; displayName: string; description: string }>;\n  commands?: Array<{ name: string; description: string; argumentHint: string }>;\n  account?: { email?: string; organization?: string; subscriptionType?: string };\n  mcpServers?: Array<{ name: string; status: string }>;\n  tools?: string[];\n  agents?: string[];\n  /** Plugins with name and path (SDK returns { name, path }[]) */\n  plugins?: Array<{ name: string; path?: string }>;\n  skills?: string[];\n  slashCommands?: string[];\n  model?: string;\n  permissionMode?: string;\n  cwd?: string;\n  outputStyle?: string;\n  betas?: string[];\n  claudeCodeVersion?: string;\n  apiKeySource?: string;\n  lastUpdated?: string;\n}\n\n/**\n * Structured preview metadata for session list display.\n * When present, allows rendering special styles (e.g., chip for web editor apply).\n */\nexport interface AgentSessionPreviewMeta {\n  /** Compact display text (e.g., user's message or \"Apply changes\") */\n  displayText?: string;\n  /** Client metadata for special rendering */\n  clientMeta?: {\n    kind?: 'web_editor_apply_batch' | 'web_editor_apply_single';\n    pageUrl?: string;\n    elementCount?: number;\n    elementLabels?: string[];\n  };\n  /** Full content for tooltip preview (truncated to avoid payload bloat) */\n  fullContent?: string;\n}\n\n/**\n * Agent session representation.\n */\nexport interface AgentSession {\n  id: string;\n  projectId: string;\n  engineName: string;\n  engineSessionId?: string;\n  name?: string;\n  /** Preview text from first user message, for display in session list */\n  preview?: string;\n  /** Structured preview metadata for special rendering (e.g., web editor apply chip) */\n  previewMeta?: AgentSessionPreviewMeta;\n  model?: string;\n  permissionMode: string;\n  allowDangerouslySkipPermissions: boolean;\n  systemPromptConfig?: SystemPromptConfig;\n  optionsConfig?: SessionOptionsConfig;\n  managementInfo?: ManagementInfo;\n  createdAt: string;\n  updatedAt: string;\n}\n\n/**\n * Options for creating a new session.\n */\nexport interface CreateSessionOptions {\n  id?: string;\n  engineSessionId?: string;\n  name?: string;\n  model?: string;\n  permissionMode?: string;\n  allowDangerouslySkipPermissions?: boolean;\n  systemPromptConfig?: SystemPromptConfig;\n  optionsConfig?: SessionOptionsConfig;\n}\n\n/**\n * Options for updating an existing session.\n */\nexport interface UpdateSessionInput {\n  engineSessionId?: string | null;\n  name?: string | null;\n  model?: string | null;\n  permissionMode?: string | null;\n  allowDangerouslySkipPermissions?: boolean | null;\n  systemPromptConfig?: SystemPromptConfig | null;\n  optionsConfig?: SessionOptionsConfig | null;\n  managementInfo?: ManagementInfo | null;\n}\n\n// ============================================================\n// JSON Parsing Utilities\n// ============================================================\n\nfunction parseJson<T>(value: string | null): T | undefined {\n  if (!value) return undefined;\n  try {\n    return JSON.parse(value) as T;\n  } catch {\n    return undefined;\n  }\n}\n\nfunction stringifyJson<T>(value: T | null | undefined): string | null {\n  if (value === null || value === undefined) return null;\n  return JSON.stringify(value);\n}\n\n// ============================================================\n// Type Conversion\n// ============================================================\n\nfunction rowToSession(row: SessionRow): AgentSession {\n  return {\n    id: row.id,\n    projectId: row.projectId,\n    engineName: row.engineName,\n    engineSessionId: row.engineSessionId ?? undefined,\n    name: row.name ?? undefined,\n    model: row.model ?? undefined,\n    permissionMode: row.permissionMode,\n    allowDangerouslySkipPermissions: row.allowDangerouslySkipPermissions === '1',\n    systemPromptConfig: parseJson<SystemPromptConfig>(row.systemPromptConfig),\n    optionsConfig: parseJson<SessionOptionsConfig>(row.optionsConfig),\n    managementInfo: parseJson<ManagementInfo>(row.managementInfo),\n    createdAt: row.createdAt,\n    updatedAt: row.updatedAt,\n  };\n}\n\n// ============================================================\n// Public API\n// ============================================================\n\n/**\n * Create a new session for a project.\n */\nexport async function createSession(\n  projectId: string,\n  engineName: EngineName,\n  options: CreateSessionOptions = {},\n): Promise<AgentSession> {\n  const db = getDb();\n  const now = new Date().toISOString();\n\n  // Resolve permission mode - AgentChat defaults to bypassPermissions for headless operation\n  const resolvedPermissionMode = options.permissionMode?.trim() || 'bypassPermissions';\n\n  // SDK requires allowDangerouslySkipPermissions=true when using bypassPermissions mode\n  // If explicitly provided, use that value; otherwise infer from permission mode\n  const resolvedAllowDangerouslySkipPermissions =\n    typeof options.allowDangerouslySkipPermissions === 'boolean'\n      ? options.allowDangerouslySkipPermissions\n      : resolvedPermissionMode === 'bypassPermissions';\n\n  const sessionData = {\n    id: options.id?.trim() || randomUUID(),\n    projectId,\n    engineName,\n    engineSessionId: options.engineSessionId?.trim() || null,\n    name: options.name?.trim() || null,\n    model: options.model?.trim() || null,\n    permissionMode: resolvedPermissionMode,\n    allowDangerouslySkipPermissions: resolvedAllowDangerouslySkipPermissions ? '1' : null,\n    systemPromptConfig: stringifyJson(options.systemPromptConfig),\n    optionsConfig: stringifyJson(options.optionsConfig),\n    managementInfo: null,\n    createdAt: now,\n    updatedAt: now,\n  };\n\n  await db.insert(sessions).values(sessionData);\n  return rowToSession(sessionData as SessionRow);\n}\n\n/**\n * Get a session by ID.\n */\nexport async function getSession(sessionId: string): Promise<AgentSession | undefined> {\n  const db = getDb();\n  const rows = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1);\n  return rows.length > 0 ? rowToSession(rows[0]) : undefined;\n}\n\n/** Maximum length for preview text */\nconst MAX_PREVIEW_LENGTH = 50;\n\n/**\n * Truncate text to max length with ellipsis.\n */\nfunction truncatePreview(text: string, maxLength: number = MAX_PREVIEW_LENGTH): string {\n  const trimmed = text.trim().replace(/\\s+/g, ' ');\n  if (trimmed.length <= maxLength) return trimmed;\n  return trimmed.slice(0, maxLength - 1) + '…';\n}\n\n/**\n * Add preview to sessions by fetching first user message for each.\n * Shared helper to avoid code duplication.\n */\nasync function addPreviewsToSessions(rows: SessionRow[]): Promise<AgentSession[]> {\n  const db = getDb();\n\n  return Promise.all(\n    rows.map(async (row) => {\n      const session = rowToSession(row);\n\n      // Query first user message for this session (include metadata for special rendering)\n      const firstUserMessages = await db\n        .select({ content: messages.content, metadata: messages.metadata })\n        .from(messages)\n        .where(and(eq(messages.sessionId, row.id), eq(messages.role, 'user')))\n        .orderBy(asc(messages.createdAt))\n        .limit(1);\n\n      if (firstUserMessages.length > 0 && firstUserMessages[0].content) {\n        const content = firstUserMessages[0].content;\n        const metadataJson = firstUserMessages[0].metadata;\n\n        session.preview = truncatePreview(content);\n\n        // Parse metadata to extract clientMeta/displayText for special rendering\n        if (metadataJson) {\n          try {\n            const parsed = JSON.parse(metadataJson) as Record<string, unknown>;\n\n            // Type-safe extraction with validation\n            const rawClientMeta = parsed.clientMeta;\n            const rawDisplayText = parsed.displayText;\n\n            // Validate displayText is a string\n            const displayText = typeof rawDisplayText === 'string' ? rawDisplayText : undefined;\n\n            // Validate clientMeta structure\n            const clientMeta =\n              rawClientMeta &&\n              typeof rawClientMeta === 'object' &&\n              'kind' in rawClientMeta &&\n              (rawClientMeta.kind === 'web_editor_apply_batch' ||\n                rawClientMeta.kind === 'web_editor_apply_single')\n                ? (rawClientMeta as AgentSessionPreviewMeta['clientMeta'])\n                : undefined;\n\n            // Only set previewMeta if we have valid special metadata\n            if (clientMeta || displayText) {\n              session.previewMeta = {\n                displayText: displayText || truncatePreview(content),\n                clientMeta,\n                // Truncate fullContent to avoid payload bloat (200 chars max)\n                fullContent: truncatePreview(content, 200),\n              };\n            }\n          } catch {\n            // Ignore JSON parse errors, just use plain preview\n          }\n        }\n      }\n\n      return session;\n    }),\n  );\n}\n\n/**\n * Get all sessions for a project, sorted by most recently updated.\n * Includes preview from first user message for each session.\n */\nexport async function getSessionsByProject(projectId: string): Promise<AgentSession[]> {\n  const db = getDb();\n  const rows = await db\n    .select()\n    .from(sessions)\n    .where(eq(sessions.projectId, projectId))\n    .orderBy(desc(sessions.updatedAt));\n\n  return addPreviewsToSessions(rows);\n}\n\n/**\n * Get all sessions across all projects, sorted by most recently updated.\n * Includes preview from first user message for each session.\n */\nexport async function getAllSessions(): Promise<AgentSession[]> {\n  const db = getDb();\n  const rows = await db.select().from(sessions).orderBy(desc(sessions.updatedAt));\n\n  return addPreviewsToSessions(rows);\n}\n\n/**\n * Get sessions for a project filtered by engine name.\n */\nexport async function getSessionsByProjectAndEngine(\n  projectId: string,\n  engineName: EngineName,\n): Promise<AgentSession[]> {\n  const db = getDb();\n  const rows = await db\n    .select()\n    .from(sessions)\n    .where(and(eq(sessions.projectId, projectId), eq(sessions.engineName, engineName)))\n    .orderBy(desc(sessions.updatedAt));\n  return rows.map(rowToSession);\n}\n\n/**\n * Update an existing session.\n */\nexport async function updateSession(sessionId: string, updates: UpdateSessionInput): Promise<void> {\n  const db = getDb();\n  const now = new Date().toISOString();\n\n  const updateData: Record<string, unknown> = {\n    updatedAt: now,\n  };\n\n  if (updates.engineSessionId !== undefined) {\n    updateData.engineSessionId = updates.engineSessionId?.trim() || null;\n  }\n\n  if (updates.name !== undefined) {\n    updateData.name = updates.name?.trim() || null;\n  }\n\n  if (updates.model !== undefined) {\n    updateData.model = updates.model?.trim() || null;\n  }\n\n  if (updates.permissionMode !== undefined) {\n    updateData.permissionMode = updates.permissionMode?.trim() || 'bypassPermissions';\n  }\n\n  if (updates.allowDangerouslySkipPermissions !== undefined) {\n    updateData.allowDangerouslySkipPermissions = updates.allowDangerouslySkipPermissions\n      ? '1'\n      : null;\n  }\n\n  if (updates.systemPromptConfig !== undefined) {\n    updateData.systemPromptConfig = stringifyJson(updates.systemPromptConfig);\n  }\n\n  if (updates.optionsConfig !== undefined) {\n    updateData.optionsConfig = stringifyJson(updates.optionsConfig);\n  }\n\n  if (updates.managementInfo !== undefined) {\n    updateData.managementInfo = stringifyJson(updates.managementInfo);\n  }\n\n  await db.update(sessions).set(updateData).where(eq(sessions.id, sessionId));\n}\n\n/**\n * Delete a session by ID.\n * Note: Messages associated with this session are NOT automatically deleted.\n * The caller should handle message cleanup if needed.\n */\nexport async function deleteSession(sessionId: string): Promise<void> {\n  const db = getDb();\n  await db.delete(sessions).where(eq(sessions.id, sessionId));\n}\n\n/**\n * Update the engine session ID (e.g., Claude SDK session_id).\n */\nexport async function updateEngineSessionId(\n  sessionId: string,\n  engineSessionId: string | null,\n): Promise<void> {\n  await updateSession(sessionId, { engineSessionId });\n}\n\n/**\n * Touch session activity - updates the updatedAt timestamp.\n * Used when a message is sent to move the session to the top of the list.\n */\nexport async function touchSessionActivity(sessionId: string): Promise<void> {\n  const db = getDb();\n  const now = new Date().toISOString();\n  await db.update(sessions).set({ updatedAt: now }).where(eq(sessions.id, sessionId));\n}\n\n/**\n * Update the cached management information.\n */\nexport async function updateManagementInfo(\n  sessionId: string,\n  info: ManagementInfo | null,\n): Promise<void> {\n  // Add timestamp to management info\n  const infoWithTimestamp = info ? { ...info, lastUpdated: new Date().toISOString() } : null;\n  await updateSession(sessionId, { managementInfo: infoWithTimestamp });\n}\n\n/**\n * Get or create a default session for a project and engine.\n * Useful for backwards compatibility - creates a session if none exists.\n */\nexport async function getOrCreateDefaultSession(\n  projectId: string,\n  engineName: EngineName,\n  options: CreateSessionOptions = {},\n): Promise<AgentSession> {\n  const existingSessions = await getSessionsByProjectAndEngine(projectId, engineName);\n\n  if (existingSessions.length > 0) {\n    // Return the most recently updated session\n    return existingSessions[0];\n  }\n\n  // Create a new default session\n  return createSession(projectId, engineName, {\n    ...options,\n    name: options.name || `Default ${engineName} session`,\n  });\n}\n"
  },
  {
    "path": "app/native-server/src/agent/storage.ts",
    "content": "/**\n * Storage path helpers for agent-related state.\n *\n * Provides unified path resolution for:\n * - SQLite database file\n * - Data directory\n * - Default workspace directory\n *\n * All paths can be overridden via environment variables.\n */\nimport os from 'node:os';\nimport path from 'node:path';\n\nconst DEFAULT_DATA_DIR = path.join(os.homedir(), '.chrome-mcp-agent');\n\n/**\n * Resolve base data directory for agent state.\n *\n * Environment:\n * - CHROME_MCP_AGENT_DATA_DIR: overrides the default base directory.\n */\nexport function getAgentDataDir(): string {\n  const raw = process.env.CHROME_MCP_AGENT_DATA_DIR;\n  if (raw && raw.trim()) {\n    return path.resolve(raw.trim());\n  }\n  return DEFAULT_DATA_DIR;\n}\n\n/**\n * Resolve database file path.\n *\n * Environment:\n * - CHROME_MCP_AGENT_DB_FILE: overrides the default database path.\n */\nexport function getDatabasePath(): string {\n  const raw = process.env.CHROME_MCP_AGENT_DB_FILE;\n  if (raw && raw.trim()) {\n    return path.resolve(raw.trim());\n  }\n  return path.join(getAgentDataDir(), 'agent.db');\n}\n\n/**\n * Get the default workspace directory for agent projects.\n * This is a subdirectory under the agent data directory.\n *\n * Cross-platform compatible:\n * - Mac/Linux: ~/.chrome-mcp-agent/workspaces\n * - Windows: %USERPROFILE%\\.chrome-mcp-agent\\workspaces\n */\nexport function getDefaultWorkspaceDir(): string {\n  return path.join(getAgentDataDir(), 'workspaces');\n}\n\n/**\n * Generate a default project root path for a given project name.\n */\nexport function getDefaultProjectRoot(projectName: string): string {\n  // Sanitize project name for use as directory name\n  const safeName = projectName\n    .trim()\n    .toLowerCase()\n    .replace(/[^a-z0-9_-]/g, '-')\n    .replace(/-+/g, '-')\n    .replace(/^-|-$/g, '');\n  return path.join(getDefaultWorkspaceDir(), safeName || 'default-project');\n}\n"
  },
  {
    "path": "app/native-server/src/agent/stream-manager.ts",
    "content": "import type { ServerResponse } from 'node:http';\nimport type { RealtimeEvent } from './types';\n\ntype WebSocketLike = {\n  readyState?: number;\n  send(data: string): void;\n  close?: () => void;\n};\n\nconst WEBSOCKET_OPEN_STATE = 1;\n\n/**\n * AgentStreamManager manages SSE/WebSocket connections keyed by sessionId.\n *\n * 中文说明：此实现参考 other/cweb 中的 StreamManager，但适配 Fastify/Node HTTP，\n * 使用 ServerResponse 直接写入 SSE 数据，避免在 Node 环境中额外引入 Web Streams 依赖。\n */\nexport class AgentStreamManager {\n  private readonly sseClients = new Map<string, Set<ServerResponse>>();\n  private readonly webSocketClients = new Map<string, Set<WebSocketLike>>();\n  private heartbeatTimer: NodeJS.Timeout | null = null;\n\n  addSseStream(sessionId: string, res: ServerResponse): void {\n    if (!this.sseClients.has(sessionId)) {\n      this.sseClients.set(sessionId, new Set());\n    }\n    this.sseClients.get(sessionId)!.add(res);\n    this.ensureHeartbeatTimer();\n  }\n\n  removeSseStream(sessionId: string, res: ServerResponse): void {\n    const clients = this.sseClients.get(sessionId);\n    if (!clients) {\n      return;\n    }\n\n    clients.delete(res);\n    if (clients.size === 0) {\n      this.sseClients.delete(sessionId);\n    }\n\n    this.stopHeartbeatTimerIfIdle();\n  }\n\n  addWebSocket(sessionId: string, socket: WebSocketLike): void {\n    if (!this.webSocketClients.has(sessionId)) {\n      this.webSocketClients.set(sessionId, new Set());\n    }\n    this.webSocketClients.get(sessionId)!.add(socket);\n    this.ensureHeartbeatTimer();\n  }\n\n  removeWebSocket(sessionId: string, socket: WebSocketLike): void {\n    const sockets = this.webSocketClients.get(sessionId);\n    if (!sockets) {\n      return;\n    }\n\n    sockets.delete(socket);\n    if (sockets.size === 0) {\n      this.webSocketClients.delete(sessionId);\n    }\n\n    this.stopHeartbeatTimerIfIdle();\n  }\n\n  publish(event: RealtimeEvent): void {\n    const payload = JSON.stringify(event);\n    const ssePayload = `data: ${payload}\\n\\n`;\n\n    // Heartbeat events are broadcast to all connections to keep them alive.\n    if (event.type === 'heartbeat') {\n      this.broadcastToAll(ssePayload, payload);\n      return;\n    }\n\n    // For all other event types, require a sessionId for routing.\n    const targetSessionId = this.extractSessionId(event);\n    if (!targetSessionId) {\n      // Drop events without sessionId to prevent cross-session leakage.\n\n      console.warn('[AgentStreamManager] Dropping event without sessionId:', event.type);\n      return;\n    }\n\n    // Session-scoped routing: only send to clients subscribed to this session.\n    this.sendToSession(targetSessionId, ssePayload, payload);\n  }\n\n  /**\n   * Extract sessionId from event based on event type.\n   */\n  private extractSessionId(event: RealtimeEvent): string | undefined {\n    switch (event.type) {\n      case 'message':\n        return event.data?.sessionId;\n      case 'status':\n        return event.data?.sessionId;\n      case 'connected':\n        return event.data?.sessionId;\n      case 'error':\n        return event.data?.sessionId;\n      case 'usage':\n        return event.data?.sessionId;\n      case 'heartbeat':\n        return undefined;\n      default:\n        return undefined;\n    }\n  }\n\n  /**\n   * Send event to a specific session's clients only.\n   */\n  private sendToSession(sessionId: string, ssePayload: string, wsPayload: string): void {\n    // SSE clients\n    const sseClients = this.sseClients.get(sessionId);\n    if (sseClients) {\n      const deadClients: ServerResponse[] = [];\n      for (const res of sseClients) {\n        if (this.isResponseDead(res)) {\n          deadClients.push(res);\n          continue;\n        }\n        try {\n          res.write(ssePayload);\n        } catch {\n          deadClients.push(res);\n        }\n      }\n      for (const res of deadClients) {\n        this.removeSseStream(sessionId, res);\n      }\n    }\n\n    // WebSocket clients\n    const wsSockets = this.webSocketClients.get(sessionId);\n    if (wsSockets) {\n      const deadSockets: WebSocketLike[] = [];\n      for (const socket of wsSockets) {\n        if (this.isSocketDead(socket)) {\n          deadSockets.push(socket);\n          continue;\n        }\n        try {\n          socket.send(wsPayload);\n        } catch {\n          deadSockets.push(socket);\n        }\n      }\n      for (const socket of deadSockets) {\n        this.removeWebSocket(sessionId, socket);\n      }\n    }\n  }\n\n  /**\n   * Broadcast event to all connected clients (used for heartbeat).\n   */\n  private broadcastToAll(ssePayload: string, wsPayload: string): void {\n    const deadSse: Array<{ sessionId: string; res: ServerResponse }> = [];\n    for (const [sessionId, clients] of this.sseClients.entries()) {\n      for (const res of clients) {\n        if (this.isResponseDead(res)) {\n          deadSse.push({ sessionId, res });\n          continue;\n        }\n        try {\n          res.write(ssePayload);\n        } catch {\n          deadSse.push({ sessionId, res });\n        }\n      }\n    }\n    for (const { sessionId, res } of deadSse) {\n      this.removeSseStream(sessionId, res);\n    }\n\n    const deadSockets: Array<{ sessionId: string; socket: WebSocketLike }> = [];\n    for (const [sessionId, sockets] of this.webSocketClients.entries()) {\n      for (const socket of sockets) {\n        if (this.isSocketDead(socket)) {\n          deadSockets.push({ sessionId, socket });\n          continue;\n        }\n        try {\n          socket.send(wsPayload);\n        } catch {\n          deadSockets.push({ sessionId, socket });\n        }\n      }\n    }\n    for (const { sessionId, socket } of deadSockets) {\n      this.removeWebSocket(sessionId, socket);\n    }\n  }\n\n  private isResponseDead(res: ServerResponse): boolean {\n    return (res as any).writableEnded || (res as any).destroyed;\n  }\n\n  private isSocketDead(socket: WebSocketLike): boolean {\n    return socket.readyState !== undefined && socket.readyState !== WEBSOCKET_OPEN_STATE;\n  }\n\n  closeAll(): void {\n    for (const [sessionId, clients] of this.sseClients.entries()) {\n      for (const res of clients) {\n        try {\n          res.end();\n        } catch {\n          // Ignore errors during shutdown.\n        }\n      }\n      this.sseClients.delete(sessionId);\n    }\n\n    for (const [sessionId, sockets] of this.webSocketClients.entries()) {\n      for (const socket of sockets) {\n        try {\n          socket.close?.();\n        } catch {\n          // Ignore errors during shutdown.\n        }\n      }\n      this.webSocketClients.delete(sessionId);\n    }\n\n    this.stopHeartbeatTimer();\n  }\n\n  private ensureHeartbeatTimer(): void {\n    if (this.heartbeatTimer) {\n      return;\n    }\n\n    this.heartbeatTimer = setInterval(() => {\n      if (this.sseClients.size === 0 && this.webSocketClients.size === 0) {\n        this.stopHeartbeatTimer();\n        return;\n      }\n\n      const event: RealtimeEvent = {\n        type: 'heartbeat',\n        data: { timestamp: new Date().toISOString() },\n      };\n      this.publish(event);\n    }, 30_000);\n\n    // Allow Node process to exit naturally even if heartbeat is active.\n    this.heartbeatTimer.unref?.();\n  }\n\n  private stopHeartbeatTimerIfIdle(): void {\n    if (this.sseClients.size === 0 && this.webSocketClients.size === 0) {\n      this.stopHeartbeatTimer();\n    }\n  }\n\n  private stopHeartbeatTimer(): void {\n    if (this.heartbeatTimer) {\n      clearInterval(this.heartbeatTimer);\n      this.heartbeatTimer = null;\n    }\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/tool-bridge.ts",
    "content": "import { Client } from '@modelcontextprotocol/sdk/client/index.js';\nimport { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';\nimport type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';\nimport { NATIVE_SERVER_PORT } from '../constant/index.js';\n\nexport interface CliToolInvocation {\n  /**\n   * The MCP server identifier (if provided by CLI).\n   * When omitted, this bridge defaults to the local chrome MCP server.\n   */\n  server?: string;\n  /**\n   * The MCP tool name to invoke.\n   */\n  tool: string;\n  /**\n   * JSON-serializable arguments for the tool call.\n   */\n  args?: Record<string, unknown>;\n}\n\nexport interface AgentToolBridgeOptions {\n  /**\n   * Base URL of the local MCP HTTP endpoint (e.g. http://127.0.0.1:12306/mcp).\n   * If omitted, DEFAULT_SERVER_PORT from chrome-mcp-shared is used.\n   */\n  mcpUrl?: string;\n}\n\n/**\n * AgentToolBridge maps CLI tool events (Codex, etc.) to MCP tool calls\n * against the local chrome MCP server via the official MCP SDK client.\n *\n * 中文说明：该桥接层负责将 CLI 上报的工具调用统一转为标准 MCP CallTool 请求，\n * 复用现有 /mcp HTTP server，而不是在本项目内自研额外协议。\n */\nexport class AgentToolBridge {\n  private readonly client: Client;\n  private readonly transport: StreamableHTTPClientTransport;\n\n  constructor(options: AgentToolBridgeOptions = {}) {\n    const url =\n      options.mcpUrl || `http://127.0.0.1:${process.env.MCP_HTTP_PORT || NATIVE_SERVER_PORT}/mcp`;\n\n    this.transport = new StreamableHTTPClientTransport(new URL(url));\n    this.client = new Client(\n      {\n        name: 'chrome-mcp-agent-bridge',\n        version: '1.0.0',\n      },\n      {},\n    );\n  }\n\n  /**\n   * Connects the MCP client over Streamable HTTP if not already connected.\n   */\n  async ensureConnected(): Promise<void> {\n    // Client.connect is idempotent; repeated calls reuse the same transport session.\n    if ((this.transport as any)._sessionId) {\n      return;\n    }\n    await this.client.connect(this.transport);\n  }\n\n  /**\n   * Invoke an MCP tool based on a CLI tool event.\n   * Returns the raw result from MCP client.callTool().\n   */\n  async callTool(invocation: CliToolInvocation): Promise<CallToolResult> {\n    await this.ensureConnected();\n\n    const args = invocation.args ?? {};\n    const result = await this.client.callTool({\n      name: invocation.tool,\n      arguments: args,\n    });\n\n    // The SDK returns a compatible structure; cast to satisfy strict typing.\n    return result as unknown as CallToolResult;\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/agent/types.ts",
    "content": "/**\n * Re-export agent types from shared package for backward compatibility.\n * All types are now defined in packages/shared/src/agent-types.ts to ensure\n * consistency between native-server and chrome-extension.\n */\nexport {\n  type AgentRole,\n  type AgentMessage,\n  type StreamTransport,\n  type AgentStatusEvent,\n  type AgentConnectedEvent,\n  type AgentHeartbeatEvent,\n  type RealtimeEvent,\n  type AgentAttachment,\n  type AgentCliPreference,\n  type AgentActRequest,\n  type AgentActResponse,\n  type AgentProject,\n  type AgentEngineInfo,\n  type AgentStoredMessage,\n} from 'chrome-mcp-shared';\n"
  },
  {
    "path": "app/native-server/src/cli.ts",
    "content": "#!/usr/bin/env node\n\nimport { program } from 'commander';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport {\n  tryRegisterUserLevelHost,\n  colorText,\n  registerWithElevatedPermissions,\n  ensureExecutionPermissions,\n  writeNodePathFile,\n} from './scripts/utils';\nimport { BrowserType, parseBrowserType, detectInstalledBrowsers } from './scripts/browser-config';\nimport { runDoctor } from './scripts/doctor';\nimport { runReport } from './scripts/report';\n\nprogram\n  .version(require('../package.json').version)\n  .description('Mcp Chrome Bridge - Local service for communicating with Chrome extension');\n\n// Register Native Messaging host\nprogram\n  .command('register')\n  .description('Register Native Messaging host')\n  .option('-f, --force', 'Force re-registration')\n  .option('-s, --system', 'Use system-level installation (requires administrator/sudo privileges)')\n  .option('-b, --browser <browser>', 'Register for specific browser (chrome, chromium, or all)')\n  .option('-d, --detect', 'Auto-detect installed browsers')\n  .action(async (options) => {\n    try {\n      // Write Node.js path for run_host scripts\n      writeNodePathFile(__dirname);\n\n      // Determine which browsers to register\n      let targetBrowsers: BrowserType[] | undefined;\n\n      if (options.browser) {\n        if (options.browser.toLowerCase() === 'all') {\n          targetBrowsers = [BrowserType.CHROME, BrowserType.CHROMIUM];\n          console.log(colorText('Registering for all supported browsers...', 'blue'));\n        } else {\n          const browserType = parseBrowserType(options.browser);\n          if (!browserType) {\n            console.error(\n              colorText(\n                `Invalid browser: ${options.browser}. Use 'chrome', 'chromium', or 'all'`,\n                'red',\n              ),\n            );\n            process.exit(1);\n          }\n          targetBrowsers = [browserType];\n        }\n      } else if (options.detect) {\n        targetBrowsers = detectInstalledBrowsers();\n        if (targetBrowsers.length === 0) {\n          console.log(\n            colorText(\n              'No supported browsers detected, will register for Chrome and Chromium',\n              'yellow',\n            ),\n          );\n          targetBrowsers = undefined; // Will use default behavior\n        }\n      }\n      // If neither option specified, tryRegisterUserLevelHost will detect browsers\n\n      // Detect if running with root/administrator privileges\n      const isRoot = process.getuid && process.getuid() === 0; // Unix/Linux/Mac\n\n      let isAdmin = false;\n      if (process.platform === 'win32') {\n        try {\n          isAdmin = require('is-admin')(); // Windows requires additional package\n        } catch (error) {\n          console.warn(\n            colorText('Warning: Unable to detect administrator privileges on Windows', 'yellow'),\n          );\n          isAdmin = false;\n        }\n      }\n\n      const hasElevatedPermissions = isRoot || isAdmin;\n\n      // If --system option is specified or running with root/administrator privileges\n      if (options.system || hasElevatedPermissions) {\n        // TODO: Update registerWithElevatedPermissions to support multiple browsers\n        await registerWithElevatedPermissions();\n        console.log(\n          colorText('System-level Native Messaging host registered successfully!', 'green'),\n        );\n        console.log(\n          colorText(\n            'You can now use connectNative in Chrome extension to connect to this service.',\n            'blue',\n          ),\n        );\n      } else {\n        // Regular user-level installation\n        console.log(colorText('Registering user-level Native Messaging host...', 'blue'));\n        const success = await tryRegisterUserLevelHost(targetBrowsers);\n\n        if (success) {\n          console.log(colorText('Native Messaging host registered successfully!', 'green'));\n          console.log(\n            colorText(\n              'You can now use connectNative in Chrome extension to connect to this service.',\n              'blue',\n            ),\n          );\n        } else {\n          console.log(\n            colorText(\n              'User-level registration failed, please try the following methods:',\n              'yellow',\n            ),\n          );\n          console.log(colorText('  1. sudo mcp-chrome-bridge register', 'yellow'));\n          console.log(colorText('  2. mcp-chrome-bridge register --system', 'yellow'));\n          process.exit(1);\n        }\n      }\n    } catch (error: any) {\n      console.error(colorText(`Registration failed: ${error.message}`, 'red'));\n      process.exit(1);\n    }\n  });\n\n// Fix execution permissions\nprogram\n  .command('fix-permissions')\n  .description('Fix execution permissions for native host files')\n  .action(async () => {\n    try {\n      console.log(colorText('Fixing execution permissions...', 'blue'));\n      await ensureExecutionPermissions();\n      console.log(colorText('✓ Execution permissions fixed successfully!', 'green'));\n    } catch (error: any) {\n      console.error(colorText(`Failed to fix permissions: ${error.message}`, 'red'));\n      process.exit(1);\n    }\n  });\n\n// Update port in stdio-config.json\nprogram\n  .command('update-port <port>')\n  .description('Update the port number in stdio-config.json')\n  .action(async (port: string) => {\n    try {\n      const portNumber = parseInt(port, 10);\n      if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {\n        console.error(colorText('Error: Port must be a valid number between 1 and 65535', 'red'));\n        process.exit(1);\n      }\n\n      const configPath = path.join(__dirname, 'mcp', 'stdio-config.json');\n\n      if (!fs.existsSync(configPath)) {\n        console.error(colorText(`Error: Configuration file not found at ${configPath}`, 'red'));\n        process.exit(1);\n      }\n\n      const configData = fs.readFileSync(configPath, 'utf8');\n      const config = JSON.parse(configData);\n\n      const currentUrl = new URL(config.url);\n      currentUrl.port = portNumber.toString();\n      config.url = currentUrl.toString();\n\n      fs.writeFileSync(configPath, JSON.stringify(config, null, 4));\n\n      console.log(colorText(`✓ Port updated successfully to ${portNumber}`, 'green'));\n      console.log(colorText(`Updated URL: ${config.url}`, 'blue'));\n    } catch (error: any) {\n      console.error(colorText(`Failed to update port: ${error.message}`, 'red'));\n      process.exit(1);\n    }\n  });\n\n// Diagnose installation and environment issues\nprogram\n  .command('doctor')\n  .description('Diagnose installation and environment issues')\n  .option('--json', 'Output diagnostics as JSON')\n  .option('--fix', 'Attempt to fix common issues automatically')\n  .option('-b, --browser <browser>', 'Target browser (chrome, chromium, or all)')\n  .action(async (options) => {\n    try {\n      const exitCode = await runDoctor({\n        json: Boolean(options.json),\n        fix: Boolean(options.fix),\n        browser: options.browser,\n      });\n      process.exit(exitCode);\n    } catch (error: any) {\n      console.error(colorText(`Doctor failed: ${error.message}`, 'red'));\n      process.exit(1);\n    }\n  });\n\n// Export diagnostic report for GitHub Issues\nprogram\n  .command('report')\n  .description('Export a diagnostic report for GitHub Issues')\n  .option('--json', 'Output report as JSON (default: Markdown)')\n  .option('--output <file>', 'Write report to file instead of stdout')\n  .option('--copy', 'Copy report to clipboard')\n  .option('--no-redact', 'Disable redaction of usernames/paths/tokens')\n  .option('--include-logs <mode>', 'Include wrapper logs: none | tail | full', 'tail')\n  .option('--log-lines <n>', 'Lines to include when --include-logs=tail', '200')\n  .option('-b, --browser <browser>', 'Target browser (chrome, chromium, or all)')\n  .action(async (options) => {\n    try {\n      const exitCode = await runReport({\n        json: Boolean(options.json),\n        output: options.output,\n        copy: Boolean(options.copy),\n        redact: options.redact,\n        includeLogs: options.includeLogs,\n        logLines: options.logLines ? parseInt(options.logLines, 10) : undefined,\n        browser: options.browser,\n      });\n      process.exit(exitCode);\n    } catch (error: any) {\n      console.error(colorText(`Report failed: ${error.message}`, 'red'));\n      process.exit(1);\n    }\n  });\n\nprogram.parse(process.argv);\n\n// If no command provided, show help\nif (!process.argv.slice(2).length) {\n  program.outputHelp();\n}\n"
  },
  {
    "path": "app/native-server/src/constant/index.ts",
    "content": "export enum NATIVE_MESSAGE_TYPE {\n  START = 'start',\n  STARTED = 'started',\n  STOP = 'stop',\n  STOPPED = 'stopped',\n  PING = 'ping',\n  PONG = 'pong',\n  ERROR = 'error',\n}\n\nexport const NATIVE_SERVER_PORT = 12306;\n\n// Timeout constants (in milliseconds)\nexport const TIMEOUTS = {\n  DEFAULT_REQUEST_TIMEOUT: 15000,\n  EXTENSION_REQUEST_TIMEOUT: 20000,\n  PROCESS_DATA_TIMEOUT: 20000,\n} as const;\n\n// Server configuration\nexport const SERVER_CONFIG = {\n  HOST: '127.0.0.1',\n  /**\n   * CORS origin whitelist - only allow Chrome/Firefox extensions and local debugging.\n   * Use RegExp patterns for extension origins, string for exact match.\n   */\n  CORS_ORIGIN: [/^chrome-extension:\\/\\//, /^moz-extension:\\/\\//, 'http://127.0.0.1'] as const,\n  LOGGER_ENABLED: false,\n} as const;\n\n// HTTP Status codes\nexport const HTTP_STATUS = {\n  OK: 200,\n  CREATED: 201,\n  NO_CONTENT: 204,\n  BAD_REQUEST: 400,\n  NOT_FOUND: 404,\n  INTERNAL_SERVER_ERROR: 500,\n  GATEWAY_TIMEOUT: 504,\n} as const;\n\n// Error messages\nexport const ERROR_MESSAGES = {\n  NATIVE_HOST_NOT_AVAILABLE: 'Native host connection not established.',\n  SERVER_NOT_RUNNING: 'Server is not actively running.',\n  REQUEST_TIMEOUT: 'Request to extension timed out.',\n  INVALID_MCP_REQUEST: 'Invalid MCP request or session.',\n  INVALID_SESSION_ID: 'Invalid or missing MCP session ID.',\n  INTERNAL_SERVER_ERROR: 'Internal Server Error',\n  MCP_SESSION_DELETION_ERROR: 'Internal server error during MCP session deletion.',\n  MCP_REQUEST_PROCESSING_ERROR: 'Internal server error during MCP request processing.',\n  INVALID_SSE_SESSION: 'Invalid or missing MCP session ID for SSE.',\n} as const;\n\n// ============================================================\n// Chrome MCP Server Configuration\n// ============================================================\n\n/**\n * Environment variables for dynamically resolving the local MCP HTTP endpoint.\n * CHROME_MCP_PORT is the preferred source; MCP_HTTP_PORT is kept for backward compatibility.\n */\nexport const CHROME_MCP_PORT_ENV = 'CHROME_MCP_PORT';\nexport const MCP_HTTP_PORT_ENV = 'MCP_HTTP_PORT';\n\n/**\n * Get the actual port the Chrome MCP server is listening on.\n * Priority: CHROME_MCP_PORT env > MCP_HTTP_PORT env > NATIVE_SERVER_PORT default\n */\nexport function getChromeMcpPort(): number {\n  const raw = process.env[CHROME_MCP_PORT_ENV] || process.env[MCP_HTTP_PORT_ENV];\n  const port = raw ? Number.parseInt(String(raw), 10) : NaN;\n  return Number.isFinite(port) && port > 0 && port <= 65535 ? port : NATIVE_SERVER_PORT;\n}\n\n/**\n * Get the full URL to the local Chrome MCP HTTP endpoint.\n * This URL is used by Claude/Codex agents to connect to the MCP server.\n */\nexport function getChromeMcpUrl(): string {\n  return `http://${SERVER_CONFIG.HOST}:${getChromeMcpPort()}/mcp`;\n}\n"
  },
  {
    "path": "app/native-server/src/file-handler.ts",
    "content": "import * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\nimport * as crypto from 'crypto';\nimport fetch from 'node-fetch';\n\n/**\n * File handler for managing file uploads through the native messaging host\n */\nexport class FileHandler {\n  private tempDir: string;\n\n  constructor() {\n    // Create a temp directory for file operations\n    this.tempDir = path.join(os.tmpdir(), 'chrome-mcp-uploads');\n    if (!fs.existsSync(this.tempDir)) {\n      fs.mkdirSync(this.tempDir, { recursive: true });\n    }\n  }\n\n  /**\n   * Handle file preparation request from the extension\n   */\n  async handleFileRequest(request: any): Promise<any> {\n    const { action, fileUrl, base64Data, fileName, filePath, traceFilePath, insightName } = request;\n\n    try {\n      switch (action) {\n        case 'prepareFile':\n          if (fileUrl) {\n            return await this.downloadFile(fileUrl, fileName);\n          } else if (base64Data) {\n            return await this.saveBase64File(base64Data, fileName);\n          } else if (filePath) {\n            return await this.verifyFile(filePath);\n          }\n          break;\n\n        case 'readBase64File': {\n          if (!filePath) return { success: false, error: 'filePath is required' };\n          return await this.readBase64File(filePath);\n        }\n\n        case 'cleanupFile':\n          return await this.cleanupFile(filePath);\n\n        case 'analyzeTrace': {\n          const targetPath = traceFilePath || filePath;\n          if (!targetPath) {\n            return { success: false, error: 'traceFilePath is required' };\n          }\n          try {\n            // With tsconfig moduleResolution=NodeNext, relative ESM imports need explicit .js extension\n            const { analyzeTraceFile } = await import('./trace-analyzer.js');\n            const res = await analyzeTraceFile(targetPath, insightName);\n            return { success: true, ...res };\n          } catch (e: any) {\n            return { success: false, error: e?.message || String(e) };\n          }\n        }\n\n        default:\n          return {\n            success: false,\n            error: `Unknown file action: ${action}`,\n          };\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : String(error),\n      };\n    }\n  }\n\n  /**\n   * Download a file from URL and save to temp directory\n   */\n  private async downloadFile(fileUrl: string, fileName?: string): Promise<any> {\n    try {\n      const response = await fetch(fileUrl);\n      if (!response.ok) {\n        throw new Error(`Failed to download file: ${response.statusText}`);\n      }\n\n      // Generate filename if not provided\n      const finalFileName = fileName || this.generateFileName(fileUrl);\n      const filePath = path.join(this.tempDir, finalFileName);\n\n      // Get the file buffer\n      const buffer = await response.buffer();\n\n      // Save to file\n      fs.writeFileSync(filePath, buffer);\n\n      return {\n        success: true,\n        filePath: filePath,\n        fileName: finalFileName,\n        size: buffer.length,\n      };\n    } catch (error) {\n      throw new Error(`Failed to download file from URL: ${error}`);\n    }\n  }\n\n  /**\n   * Save base64 data as a file\n   */\n  private async saveBase64File(base64Data: string, fileName?: string): Promise<any> {\n    try {\n      // Remove data URL prefix if present\n      const base64Content = base64Data.replace(/^data:.*?;base64,/, '');\n\n      // Convert base64 to buffer\n      const buffer = Buffer.from(base64Content, 'base64');\n\n      // Generate filename if not provided\n      const finalFileName = fileName || `upload-${Date.now()}.bin`;\n      const filePath = path.join(this.tempDir, finalFileName);\n\n      // Save to file\n      fs.writeFileSync(filePath, buffer);\n\n      return {\n        success: true,\n        filePath: filePath,\n        fileName: finalFileName,\n        size: buffer.length,\n      };\n    } catch (error) {\n      throw new Error(`Failed to save base64 file: ${error}`);\n    }\n  }\n\n  /**\n   * Verify that a file exists and is accessible\n   */\n  private async verifyFile(filePath: string): Promise<any> {\n    try {\n      // Check if file exists\n      if (!fs.existsSync(filePath)) {\n        throw new Error(`File does not exist: ${filePath}`);\n      }\n\n      // Get file stats\n      const stats = fs.statSync(filePath);\n\n      // Check if it's actually a file\n      if (!stats.isFile()) {\n        throw new Error(`Path is not a file: ${filePath}`);\n      }\n\n      // Check if file is readable\n      fs.accessSync(filePath, fs.constants.R_OK);\n\n      return {\n        success: true,\n        filePath: filePath,\n        fileName: path.basename(filePath),\n        size: stats.size,\n      };\n    } catch (error) {\n      throw new Error(`Failed to verify file: ${error}`);\n    }\n  }\n\n  /**\n   * Read file content and return as base64 string\n   */\n  private async readBase64File(filePath: string): Promise<any> {\n    try {\n      if (!fs.existsSync(filePath)) {\n        throw new Error(`File does not exist: ${filePath}`);\n      }\n      const stats = fs.statSync(filePath);\n      if (!stats.isFile()) {\n        throw new Error(`Path is not a file: ${filePath}`);\n      }\n      const buf = fs.readFileSync(filePath);\n      const base64 = buf.toString('base64');\n      return {\n        success: true,\n        filePath,\n        fileName: path.basename(filePath),\n        size: stats.size,\n        base64Data: base64,\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,\n      };\n    }\n  }\n\n  /**\n   * Clean up a temporary file\n   */\n  private async cleanupFile(filePath: string): Promise<any> {\n    try {\n      // Only allow cleanup of files in our temp directory\n      if (!filePath.startsWith(this.tempDir)) {\n        return {\n          success: false,\n          error: 'Can only cleanup files in temp directory',\n        };\n      }\n\n      if (fs.existsSync(filePath)) {\n        fs.unlinkSync(filePath);\n      }\n\n      return {\n        success: true,\n        message: 'File cleaned up successfully',\n      };\n    } catch (error) {\n      return {\n        success: false,\n        error: `Failed to cleanup file: ${error}`,\n      };\n    }\n  }\n\n  /**\n   * Generate a filename from URL or create a unique one\n   */\n  private generateFileName(url?: string): string {\n    if (url) {\n      try {\n        const urlObj = new URL(url);\n        const pathname = urlObj.pathname;\n        const basename = path.basename(pathname);\n        if (basename && basename !== '/') {\n          // Add random suffix to avoid collisions\n          const ext = path.extname(basename);\n          const name = path.basename(basename, ext);\n          const randomSuffix = crypto.randomBytes(4).toString('hex');\n          return `${name}-${randomSuffix}${ext}`;\n        }\n      } catch {\n        // Invalid URL, fall through to generate random name\n      }\n    }\n\n    // Generate random filename\n    return `upload-${crypto.randomBytes(8).toString('hex')}.bin`;\n  }\n\n  /**\n   * Clean up old temporary files (older than 1 hour)\n   */\n  cleanupOldFiles(): void {\n    try {\n      const now = Date.now();\n      const oneHour = 60 * 60 * 1000;\n\n      const files = fs.readdirSync(this.tempDir);\n      for (const file of files) {\n        const filePath = path.join(this.tempDir, file);\n        const stats = fs.statSync(filePath);\n        if (now - stats.mtimeMs > oneHour) {\n          fs.unlinkSync(filePath);\n          // Use stderr to avoid polluting stdout (Native Messaging protocol)\n          console.error(`Cleaned up old temp file: ${file}`);\n        }\n      }\n    } catch (error) {\n      console.error('Error cleaning up old files:', error);\n    }\n  }\n}\n\nexport default new FileHandler();\n"
  },
  {
    "path": "app/native-server/src/index.ts",
    "content": "#!/usr/bin/env node\nimport serverInstance from './server';\nimport nativeMessagingHostInstance from './native-messaging-host';\n\ntry {\n  serverInstance.setNativeHost(nativeMessagingHostInstance); // Server needs setNativeHost method\n  nativeMessagingHostInstance.setServer(serverInstance); // NativeHost needs setServer method\n  nativeMessagingHostInstance.start();\n} catch (error) {\n  process.exit(1);\n}\n\nprocess.on('error', (error) => {\n  process.exit(1);\n});\n\n// Handle process signals and uncaught exceptions\nprocess.on('SIGINT', () => {\n  process.exit(0);\n});\n\nprocess.on('SIGTERM', () => {\n  process.exit(0);\n});\n\nprocess.on('exit', (code) => {\n});\n\nprocess.on('uncaughtException', (error) => {\n  process.exit(1);\n});\n\nprocess.on('unhandledRejection', (reason) => {\n  // Don't exit immediately, let the program continue running\n});\n"
  },
  {
    "path": "app/native-server/src/mcp/mcp-server-stdio.ts",
    "content": "#!/usr/bin/env node\n\nimport { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport { Client } from '@modelcontextprotocol/sdk/client/index.js';\nimport {\n  CallToolRequestSchema,\n  CallToolResult,\n  ListToolsRequestSchema,\n  ListResourcesRequestSchema,\n  ListPromptsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js';\nimport { TOOL_SCHEMAS } from 'chrome-mcp-shared';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nlet stdioMcpServer: Server | null = null;\nlet mcpClient: Client | null = null;\n\n// Read configuration from stdio-config.json\nconst loadConfig = () => {\n  try {\n    const configPath = path.join(__dirname, 'stdio-config.json');\n    const configData = fs.readFileSync(configPath, 'utf8');\n    return JSON.parse(configData);\n  } catch (error) {\n    console.error('Failed to load stdio-config.json:', error);\n    throw new Error('Configuration file stdio-config.json not found or invalid');\n  }\n};\n\nexport const getStdioMcpServer = () => {\n  if (stdioMcpServer) {\n    return stdioMcpServer;\n  }\n  stdioMcpServer = new Server(\n    {\n      name: 'StdioChromeMcpServer',\n      version: '1.0.0',\n    },\n    {\n      capabilities: {\n        tools: {},\n        resources: {},\n        prompts: {},\n      },\n    },\n  );\n\n  setupTools(stdioMcpServer);\n  return stdioMcpServer;\n};\n\nexport const ensureMcpClient = async () => {\n  try {\n    if (mcpClient) {\n      const pingResult = await mcpClient.ping();\n      if (pingResult) {\n        return mcpClient;\n      }\n    }\n\n    const config = loadConfig();\n    mcpClient = new Client({ name: 'Mcp Chrome Proxy', version: '1.0.0' }, { capabilities: {} });\n    const transport = new StreamableHTTPClientTransport(new URL(config.url), {});\n    await mcpClient.connect(transport);\n    return mcpClient;\n  } catch (error) {\n    mcpClient?.close();\n    mcpClient = null;\n    console.error('Failed to connect to MCP server:', error);\n  }\n};\n\nexport const setupTools = (server: Server) => {\n  // List tools handler\n  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));\n\n  // Call tool handler\n  server.setRequestHandler(CallToolRequestSchema, async (request) =>\n    handleToolCall(request.params.name, request.params.arguments || {}),\n  );\n\n  // List resources handler - REQUIRED BY MCP PROTOCOL\n  server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));\n\n  // List prompts handler - REQUIRED BY MCP PROTOCOL\n  server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));\n};\n\nconst handleToolCall = async (name: string, args: any): Promise<CallToolResult> => {\n  try {\n    const client = await ensureMcpClient();\n    if (!client) {\n      throw new Error('Failed to connect to MCP server');\n    }\n    // Use a sane default of 2 minutes; the previous value mistakenly used 2*6*1000 (12s)\n    const DEFAULT_CALL_TIMEOUT_MS = 2 * 60 * 1000;\n    const result = await client.callTool({ name, arguments: args }, undefined, {\n      timeout: DEFAULT_CALL_TIMEOUT_MS,\n    });\n    return result as CallToolResult;\n  } catch (error: any) {\n    return {\n      content: [\n        {\n          type: 'text',\n          text: `Error calling tool: ${error.message}`,\n        },\n      ],\n      isError: true,\n    };\n  }\n};\n\nasync function main() {\n  const transport = new StdioServerTransport();\n  await getStdioMcpServer().connect(transport);\n}\n\nmain().catch((error) => {\n  console.error('Fatal error Chrome MCP Server main():', error);\n  process.exit(1);\n});\n"
  },
  {
    "path": "app/native-server/src/mcp/mcp-server.ts",
    "content": "import { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport { setupTools } from './register-tools';\n\nexport let mcpServer: Server | null = null;\n\nexport const getMcpServer = () => {\n  if (mcpServer) {\n    return mcpServer;\n  }\n  mcpServer = new Server(\n    {\n      name: 'ChromeMcpServer',\n      version: '1.0.0',\n    },\n    {\n      capabilities: {\n        tools: {},\n      },\n    },\n  );\n\n  setupTools(mcpServer);\n  return mcpServer;\n};\n"
  },
  {
    "path": "app/native-server/src/mcp/register-tools.ts",
    "content": "import { Server } from '@modelcontextprotocol/sdk/server/index.js';\nimport {\n  CallToolRequestSchema,\n  CallToolResult,\n  ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js';\nimport nativeMessagingHostInstance from '../native-messaging-host';\nimport { NativeMessageType, TOOL_SCHEMAS } from 'chrome-mcp-shared';\nimport type { Tool } from '@modelcontextprotocol/sdk/types.js';\n\nasync function listDynamicFlowTools(): Promise<Tool[]> {\n  try {\n    const response = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(\n      {},\n      'rr_list_published_flows',\n      20000,\n    );\n    if (response && response.status === 'success' && Array.isArray(response.items)) {\n      const tools: Tool[] = [];\n      for (const item of response.items) {\n        const name = `flow.${item.slug}`;\n        const description =\n          (item.meta && item.meta.tool && item.meta.tool.description) ||\n          item.description ||\n          'Recorded flow';\n        const properties: Record<string, any> = {};\n        const required: string[] = [];\n        for (const v of item.variables || []) {\n          const desc = v.label || v.key;\n          const typ = (v.type || 'string').toLowerCase();\n          const prop: any = { description: desc };\n          if (typ === 'boolean') prop.type = 'boolean';\n          else if (typ === 'number') prop.type = 'number';\n          else if (typ === 'enum') {\n            prop.type = 'string';\n            if (v.rules && Array.isArray(v.rules.enum)) prop.enum = v.rules.enum;\n          } else if (typ === 'array') {\n            // default array of strings; can extend with itemType later\n            prop.type = 'array';\n            prop.items = { type: 'string' };\n          } else {\n            prop.type = 'string';\n          }\n          if (v.default !== undefined) prop.default = v.default;\n          if (v.rules && v.rules.required) required.push(v.key);\n          properties[v.key] = prop;\n        }\n        // Run options\n        properties['tabTarget'] = { type: 'string', enum: ['current', 'new'], default: 'current' };\n        properties['refresh'] = { type: 'boolean', default: false };\n        properties['captureNetwork'] = { type: 'boolean', default: false };\n        properties['returnLogs'] = { type: 'boolean', default: false };\n        properties['timeoutMs'] = { type: 'number', minimum: 0 };\n        const tool: Tool = {\n          name,\n          description,\n          inputSchema: { type: 'object', properties, required },\n        };\n        tools.push(tool);\n      }\n      return tools;\n    }\n    return [];\n  } catch (e) {\n    return [];\n  }\n}\n\nexport const setupTools = (server: Server) => {\n  // List tools handler\n  server.setRequestHandler(ListToolsRequestSchema, async () => {\n    const dynamicTools = await listDynamicFlowTools();\n    return { tools: [...TOOL_SCHEMAS, ...dynamicTools] };\n  });\n\n  // Call tool handler\n  server.setRequestHandler(CallToolRequestSchema, async (request) =>\n    handleToolCall(request.params.name, request.params.arguments || {}),\n  );\n};\n\nconst handleToolCall = async (name: string, args: any): Promise<CallToolResult> => {\n  try {\n    // If calling a dynamic flow tool (name starts with flow.), proxy to common flow-run tool\n    if (name && name.startsWith('flow.')) {\n      // We need to resolve flow by slug to ID\n      try {\n        const resp = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(\n          {},\n          'rr_list_published_flows',\n          20000,\n        );\n        const items = (resp && resp.items) || [];\n        const slug = name.slice('flow.'.length);\n        const match = items.find((it: any) => it.slug === slug);\n        if (!match) throw new Error(`Flow not found for tool ${name}`);\n        const flowArgs = { flowId: match.id, args };\n        const proxyRes = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(\n          { name: 'record_replay_flow_run', args: flowArgs },\n          NativeMessageType.CALL_TOOL,\n          120000,\n        );\n        if (proxyRes.status === 'success') return proxyRes.data;\n        return {\n          content: [{ type: 'text', text: `Error calling dynamic flow tool: ${proxyRes.error}` }],\n          isError: true,\n        };\n      } catch (err: any) {\n        return {\n          content: [\n            {\n              type: 'text',\n              text: `Error resolving dynamic flow tool: ${err?.message || String(err)}`,\n            },\n          ],\n          isError: true,\n        };\n      }\n    }\n    // 发送请求到Chrome扩展并等待响应\n    const response = await nativeMessagingHostInstance.sendRequestToExtensionAndWait(\n      {\n        name,\n        args,\n      },\n      NativeMessageType.CALL_TOOL,\n      120000, // 延长到 120 秒，避免性能分析等长任务超时\n    );\n    if (response.status === 'success') {\n      return response.data;\n    } else {\n      return {\n        content: [\n          {\n            type: 'text',\n            text: `Error calling tool: ${response.error}`,\n          },\n        ],\n        isError: true,\n      };\n    }\n  } catch (error: any) {\n    return {\n      content: [\n        {\n          type: 'text',\n          text: `Error calling tool: ${error.message}`,\n        },\n      ],\n      isError: true,\n    };\n  }\n};\n"
  },
  {
    "path": "app/native-server/src/mcp/stdio-config.json",
    "content": "{\n  \"url\": \"http://127.0.0.1:12306/mcp\"\n}\n"
  },
  {
    "path": "app/native-server/src/native-messaging-host.ts",
    "content": "import { stdin, stdout } from 'process';\nimport { Server } from './server';\nimport { v4 as uuidv4 } from 'uuid';\nimport { NativeMessageType } from 'chrome-mcp-shared';\nimport { TIMEOUTS } from './constant';\nimport fileHandler from './file-handler';\n\ninterface PendingRequest {\n  resolve: (value: any) => void;\n  reject: (reason?: any) => void;\n  timeoutId: NodeJS.Timeout;\n}\n\nexport class NativeMessagingHost {\n  private associatedServer: Server | null = null;\n  private pendingRequests: Map<string, PendingRequest> = new Map();\n\n  public setServer(serverInstance: Server): void {\n    this.associatedServer = serverInstance;\n  }\n\n  // add message handler to wait for start server\n  public start(): void {\n    try {\n      this.setupMessageHandling();\n    } catch (error: any) {\n      process.exit(1);\n    }\n  }\n\n  private setupMessageHandling(): void {\n    let buffer = Buffer.alloc(0);\n    let expectedLength = -1;\n    const MAX_MESSAGES_PER_TICK = 100; // Safety guard to avoid long-running loops per readable tick\n    const MAX_MESSAGE_SIZE_BYTES = 16 * 1024 * 1024; // 16MB upper bound for a single message\n\n    const processAvailable = () => {\n      let processed = 0;\n      while (processed < MAX_MESSAGES_PER_TICK) {\n        // Read length header when needed\n        if (expectedLength === -1) {\n          if (buffer.length < 4) break; // not enough for header\n          expectedLength = buffer.readUInt32LE(0);\n          buffer = buffer.slice(4);\n\n          // Validate length header\n          if (expectedLength <= 0 || expectedLength > MAX_MESSAGE_SIZE_BYTES) {\n            this.sendError(`Invalid message length: ${expectedLength}`);\n            // Reset state to resynchronize stream\n            expectedLength = -1;\n            buffer = Buffer.alloc(0);\n            break;\n          }\n        }\n\n        // Wait for complete body\n        if (buffer.length < expectedLength) break;\n\n        const messageBuffer = buffer.slice(0, expectedLength);\n        buffer = buffer.slice(expectedLength);\n        expectedLength = -1;\n        processed++;\n\n        try {\n          const message = JSON.parse(messageBuffer.toString());\n          this.handleMessage(message);\n        } catch (error: any) {\n          this.sendError(`Failed to parse message: ${error.message}`);\n        }\n      }\n\n      // If we hit the cap but still have at least one complete message pending, schedule to continue soon\n      if (processed === MAX_MESSAGES_PER_TICK) {\n        setImmediate(processAvailable);\n      }\n    };\n\n    stdin.on('readable', () => {\n      let chunk;\n      while ((chunk = stdin.read()) !== null) {\n        buffer = Buffer.concat([buffer, chunk]);\n        processAvailable();\n      }\n    });\n\n    stdin.on('end', () => {\n      this.cleanup();\n    });\n\n    stdin.on('error', () => {\n      this.cleanup();\n    });\n  }\n\n  private async handleMessage(message: any): Promise<void> {\n    if (!message || typeof message !== 'object') {\n      this.sendError('Invalid message format');\n      return;\n    }\n\n    if (message.responseToRequestId) {\n      const requestId = message.responseToRequestId;\n      const pending = this.pendingRequests.get(requestId);\n\n      if (pending) {\n        clearTimeout(pending.timeoutId);\n        if (message.error) {\n          pending.reject(new Error(message.error));\n        } else {\n          pending.resolve(message.payload);\n        }\n        this.pendingRequests.delete(requestId);\n      } else {\n        // just ignore\n      }\n      return;\n    }\n\n    // Handle directive messages from Chrome\n    try {\n      switch (message.type) {\n        case NativeMessageType.START:\n          await this.startServer(message.payload?.port || 12306);\n          break;\n        case NativeMessageType.STOP:\n          await this.stopServer();\n          break;\n        // Keep ping/pong for simple liveness detection, but this differs from request-response pattern\n        case 'ping_from_extension':\n          this.sendMessage({ type: 'pong_to_extension' });\n          break;\n        case 'file_operation':\n          await this.handleFileOperation(message);\n          break;\n        default:\n          // Double check when message type is not supported\n          if (!message.responseToRequestId) {\n            this.sendError(\n              `Unknown message type or non-response message: ${message.type || 'no type'}`,\n            );\n          }\n      }\n    } catch (error: any) {\n      this.sendError(`Failed to handle directive message: ${error.message}`);\n    }\n  }\n\n  /**\n   * Handle file operations from the extension\n   */\n  private async handleFileOperation(message: any): Promise<void> {\n    try {\n      const result = await fileHandler.handleFileRequest(message.payload);\n\n      if (message.requestId) {\n        // Send response back with the request ID\n        this.sendMessage({\n          type: 'file_operation_response',\n          responseToRequestId: message.requestId,\n          payload: result,\n        });\n      } else {\n        // No request ID, just send result\n        this.sendMessage({\n          type: 'file_operation_result',\n          payload: result,\n        });\n      }\n    } catch (error: any) {\n      const errorResponse = {\n        success: false,\n        error: error.message || 'Unknown error during file operation',\n      };\n\n      if (message.requestId) {\n        this.sendMessage({\n          type: 'file_operation_response',\n          responseToRequestId: message.requestId,\n          error: errorResponse.error,\n        });\n      } else {\n        this.sendError(`File operation failed: ${errorResponse.error}`);\n      }\n    }\n  }\n\n  /**\n   * Send request to Chrome and wait for response\n   * @param messagePayload Data to send to Chrome\n   * @param timeoutMs Timeout for waiting response (milliseconds)\n   * @returns Promise, resolves to Chrome's returned payload on success, rejects on failure\n   */\n  public sendRequestToExtensionAndWait(\n    messagePayload: any,\n    messageType: string = 'request_data',\n    timeoutMs: number = TIMEOUTS.DEFAULT_REQUEST_TIMEOUT,\n  ): Promise<any> {\n    return new Promise((resolve, reject) => {\n      const requestId = uuidv4(); // Generate unique request ID\n\n      const timeoutId = setTimeout(() => {\n        this.pendingRequests.delete(requestId); // Remove from Map after timeout\n        reject(new Error(`Request timed out after ${timeoutMs}ms`));\n      }, timeoutMs);\n\n      // Store request's resolve/reject functions and timeout ID\n      this.pendingRequests.set(requestId, { resolve, reject, timeoutId });\n\n      // Send message with requestId to Chrome\n      this.sendMessage({\n        type: messageType, // Define a request type, e.g. 'request_data'\n        payload: messagePayload,\n        requestId: requestId, // <--- Key: include request ID\n      });\n    });\n  }\n\n  /**\n   * Start Fastify server (now accepts Server instance)\n   */\n  private async startServer(port: number): Promise<void> {\n    if (!this.associatedServer) {\n      this.sendError('Internal error: server instance not set');\n      return;\n    }\n    try {\n      if (this.associatedServer.isRunning) {\n        this.sendMessage({\n          type: NativeMessageType.ERROR,\n          payload: { message: 'Server is already running' },\n        });\n        return;\n      }\n\n      await this.associatedServer.start(port, this);\n\n      this.sendMessage({\n        type: NativeMessageType.SERVER_STARTED,\n        payload: { port },\n      });\n    } catch (error: any) {\n      this.sendError(`Failed to start server: ${error.message}`);\n    }\n  }\n\n  /**\n   * Stop Fastify server\n   */\n  private async stopServer(): Promise<void> {\n    if (!this.associatedServer) {\n      this.sendError('Internal error: server instance not set');\n      return;\n    }\n    try {\n      // Check status through associatedServer\n      if (!this.associatedServer.isRunning) {\n        this.sendMessage({\n          type: NativeMessageType.ERROR,\n          payload: { message: 'Server is not running' },\n        });\n        return;\n      }\n\n      await this.associatedServer.stop();\n      // this.serverStarted = false; // Server should update its own status after successful stop\n\n      this.sendMessage({ type: NativeMessageType.SERVER_STOPPED }); // Distinguish from previous 'stopped'\n    } catch (error: any) {\n      this.sendError(`Failed to stop server: ${error.message}`);\n    }\n  }\n\n  /**\n   * Send message to Chrome extension\n   */\n  public sendMessage(message: any): void {\n    try {\n      const messageString = JSON.stringify(message);\n      const messageBuffer = Buffer.from(messageString);\n      const headerBuffer = Buffer.alloc(4);\n      headerBuffer.writeUInt32LE(messageBuffer.length, 0);\n      // Ensure atomic write\n      stdout.write(Buffer.concat([headerBuffer, messageBuffer]), (err) => {\n        if (err) {\n          // Consider how to handle write failure, may affect request completion\n        } else {\n          // Message sent successfully, no action needed\n        }\n      });\n    } catch (error: any) {\n      // Catch JSON.stringify or Buffer operation errors\n      // If preparation stage fails, associated request may never be sent\n      // Need to consider whether to reject corresponding Promise (if called within sendRequestToExtensionAndWait)\n    }\n  }\n\n  /**\n   * Send error message to Chrome extension (mainly for sending non-request-response type errors)\n   */\n  private sendError(errorMessage: string): void {\n    this.sendMessage({\n      type: NativeMessageType.ERROR_FROM_NATIVE_HOST, // Use more explicit type\n      payload: { message: errorMessage },\n    });\n  }\n\n  /**\n   * Clean up resources\n   */\n  private cleanup(): void {\n    // Reject all pending requests\n    this.pendingRequests.forEach((pending) => {\n      clearTimeout(pending.timeoutId);\n      pending.reject(new Error('Native host is shutting down or Chrome disconnected.'));\n    });\n    this.pendingRequests.clear();\n\n    if (this.associatedServer && this.associatedServer.isRunning) {\n      this.associatedServer\n        .stop()\n        .then(() => {\n          process.exit(0);\n        })\n        .catch(() => {\n          process.exit(1);\n        });\n    } else {\n      process.exit(0);\n    }\n  }\n}\n\nconst nativeMessagingHostInstance = new NativeMessagingHost();\nexport default nativeMessagingHostInstance;\n"
  },
  {
    "path": "app/native-server/src/scripts/browser-config.ts",
    "content": "import * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { execSync } from 'child_process';\nimport { HOST_NAME } from './constant';\n\nexport enum BrowserType {\n  CHROME = 'chrome',\n  CHROMIUM = 'chromium',\n}\n\nexport interface BrowserConfig {\n  type: BrowserType;\n  displayName: string;\n  userManifestPath: string;\n  systemManifestPath: string;\n  registryKey?: string; // Windows only\n  systemRegistryKey?: string; // Windows only\n}\n\n/**\n * Get the user-level manifest path for a specific browser\n */\nfunction getUserManifestPathForBrowser(browser: BrowserType): string {\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join(appData, 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n      case BrowserType.CHROMIUM:\n        return path.join(appData, 'Chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n      default:\n        return path.join(appData, 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n    }\n  } else if (platform === 'darwin') {\n    const home = os.homedir();\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join(\n          home,\n          'Library',\n          'Application Support',\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      case BrowserType.CHROMIUM:\n        return path.join(\n          home,\n          'Library',\n          'Application Support',\n          'Chromium',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      default:\n        return path.join(\n          home,\n          'Library',\n          'Application Support',\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n    }\n  } else {\n    // Linux\n    const home = os.homedir();\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join(\n          home,\n          '.config',\n          'google-chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      case BrowserType.CHROMIUM:\n        return path.join(home, '.config', 'chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n      default:\n        return path.join(\n          home,\n          '.config',\n          'google-chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n    }\n  }\n}\n\n/**\n * Get the system-level manifest path for a specific browser\n */\nfunction getSystemManifestPathForBrowser(browser: BrowserType): string {\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    const programFiles = process.env.ProgramFiles || 'C:\\\\Program Files';\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join(\n          programFiles,\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      case BrowserType.CHROMIUM:\n        return path.join(programFiles, 'Chromium', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n      default:\n        return path.join(\n          programFiles,\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n    }\n  } else if (platform === 'darwin') {\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join(\n          '/Library',\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      case BrowserType.CHROMIUM:\n        return path.join(\n          '/Library',\n          'Application Support',\n          'Chromium',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n      default:\n        return path.join(\n          '/Library',\n          'Google',\n          'Chrome',\n          'NativeMessagingHosts',\n          `${HOST_NAME}.json`,\n        );\n    }\n  } else {\n    // Linux\n    switch (browser) {\n      case BrowserType.CHROME:\n        return path.join('/etc', 'opt', 'chrome', 'native-messaging-hosts', `${HOST_NAME}.json`);\n      case BrowserType.CHROMIUM:\n        return path.join('/etc', 'chromium', 'native-messaging-hosts', `${HOST_NAME}.json`);\n      default:\n        return path.join('/etc', 'opt', 'chrome', 'native-messaging-hosts', `${HOST_NAME}.json`);\n    }\n  }\n}\n\n/**\n * Get Windows registry keys for a browser\n */\nfunction getRegistryKeys(browser: BrowserType): { user: string; system: string } | undefined {\n  if (os.platform() !== 'win32') return undefined;\n\n  const browserPaths: Record<BrowserType, { user: string; system: string }> = {\n    [BrowserType.CHROME]: {\n      user: `HKCU\\\\Software\\\\Google\\\\Chrome\\\\NativeMessagingHosts\\\\${HOST_NAME}`,\n      system: `HKLM\\\\Software\\\\Google\\\\Chrome\\\\NativeMessagingHosts\\\\${HOST_NAME}`,\n    },\n    [BrowserType.CHROMIUM]: {\n      user: `HKCU\\\\Software\\\\Chromium\\\\NativeMessagingHosts\\\\${HOST_NAME}`,\n      system: `HKLM\\\\Software\\\\Chromium\\\\NativeMessagingHosts\\\\${HOST_NAME}`,\n    },\n  };\n\n  return browserPaths[browser];\n}\n\n/**\n * Get browser configuration\n */\nexport function getBrowserConfig(browser: BrowserType): BrowserConfig {\n  const registryKeys = getRegistryKeys(browser);\n\n  return {\n    type: browser,\n    displayName: browser.charAt(0).toUpperCase() + browser.slice(1),\n    userManifestPath: getUserManifestPathForBrowser(browser),\n    systemManifestPath: getSystemManifestPathForBrowser(browser),\n    registryKey: registryKeys?.user,\n    systemRegistryKey: registryKeys?.system,\n  };\n}\n\n/**\n * Detect installed browsers on the system\n */\nexport function detectInstalledBrowsers(): BrowserType[] {\n  const detectedBrowsers: BrowserType[] = [];\n  const platform = os.platform();\n\n  if (platform === 'win32') {\n    // Check Windows registry for installed browsers\n    const browsers: Array<{ type: BrowserType; registryPath: string }> = [\n      { type: BrowserType.CHROME, registryPath: 'HKLM\\\\SOFTWARE\\\\Google\\\\Chrome' },\n      { type: BrowserType.CHROMIUM, registryPath: 'HKLM\\\\SOFTWARE\\\\Chromium' },\n    ];\n\n    for (const browser of browsers) {\n      try {\n        execSync(`reg query \"${browser.registryPath}\" 2>nul`, { stdio: 'pipe' });\n        detectedBrowsers.push(browser.type);\n      } catch {\n        // Browser not installed\n      }\n    }\n  } else if (platform === 'darwin') {\n    // Check macOS Applications folder\n    const browsers: Array<{ type: BrowserType; appPath: string }> = [\n      { type: BrowserType.CHROME, appPath: '/Applications/Google Chrome.app' },\n      { type: BrowserType.CHROMIUM, appPath: '/Applications/Chromium.app' },\n    ];\n\n    for (const browser of browsers) {\n      if (fs.existsSync(browser.appPath)) {\n        detectedBrowsers.push(browser.type);\n      }\n    }\n  } else {\n    // Check Linux paths using which command\n    const browsers: Array<{ type: BrowserType; commands: string[] }> = [\n      { type: BrowserType.CHROME, commands: ['google-chrome', 'google-chrome-stable'] },\n      { type: BrowserType.CHROMIUM, commands: ['chromium', 'chromium-browser'] },\n    ];\n\n    for (const browser of browsers) {\n      for (const cmd of browser.commands) {\n        try {\n          execSync(`which ${cmd} 2>/dev/null`, { stdio: 'pipe' });\n          detectedBrowsers.push(browser.type);\n          break; // Found one command, no need to check others\n        } catch {\n          // Command not found\n        }\n      }\n    }\n  }\n\n  return detectedBrowsers;\n}\n\n/**\n * Get all supported browser configs\n */\nexport function getAllBrowserConfigs(): BrowserConfig[] {\n  return Object.values(BrowserType).map((browser) => getBrowserConfig(browser));\n}\n\n/**\n * Parse browser type from string\n */\nexport function parseBrowserType(browserStr: string): BrowserType | undefined {\n  const normalized = browserStr.toLowerCase();\n  return Object.values(BrowserType).find((type) => type === normalized);\n}\n"
  },
  {
    "path": "app/native-server/src/scripts/build.ts",
    "content": "import { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\n\nconst distDir = path.join(__dirname, '..', '..', 'dist');\n// 清理上次构建\nconsole.log('清理上次构建...');\ntry {\n  fs.rmSync(distDir, { recursive: true, force: true });\n} catch (err) {\n  // 忽略目录不存在的错误\n  console.log(err);\n}\n\n// 创建dist目录\nfs.mkdirSync(distDir, { recursive: true });\nfs.mkdirSync(path.join(distDir, 'logs'), { recursive: true }); // 创建logs目录\nconsole.log('dist 和 dist/logs 目录已创建/确认存在');\n\n// 编译TypeScript\nconsole.log('编译TypeScript...');\nexecSync('tsc', { stdio: 'inherit' });\n\n// 复制配置文件\nconsole.log('复制配置文件...');\nconst configSourcePath = path.join(__dirname, '..', 'mcp', 'stdio-config.json');\nconst configDestPath = path.join(distDir, 'mcp', 'stdio-config.json');\n\ntry {\n  // 确保目标目录存在\n  fs.mkdirSync(path.dirname(configDestPath), { recursive: true });\n\n  if (fs.existsSync(configSourcePath)) {\n    fs.copyFileSync(configSourcePath, configDestPath);\n    console.log(`已将 stdio-config.json 复制到 ${configDestPath}`);\n  } else {\n    console.error(`错误: 配置文件未找到: ${configSourcePath}`);\n  }\n} catch (error) {\n  console.error('复制配置文件时出错:', error);\n}\n\n// 复制package.json并更新其内容\nconsole.log('准备package.json...');\nconst packageJson = require('../../package.json');\n\n// 创建安装说明\nconst readmeContent = `# ${packageJson.name}\n\n本程序为Chrome扩展的Native Messaging主机端。\n\n## 安装说明\n\n1. 确保已安装Node.js\n2. 全局安装本程序:\n   \\`\\`\\`\n   npm install -g ${packageJson.name}\n   \\`\\`\\`\n3. 注册Native Messaging主机:\n   \\`\\`\\`\n   # 用户级别安装（推荐）\n   ${packageJson.name} register\n\n   # 如果用户级别安装失败，可以尝试系统级别安装\n   ${packageJson.name} register --system\n   # 或者使用管理员权限\n   sudo ${packageJson.name} register\n   \\`\\`\\`\n\n## 使用方法\n\n此应用程序由Chrome扩展自动启动，无需手动运行。\n`;\n\nfs.writeFileSync(path.join(distDir, 'README.md'), readmeContent);\n\nconsole.log('复制包装脚本...');\nconst scriptsSourceDir = path.join(__dirname, '.');\nconst macOsWrapperSourcePath = path.join(scriptsSourceDir, 'run_host.sh');\nconst windowsWrapperSourcePath = path.join(scriptsSourceDir, 'run_host.bat');\n\nconst macOsWrapperDestPath = path.join(distDir, 'run_host.sh');\nconst windowsWrapperDestPath = path.join(distDir, 'run_host.bat');\n\ntry {\n  if (fs.existsSync(macOsWrapperSourcePath)) {\n    fs.copyFileSync(macOsWrapperSourcePath, macOsWrapperDestPath);\n    console.log(`已将 ${macOsWrapperSourcePath} 复制到 ${macOsWrapperDestPath}`);\n  } else {\n    console.error(`错误: macOS 包装脚本源文件未找到: ${macOsWrapperSourcePath}`);\n  }\n\n  if (fs.existsSync(windowsWrapperSourcePath)) {\n    fs.copyFileSync(windowsWrapperSourcePath, windowsWrapperDestPath);\n    console.log(`已将 ${windowsWrapperSourcePath} 复制到 ${windowsWrapperDestPath}`);\n  } else {\n    console.error(`错误: Windows 包装脚本源文件未找到: ${windowsWrapperSourcePath}`);\n  }\n} catch (error) {\n  console.error('复制包装脚本时出错:', error);\n}\n\n// 为关键JavaScript文件和macOS包装脚本添加可执行权限\nconsole.log('添加可执行权限...');\nconst filesToMakeExecutable = ['index.js', 'cli.js', 'run_host.sh']; // cli.js 假设在 dist 根目录\n\nfilesToMakeExecutable.forEach((file) => {\n  const filePath = path.join(distDir, file); // filePath 现在是目标路径\n  try {\n    if (fs.existsSync(filePath)) {\n      fs.chmodSync(filePath, '755');\n      console.log(`已为 ${file} 添加可执行权限 (755)`);\n    } else {\n      console.warn(`警告: ${filePath} 不存在，无法添加可执行权限`);\n    }\n  } catch (error) {\n    console.error(`为 ${file} 添加可执行权限时出错:`, error);\n  }\n});\n\n// Write node_path.txt immediately after build to ensure Chrome uses the correct Node.js version.\n// This is critical for development mode where dist is deleted on each rebuild.\n// The file points to the same Node.js that compiled the native modules (better-sqlite3 etc.)\nconsole.log('写入 node_path.txt...');\nconst nodePathFile = path.join(distDir, 'node_path.txt');\nfs.writeFileSync(nodePathFile, process.execPath, 'utf8');\nconsole.log(`已写入 Node.js 路径: ${process.execPath}`);\n\nconsole.log('✅ 构建完成');\n"
  },
  {
    "path": "app/native-server/src/scripts/constant.ts",
    "content": "export const COMMAND_NAME = 'mcp-chrome-bridge';\nexport const EXTENSION_ID = 'hbdgbgagpkpjffpklnamcljpakneikee';\nexport const HOST_NAME = 'com.chromemcp.nativehost';\nexport const DESCRIPTION = 'Node.js Host for Browser Bridge Extension';\n"
  },
  {
    "path": "app/native-server/src/scripts/doctor.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * doctor.ts\n *\n * Diagnoses common installation and runtime issues for the Chrome Native Messaging host.\n * Provides checks for manifest files, Node.js path, permissions, and connectivity.\n */\n\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { execFileSync } from 'child_process';\nimport { EXTENSION_ID, HOST_NAME, COMMAND_NAME } from './constant';\nimport {\n  BrowserType,\n  detectInstalledBrowsers,\n  getBrowserConfig,\n  parseBrowserType,\n} from './browser-config';\nimport {\n  colorText,\n  ensureExecutionPermissions,\n  tryRegisterUserLevelHost,\n  getLogDir,\n} from './utils';\nimport { NATIVE_SERVER_PORT } from '../constant';\n\nconst EXPECTED_PORT = 12306;\nconst SCHEMA_VERSION = 1;\nconst MIN_NODE_MAJOR_VERSION = 20;\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface DoctorOptions {\n  json?: boolean;\n  fix?: boolean;\n  browser?: string;\n}\n\nexport type DoctorStatus = 'ok' | 'warn' | 'error';\n\nexport interface DoctorFixAttempt {\n  id: string;\n  description: string;\n  success: boolean;\n  error?: string;\n}\n\nexport interface DoctorCheckResult {\n  id: string;\n  title: string;\n  status: DoctorStatus;\n  message: string;\n  details?: Record<string, unknown>;\n}\n\nexport interface DoctorReport {\n  schemaVersion: number;\n  timestamp: string;\n  ok: boolean;\n  summary: {\n    ok: number;\n    warn: number;\n    error: number;\n  };\n  environment: {\n    platform: NodeJS.Platform;\n    arch: string;\n    node: {\n      version: string;\n      execPath: string;\n    };\n    package: {\n      name: string;\n      version: string;\n      rootDir: string;\n      distDir: string;\n    };\n    command: {\n      canonical: string;\n      aliases: string[];\n    };\n    nativeHost: {\n      hostName: string;\n      expectedPort: number;\n    };\n  };\n  fixes: DoctorFixAttempt[];\n  checks: DoctorCheckResult[];\n  nextSteps: string[];\n}\n\ninterface NodeResolutionResult {\n  nodePath?: string;\n  source?: string;\n  version?: string;\n  versionError?: string;\n  nodePathFile: {\n    path: string;\n    exists: boolean;\n    value?: string;\n    valid?: boolean;\n    error?: string;\n  };\n}\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\nfunction readPackageJson(): Record<string, unknown> {\n  try {\n    return require('../../package.json') as Record<string, unknown>;\n  } catch {\n    return {};\n  }\n}\n\nfunction getCommandInfo(pkg: Record<string, unknown>): { canonical: string; aliases: string[] } {\n  const bin = pkg.bin as Record<string, string> | undefined;\n  if (!bin || typeof bin !== 'object') {\n    return { canonical: COMMAND_NAME, aliases: [] };\n  }\n\n  const canonical = COMMAND_NAME;\n  const canonicalTarget = bin[canonical];\n\n  const aliases = canonicalTarget\n    ? Object.keys(bin).filter((name) => name !== canonical && bin[name] === canonicalTarget)\n    : [];\n\n  return { canonical, aliases };\n}\n\nfunction resolveDistDir(): string {\n  // __dirname is dist/scripts when running from compiled code\n  const candidateFromDistScripts = path.resolve(__dirname, '..');\n  const candidateFromSrcScripts = path.resolve(__dirname, '..', '..', 'dist');\n\n  const looksLikeDist = (dir: string): boolean => {\n    return (\n      fs.existsSync(path.join(dir, 'mcp', 'stdio-config.json')) ||\n      fs.existsSync(path.join(dir, 'run_host.sh')) ||\n      fs.existsSync(path.join(dir, 'run_host.bat'))\n    );\n  };\n\n  if (looksLikeDist(candidateFromDistScripts)) return candidateFromDistScripts;\n  if (looksLikeDist(candidateFromSrcScripts)) return candidateFromSrcScripts;\n  return candidateFromDistScripts;\n}\n\nfunction stringifyError(err: unknown): string {\n  if (err instanceof Error) return err.message;\n  return String(err);\n}\n\nfunction canExecute(filePath: string): boolean {\n  try {\n    fs.accessSync(filePath, fs.constants.X_OK);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction normalizeComparablePath(filePath: string): string {\n  if (process.platform === 'win32') {\n    return path.normalize(filePath).toLowerCase();\n  }\n  return path.normalize(filePath);\n}\n\nfunction stripOuterQuotes(input: string): string {\n  const trimmed = input.trim();\n  if (\n    (trimmed.startsWith('\"') && trimmed.endsWith('\"')) ||\n    (trimmed.startsWith(\"'\") && trimmed.endsWith(\"'\"))\n  ) {\n    return trimmed.slice(1, -1);\n  }\n  return trimmed;\n}\n\nfunction expandTilde(inputPath: string): string {\n  if (inputPath === '~') return os.homedir();\n  if (inputPath.startsWith('~/') || inputPath.startsWith('~\\\\')) {\n    return path.join(os.homedir(), inputPath.slice(2));\n  }\n  return inputPath;\n}\n\nfunction expandWindowsEnvVars(input: string): string {\n  if (process.platform !== 'win32') return input;\n  return input.replace(/%([^%]+)%/g, (_match, name: string) => {\n    const key = String(name);\n    return (\n      process.env[key] ?? process.env[key.toUpperCase()] ?? process.env[key.toLowerCase()] ?? _match\n    );\n  });\n}\n\nfunction parseVersionFromDirName(dirName: string): number[] | null {\n  const cleaned = dirName.trim().replace(/^v/, '');\n  if (!/^\\d+(\\.\\d+){0,3}$/.test(cleaned)) return null;\n  return cleaned.split('.').map((part) => Number(part));\n}\n\n/**\n * Parse Node.js version string from `node -v` output.\n * Handles versions like: v20.10.0, v22.0.0-nightly.2024..., v21.0.0-rc.1\n * Returns major version number or null if parsing fails.\n */\nfunction parseNodeMajorVersion(versionString: string): number | null {\n  if (!versionString) return null;\n  // Match pattern: v?MAJOR.MINOR.PATCH[-anything]\n  const match = versionString.trim().match(/^v?(\\d+)(?:\\.\\d+)*(?:[-+].*)?$/i);\n  if (match?.[1]) {\n    const major = Number(match[1]);\n    return Number.isNaN(major) ? null : major;\n  }\n  return null;\n}\n\nfunction compareVersions(a: number[], b: number[]): number {\n  const len = Math.max(a.length, b.length);\n  for (let i = 0; i < len; i++) {\n    const av = a[i] ?? 0;\n    const bv = b[i] ?? 0;\n    if (av !== bv) return av - bv;\n  }\n  return 0;\n}\n\nfunction pickLatestVersionDir(parentDir: string): string | null {\n  if (!fs.existsSync(parentDir)) return null;\n  const dirents = fs.readdirSync(parentDir, { withFileTypes: true });\n  let best: { name: string; version: number[] } | null = null;\n\n  for (const dirent of dirents) {\n    if (!dirent.isDirectory()) continue;\n    const parsed = parseVersionFromDirName(dirent.name);\n    if (!parsed) continue;\n    if (!best || compareVersions(parsed, best.version) > 0) {\n      best = { name: dirent.name, version: parsed };\n    }\n  }\n\n  return best ? path.join(parentDir, best.name) : null;\n}\n\n// ============================================================================\n// Node Resolution (mirrors run_host.sh/bat logic)\n// ============================================================================\n\nfunction resolveNodeCandidate(distDir: string): NodeResolutionResult {\n  const nodeFileName = process.platform === 'win32' ? 'node.exe' : 'node';\n  const nodePathFilePath = path.join(distDir, 'node_path.txt');\n\n  const nodePathFile: NodeResolutionResult['nodePathFile'] = {\n    path: nodePathFilePath,\n    exists: fs.existsSync(nodePathFilePath),\n  };\n\n  const consider = (\n    source: string,\n    rawCandidate?: string,\n  ): { nodePath: string; source: string } | null => {\n    if (!rawCandidate) return null;\n    let candidate = expandTilde(stripOuterQuotes(rawCandidate));\n\n    try {\n      if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {\n        candidate = path.join(candidate, nodeFileName);\n      }\n    } catch {\n      // ignore\n    }\n\n    if (canExecute(candidate)) {\n      return { nodePath: candidate, source };\n    }\n    return null;\n  };\n\n  // Priority 0: CHROME_MCP_NODE_PATH\n  const fromEnv = consider('CHROME_MCP_NODE_PATH', process.env.CHROME_MCP_NODE_PATH);\n  if (fromEnv) {\n    return { ...fromEnv, nodePathFile };\n  }\n\n  // Priority 1: node_path.txt\n  if (nodePathFile.exists) {\n    try {\n      const content = fs.readFileSync(nodePathFilePath, 'utf8').trim();\n      nodePathFile.value = content;\n      const fromFile = consider('node_path.txt', content);\n      nodePathFile.valid = Boolean(fromFile);\n      if (fromFile) {\n        return { ...fromFile, nodePathFile };\n      }\n    } catch (e) {\n      nodePathFile.error = stringifyError(e);\n      nodePathFile.valid = false;\n    }\n  }\n\n  // Priority 1.5: Relative path fallback (mirrors run_host.sh/bat)\n  // Unix: ../../../bin/node (from dist/)\n  // Windows: ..\\..\\..\\node.exe (from dist/, no bin/ subdirectory)\n  const relativeNodePath =\n    process.platform === 'win32'\n      ? path.resolve(distDir, '..', '..', '..', nodeFileName)\n      : path.resolve(distDir, '..', '..', '..', 'bin', nodeFileName);\n  const fromRelative = consider('relative', relativeNodePath);\n  if (fromRelative) return { ...fromRelative, nodePathFile };\n\n  // Priority 2: Volta\n  const voltaHome = process.env.VOLTA_HOME || path.join(os.homedir(), '.volta');\n  const fromVolta = consider('volta', path.join(voltaHome, 'bin', nodeFileName));\n  if (fromVolta) return { ...fromVolta, nodePathFile };\n\n  // Priority 3: asdf (cross-platform)\n  const asdfDir = process.env.ASDF_DATA_DIR || path.join(os.homedir(), '.asdf');\n  const asdfNodejsDir = path.join(asdfDir, 'installs', 'nodejs');\n  const latestAsdf = pickLatestVersionDir(asdfNodejsDir);\n  if (latestAsdf) {\n    const fromAsdf = consider('asdf', path.join(latestAsdf, 'bin', nodeFileName));\n    if (fromAsdf) return { ...fromAsdf, nodePathFile };\n  }\n\n  // Priority 4: fnm (cross-platform, Windows uses different layout)\n  const fnmDir = process.env.FNM_DIR || path.join(os.homedir(), '.fnm');\n  const fnmVersionsDir = path.join(fnmDir, 'node-versions');\n  const latestFnm = pickLatestVersionDir(fnmVersionsDir);\n  if (latestFnm) {\n    const fnmNodePath =\n      process.platform === 'win32'\n        ? path.join(latestFnm, 'installation', nodeFileName)\n        : path.join(latestFnm, 'installation', 'bin', nodeFileName);\n    const fromFnm = consider('fnm', fnmNodePath);\n    if (fromFnm) return { ...fromFnm, nodePathFile };\n  }\n\n  // Priority 5: NVM (Unix only)\n  if (process.platform !== 'win32') {\n    const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm');\n    const nvmDefaultAlias = path.join(nvmDir, 'alias', 'default');\n    try {\n      if (fs.existsSync(nvmDefaultAlias)) {\n        const stat = fs.lstatSync(nvmDefaultAlias);\n        const maybeVersion = stat.isSymbolicLink()\n          ? fs.readlinkSync(nvmDefaultAlias).trim()\n          : fs.readFileSync(nvmDefaultAlias, 'utf8').trim();\n        const fromDefault = consider(\n          'nvm-default',\n          path.join(nvmDir, 'versions', 'node', maybeVersion, 'bin', 'node'),\n        );\n        if (fromDefault) return { ...fromDefault, nodePathFile };\n      }\n    } catch {\n      // ignore\n    }\n\n    const latestNvm = pickLatestVersionDir(path.join(nvmDir, 'versions', 'node'));\n    if (latestNvm) {\n      const fromNvm = consider('nvm-latest', path.join(latestNvm, 'bin', 'node'));\n      if (fromNvm) return { ...fromNvm, nodePathFile };\n    }\n  }\n\n  // Priority 6: Common paths\n  const commonPaths =\n    process.platform === 'win32'\n      ? [\n          path.join(process.env.ProgramFiles || 'C:\\\\Program Files', 'nodejs', 'node.exe'),\n          path.join(\n            process.env['ProgramFiles(x86)'] || 'C:\\\\Program Files (x86)',\n            'nodejs',\n            'node.exe',\n          ),\n          path.join(process.env.LOCALAPPDATA || '', 'Programs', 'nodejs', 'node.exe'),\n        ].filter((p) => path.isAbsolute(p))\n      : ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];\n  for (const common of commonPaths) {\n    const resolved = consider('common', common);\n    if (resolved) return { ...resolved, nodePathFile };\n  }\n\n  // Priority 7: PATH\n  const pathEnv = process.env.PATH || '';\n  for (const rawDir of pathEnv.split(path.delimiter)) {\n    const dir = stripOuterQuotes(rawDir);\n    if (!dir) continue;\n    const candidate = path.join(dir, nodeFileName);\n    if (canExecute(candidate)) {\n      return { nodePath: candidate, source: 'PATH', nodePathFile };\n    }\n  }\n\n  return { nodePathFile };\n}\n\n// ============================================================================\n// Browser Resolution\n// ============================================================================\n\nfunction resolveTargetBrowsers(browserArg: string | undefined): BrowserType[] | undefined {\n  if (!browserArg) return undefined;\n  const normalized = browserArg.toLowerCase();\n  if (normalized === 'all') return [BrowserType.CHROME, BrowserType.CHROMIUM];\n  if (normalized === 'detect' || normalized === 'auto') return undefined;\n  const parsed = parseBrowserType(normalized);\n  if (!parsed) {\n    throw new Error(`Invalid browser: ${browserArg}. Use 'chrome', 'chromium', or 'all'`);\n  }\n  return [parsed];\n}\n\nfunction resolveBrowsersToCheck(requested: BrowserType[] | undefined): BrowserType[] {\n  if (requested && requested.length > 0) return requested;\n  const detected = detectInstalledBrowsers();\n  if (detected.length > 0) return detected;\n  return [BrowserType.CHROME, BrowserType.CHROMIUM];\n}\n\n// ============================================================================\n// Windows Registry Check\n// ============================================================================\n\ntype RegistryValueType = 'REG_SZ' | 'REG_EXPAND_SZ';\n\nfunction queryWindowsRegistryDefaultValue(registryKey: string): {\n  value?: string;\n  valueType?: RegistryValueType;\n  error?: string;\n} {\n  try {\n    const output = execFileSync('reg', ['query', registryKey, '/ve'], {\n      encoding: 'utf8',\n      stdio: ['ignore', 'pipe', 'pipe'],\n      timeout: 2500,\n      windowsHide: true,\n    });\n    const lines = output\n      .split(/\\r?\\n/)\n      .map((l) => l.trim())\n      .filter(Boolean);\n    for (const line of lines) {\n      const match = line.match(/\\b(REG_SZ|REG_EXPAND_SZ)\\b\\s+(.*)$/i);\n      if (match?.[2]) {\n        const valueType = match[1].toUpperCase() as RegistryValueType;\n        return { value: match[2].trim(), valueType };\n      }\n    }\n    return { error: 'No REG_SZ/REG_EXPAND_SZ default value found' };\n  } catch (e) {\n    return { error: stringifyError(e) };\n  }\n}\n\n// ============================================================================\n// Fix Attempts\n// ============================================================================\n\nasync function attemptFixes(\n  enabled: boolean,\n  silent: boolean,\n  distDir: string,\n  targetBrowsers: BrowserType[] | undefined,\n): Promise<DoctorFixAttempt[]> {\n  if (!enabled) return [];\n\n  const fixes: DoctorFixAttempt[] = [];\n  const logDir = getLogDir();\n  const nodePathFile = path.join(distDir, 'node_path.txt');\n\n  const withMutedConsole = async <T>(fn: () => Promise<T>): Promise<T> => {\n    if (!silent) return await fn();\n    const originalLog = console.log;\n    const originalInfo = console.info;\n    const originalWarn = console.warn;\n    const originalError = console.error;\n    console.log = () => {};\n    console.info = () => {};\n    console.warn = () => {};\n    console.error = () => {};\n    try {\n      return await fn();\n    } finally {\n      console.log = originalLog;\n      console.info = originalInfo;\n      console.warn = originalWarn;\n      console.error = originalError;\n    }\n  };\n\n  const attempt = async (id: string, description: string, action: () => Promise<void> | void) => {\n    try {\n      await withMutedConsole(async () => {\n        await action();\n      });\n      fixes.push({ id, description, success: true });\n    } catch (e) {\n      fixes.push({ id, description, success: false, error: stringifyError(e) });\n    }\n  };\n\n  await attempt('logs', 'Ensure logs directory exists', async () => {\n    fs.mkdirSync(logDir, { recursive: true });\n  });\n\n  await attempt('node_path', 'Write node_path.txt for run_host scripts', async () => {\n    fs.writeFileSync(nodePathFile, process.execPath, 'utf8');\n  });\n\n  await attempt('permissions', 'Fix execution permissions for native host files', async () => {\n    await ensureExecutionPermissions();\n  });\n\n  await attempt('register', 'Re-register Native Messaging host (user-level)', async () => {\n    const ok = await tryRegisterUserLevelHost(targetBrowsers);\n    if (!ok) {\n      throw new Error('User-level registration failed');\n    }\n  });\n\n  return fixes;\n}\n\n// ============================================================================\n// JSON File Reading\n// ============================================================================\n\nfunction readJsonFile(\n  filePath: string,\n): { ok: true; value: unknown } | { ok: false; error: string } {\n  try {\n    const raw = fs.readFileSync(filePath, 'utf8');\n    return { ok: true, value: JSON.parse(raw) };\n  } catch (e) {\n    return { ok: false, error: stringifyError(e) };\n  }\n}\n\n// ============================================================================\n// Connectivity Check\n// ============================================================================\n\ntype FetchFn = typeof globalThis.fetch;\n\nfunction resolveFetch(): FetchFn | null {\n  if (typeof globalThis.fetch === 'function') {\n    return globalThis.fetch.bind(globalThis) as FetchFn;\n  }\n  try {\n    const mod = require('node-fetch');\n    return (mod.default ?? mod) as FetchFn;\n  } catch {\n    return null;\n  }\n}\n\nasync function checkConnectivity(\n  url: string,\n  timeoutMs: number,\n): Promise<{ ok: boolean; status?: number; error?: string }> {\n  const fetchFn = resolveFetch();\n  if (!fetchFn) {\n    return { ok: false, error: 'fetch is not available (requires Node.js >=18 or node-fetch)' };\n  }\n\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), timeoutMs);\n  // Prevent timeout from keeping the process alive\n  if (typeof timeout.unref === 'function') {\n    timeout.unref();\n  }\n\n  try {\n    const res = await fetchFn(url, { method: 'GET', signal: controller.signal });\n    return { ok: res.ok, status: res.status };\n  } catch (e: unknown) {\n    const errMessage = e instanceof Error ? e.message : String(e);\n    const errName = e instanceof Error ? e.name : '';\n    if (errName === 'AbortError' || errMessage.toLowerCase().includes('abort')) {\n      return { ok: false, error: `Timeout after ${timeoutMs}ms` };\n    }\n    return { ok: false, error: errMessage };\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\n// ============================================================================\n// Summary Computation\n// ============================================================================\n\nfunction computeSummary(checks: DoctorCheckResult[]): { ok: number; warn: number; error: number } {\n  let ok = 0;\n  let warn = 0;\n  let error = 0;\n  for (const check of checks) {\n    if (check.status === 'ok') ok++;\n    else if (check.status === 'warn') warn++;\n    else error++;\n  }\n  return { ok, warn, error };\n}\n\nfunction statusBadge(status: DoctorStatus): string {\n  if (status === 'ok') return colorText('[OK]', 'green');\n  if (status === 'warn') return colorText('[WARN]', 'yellow');\n  return colorText('[ERROR]', 'red');\n}\n\n// ============================================================================\n// Main Doctor Function\n// ============================================================================\n\n/**\n * Collect doctor report without outputting to console.\n * Used by both runDoctor and report command.\n */\nexport async function collectDoctorReport(options: DoctorOptions): Promise<DoctorReport> {\n  const pkg = readPackageJson();\n  const distDir = resolveDistDir();\n  const rootDir = path.resolve(distDir, '..');\n  const packageName = typeof pkg.name === 'string' ? pkg.name : 'mcp-chrome-bridge';\n  const packageVersion = typeof pkg.version === 'string' ? pkg.version : 'unknown';\n  const commandInfo = getCommandInfo(pkg);\n\n  const targetBrowsers = resolveTargetBrowsers(options.browser);\n  const browsersToCheck = resolveBrowsersToCheck(targetBrowsers);\n\n  const wrapperScriptName = process.platform === 'win32' ? 'run_host.bat' : 'run_host.sh';\n  const wrapperPath = path.resolve(distDir, wrapperScriptName);\n  const nodeScriptPath = path.resolve(distDir, 'index.js');\n  const logDir = getLogDir();\n  const stdioConfigPath = path.resolve(distDir, 'mcp', 'stdio-config.json');\n\n  // Run fixes if requested\n  const fixes = await attemptFixes(\n    Boolean(options.fix),\n    Boolean(options.json),\n    distDir,\n    targetBrowsers,\n  );\n\n  const checks: DoctorCheckResult[] = [];\n  const nextSteps: string[] = [];\n\n  // Check 1: Installation info\n  checks.push({\n    id: 'installation',\n    title: 'Installation',\n    status: 'ok',\n    message: `${packageName}@${packageVersion}, ${process.platform}-${process.arch}, node ${process.version}`,\n    details: {\n      packageRoot: rootDir,\n      distDir,\n      execPath: process.execPath,\n      aliases: commandInfo.aliases,\n    },\n  });\n\n  // Check 2: Host files\n  const missingHostFiles: string[] = [];\n  if (!fs.existsSync(wrapperPath)) missingHostFiles.push(wrapperPath);\n  if (!fs.existsSync(nodeScriptPath)) missingHostFiles.push(nodeScriptPath);\n  if (!fs.existsSync(stdioConfigPath)) missingHostFiles.push(stdioConfigPath);\n\n  if (missingHostFiles.length > 0) {\n    checks.push({\n      id: 'host.files',\n      title: 'Host files',\n      status: 'error',\n      message: `Missing required files (${missingHostFiles.length})`,\n      details: { missing: missingHostFiles },\n    });\n    nextSteps.push(`Reinstall: npm install -g ${COMMAND_NAME}`);\n  } else {\n    checks.push({\n      id: 'host.files',\n      title: 'Host files',\n      status: 'ok',\n      message: `Wrapper: ${wrapperPath}`,\n      details: { wrapperPath, nodeScriptPath, stdioConfigPath },\n    });\n  }\n\n  // Check 3: Permissions (Unix only)\n  if (process.platform !== 'win32' && fs.existsSync(wrapperPath)) {\n    const executable = canExecute(wrapperPath);\n    checks.push({\n      id: 'host.permissions',\n      title: 'Host permissions',\n      status: executable ? 'ok' : 'error',\n      message: executable ? 'run_host.sh is executable' : 'run_host.sh is not executable',\n      details: {\n        path: wrapperPath,\n        fix: executable\n          ? undefined\n          : [`${COMMAND_NAME} fix-permissions`, `chmod +x \"${wrapperPath}\"`],\n      },\n    });\n    if (!executable) nextSteps.push(`${COMMAND_NAME} fix-permissions`);\n  } else {\n    checks.push({\n      id: 'host.permissions',\n      title: 'Host permissions',\n      status: 'ok',\n      message: process.platform === 'win32' ? 'Not applicable on Windows' : 'N/A',\n    });\n  }\n\n  // Check 4: Node resolution\n  const nodeResolution = resolveNodeCandidate(distDir);\n  if (nodeResolution.nodePath) {\n    try {\n      nodeResolution.version = execFileSync(nodeResolution.nodePath, ['-v'], {\n        encoding: 'utf8',\n        stdio: ['ignore', 'pipe', 'pipe'],\n        timeout: 2500,\n        windowsHide: true,\n      }).trim();\n    } catch (e) {\n      nodeResolution.versionError = stringifyError(e);\n    }\n  }\n\n  // Parse Node version and check if it meets minimum requirement\n  const nodeMajorVersion = parseNodeMajorVersion(nodeResolution.version || '');\n  const nodeVersionTooOld = nodeMajorVersion !== null && nodeMajorVersion < MIN_NODE_MAJOR_VERSION;\n\n  const nodePathWarn =\n    Boolean(nodeResolution.nodePath) &&\n    (!nodeResolution.nodePathFile.exists || nodeResolution.nodePathFile.valid === false) &&\n    !process.env.CHROME_MCP_NODE_PATH;\n\n  // Determine node check status: error if not found or version too old, warn if path issue\n  let nodeStatus: DoctorStatus = 'ok';\n  let nodeMessage: string;\n  let nodeFix: string[] | undefined;\n\n  if (!nodeResolution.nodePath) {\n    nodeStatus = 'error';\n    nodeMessage = 'Node.js executable not found by wrapper search order';\n    nodeFix = [\n      `${COMMAND_NAME} doctor --fix`,\n      `Or set CHROME_MCP_NODE_PATH to an absolute node path`,\n    ];\n    nextSteps.push(`${COMMAND_NAME} doctor --fix`);\n  } else if (nodeResolution.versionError) {\n    nodeStatus = 'error';\n    nodeMessage = `Found ${nodeResolution.source}: ${nodeResolution.nodePath} but failed to run \"node -v\" (${nodeResolution.versionError})`;\n    nodeFix = [\n      `Verify the executable: \"${nodeResolution.nodePath}\" -v`,\n      `Reinstall/repair Node.js`,\n    ];\n    nextSteps.push(`Verify Node.js: \"${nodeResolution.nodePath}\" -v`);\n  } else if (nodeVersionTooOld) {\n    nodeStatus = 'error';\n    nodeMessage = `Node.js ${nodeResolution.version} is too old (requires >= ${MIN_NODE_MAJOR_VERSION}.0.0)`;\n    nodeFix = [`Upgrade Node.js to version ${MIN_NODE_MAJOR_VERSION} or higher`];\n    nextSteps.push(`Upgrade Node.js to version ${MIN_NODE_MAJOR_VERSION}+`);\n  } else if (nodePathWarn) {\n    nodeStatus = 'warn';\n    nodeMessage = `Using ${nodeResolution.source}: ${nodeResolution.nodePath}${nodeResolution.version ? ` (${nodeResolution.version})` : ''}`;\n    nodeFix = [\n      `${COMMAND_NAME} doctor --fix`,\n      `Or set CHROME_MCP_NODE_PATH to an absolute node path`,\n    ];\n  } else {\n    nodeStatus = 'ok';\n    nodeMessage = `Using ${nodeResolution.source}: ${nodeResolution.nodePath}${nodeResolution.version ? ` (${nodeResolution.version})` : ''}`;\n  }\n\n  checks.push({\n    id: 'node',\n    title: 'Node executable',\n    status: nodeStatus,\n    message: nodeMessage,\n    details: {\n      resolved: nodeResolution.nodePath\n        ? {\n            source: nodeResolution.source,\n            path: nodeResolution.nodePath,\n            version: nodeResolution.version,\n            versionError: nodeResolution.versionError,\n            majorVersion: nodeMajorVersion,\n          }\n        : undefined,\n      nodePathFile: nodeResolution.nodePathFile,\n      minRequired: `>=${MIN_NODE_MAJOR_VERSION}.0.0`,\n      fix: nodeFix,\n    },\n  });\n\n  // Check 5: Manifest checks per browser\n  const expectedOrigin = `chrome-extension://${EXTENSION_ID}/`;\n  for (const browser of browsersToCheck) {\n    const config = getBrowserConfig(browser);\n    const candidates = [config.userManifestPath, config.systemManifestPath];\n    const found = candidates.find((p) => fs.existsSync(p));\n\n    if (!found) {\n      checks.push({\n        id: `manifest.${browser}`,\n        title: `${config.displayName} manifest`,\n        status: 'error',\n        message: 'Manifest not found',\n        details: {\n          expected: candidates,\n          fix: [\n            `${COMMAND_NAME} register --browser ${browser}`,\n            `${COMMAND_NAME} register --detect`,\n          ],\n        },\n      });\n      nextSteps.push(`${COMMAND_NAME} register --detect`);\n      continue;\n    }\n\n    const parsed = readJsonFile(found);\n    if (!parsed.ok) {\n      checks.push({\n        id: `manifest.${browser}`,\n        title: `${config.displayName} manifest`,\n        status: 'error',\n        message: `Failed to parse manifest: ${parsed.error}`,\n        details: { path: found, fix: [`${COMMAND_NAME} register --browser ${browser}`] },\n      });\n      nextSteps.push(`${COMMAND_NAME} register --browser ${browser}`);\n      continue;\n    }\n\n    const manifest = parsed.value as Record<string, unknown>;\n    const issues: string[] = [];\n    if (manifest.name !== HOST_NAME) issues.push(`name != ${HOST_NAME}`);\n    if (manifest.type !== 'stdio') issues.push(`type != stdio`);\n    if (typeof manifest.path !== 'string') issues.push('path is missing');\n    if (typeof manifest.path === 'string') {\n      const actual = normalizeComparablePath(manifest.path);\n      const expected = normalizeComparablePath(wrapperPath);\n      if (actual !== expected) issues.push('path does not match installed wrapper');\n      if (!fs.existsSync(manifest.path)) issues.push('path target does not exist');\n    }\n    const allowedOrigins = manifest.allowed_origins;\n    if (!Array.isArray(allowedOrigins) || !allowedOrigins.includes(expectedOrigin)) {\n      issues.push(`allowed_origins missing ${expectedOrigin}`);\n    }\n\n    checks.push({\n      id: `manifest.${browser}`,\n      title: `${config.displayName} manifest`,\n      status: issues.length === 0 ? 'ok' : 'error',\n      message: issues.length === 0 ? found : `Invalid manifest (${issues.join('; ')})`,\n      details: {\n        path: found,\n        expectedWrapperPath: wrapperPath,\n        expectedOrigin,\n        fix: issues.length === 0 ? undefined : [`${COMMAND_NAME} register --browser ${browser}`],\n      },\n    });\n    if (issues.length > 0) nextSteps.push(`${COMMAND_NAME} register --browser ${browser}`);\n  }\n\n  // Check 6: Windows registry (Windows only)\n  if (process.platform === 'win32') {\n    for (const browser of browsersToCheck) {\n      const config = getBrowserConfig(browser);\n      const keySpecs = [\n        config.registryKey ? { key: config.registryKey, expected: config.userManifestPath } : null,\n        config.systemRegistryKey\n          ? { key: config.systemRegistryKey, expected: config.systemManifestPath }\n          : null,\n      ].filter(Boolean) as Array<{ key: string; expected: string }>;\n      if (keySpecs.length === 0) continue;\n\n      let anyValue = false;\n      let anyExistingTarget = false;\n      let anyMissingTarget = false;\n      let anyMismatch = false;\n\n      const results: Array<{\n        key: string;\n        expected: string;\n        value?: string;\n        valueType?: string;\n        expandedValue?: string;\n        exists?: boolean;\n        matchesExpected?: boolean;\n        error?: string;\n      }> = [];\n\n      for (const spec of keySpecs) {\n        const res = queryWindowsRegistryDefaultValue(spec.key);\n        if (!res.value) {\n          results.push({ key: spec.key, expected: spec.expected, error: res.error });\n          continue;\n        }\n\n        anyValue = true;\n        // Expand environment variables for REG_EXPAND_SZ values\n        const expandedValue = expandWindowsEnvVars(stripOuterQuotes(res.value));\n        const exists = fs.existsSync(expandedValue);\n        const matchesExpected =\n          normalizeComparablePath(expandedValue) === normalizeComparablePath(spec.expected);\n\n        if (exists) {\n          anyExistingTarget = true;\n          if (!matchesExpected) anyMismatch = true;\n        } else {\n          anyMissingTarget = true;\n        }\n\n        results.push({\n          key: spec.key,\n          expected: spec.expected,\n          value: res.value,\n          valueType: res.valueType,\n          expandedValue: expandedValue !== res.value ? expandedValue : undefined,\n          exists,\n          matchesExpected,\n        });\n      }\n\n      let status: DoctorStatus = 'error';\n      let message = 'Registry entry not found';\n      if (!anyValue) {\n        status = 'error';\n        message = 'Registry entry not found';\n      } else if (!anyExistingTarget) {\n        status = 'error';\n        message = 'Registry entry points to missing manifest';\n      } else if (anyMissingTarget || anyMismatch) {\n        status = 'warn';\n        message = 'Registry entry found but inconsistent';\n      } else {\n        status = 'ok';\n        message = 'Registry entry points to manifest';\n      }\n\n      checks.push({\n        id: `registry.${browser}`,\n        title: `${config.displayName} registry`,\n        status,\n        message,\n        details: {\n          keys: keySpecs.map((s) => s.key),\n          results,\n          fix: status === 'ok' ? undefined : [`${COMMAND_NAME} register --browser ${browser}`],\n        },\n      });\n      if (status !== 'ok') nextSteps.push(`${COMMAND_NAME} register --browser ${browser}`);\n    }\n  }\n\n  // Check 7: Port configuration\n  if (fs.existsSync(stdioConfigPath)) {\n    const cfg = readJsonFile(stdioConfigPath);\n    if (!cfg.ok) {\n      checks.push({\n        id: 'port.config',\n        title: 'Port config',\n        status: 'error',\n        message: `Failed to parse stdio-config.json: ${cfg.error}`,\n      });\n    } else {\n      try {\n        const configValue = cfg.value as Record<string, unknown>;\n        const url = new URL(configValue.url as string);\n        const port = Number(url.port);\n        const portOk = port === EXPECTED_PORT;\n        checks.push({\n          id: 'port.config',\n          title: 'Port config',\n          status: portOk ? 'ok' : 'error',\n          message: configValue.url as string,\n          details: {\n            expectedPort: EXPECTED_PORT,\n            actualPort: port,\n            fix: portOk ? undefined : [`${COMMAND_NAME} update-port ${EXPECTED_PORT}`],\n          },\n        });\n        if (!portOk) nextSteps.push(`${COMMAND_NAME} update-port ${EXPECTED_PORT}`);\n\n        // Check constant consistency\n        const nativePortOk = NATIVE_SERVER_PORT === EXPECTED_PORT;\n        checks.push({\n          id: 'port.constant',\n          title: 'Port constant',\n          status: nativePortOk ? 'ok' : 'warn',\n          message: `NATIVE_SERVER_PORT=${NATIVE_SERVER_PORT}`,\n          details: { expectedPort: EXPECTED_PORT },\n        });\n\n        // Connectivity check\n        const pingUrl = new URL('/ping', url);\n        const ping = await checkConnectivity(pingUrl.toString(), 1500);\n        checks.push({\n          id: 'connectivity',\n          title: 'Connectivity',\n          status: ping.ok ? 'ok' : 'warn',\n          message: ping.ok\n            ? `GET ${pingUrl} -> ${ping.status}`\n            : `GET ${pingUrl} failed (${ping.error || 'unknown error'})`,\n          details: {\n            hint: 'If the server is not running, click \"Connect\" in the extension and retry.',\n          },\n        });\n        if (!ping.ok) nextSteps.push('Click \"Connect\" in the extension, then re-run doctor');\n      } catch (e) {\n        checks.push({\n          id: 'port.config',\n          title: 'Port config',\n          status: 'error',\n          message: `Invalid URL in stdio-config.json: ${stringifyError(e)}`,\n        });\n      }\n    }\n  }\n\n  // Check 8: Logs directory\n  checks.push({\n    id: 'logs',\n    title: 'Logs',\n    status: fs.existsSync(logDir) ? 'ok' : 'warn',\n    message: logDir,\n    details: {\n      hint: 'Wrapper logs are created when Chrome launches the native host.',\n    },\n  });\n\n  // Compute summary\n  const summary = computeSummary(checks);\n  const ok = summary.error === 0;\n\n  const report: DoctorReport = {\n    schemaVersion: SCHEMA_VERSION,\n    timestamp: new Date().toISOString(),\n    ok,\n    summary,\n    environment: {\n      platform: process.platform,\n      arch: process.arch,\n      node: { version: process.version, execPath: process.execPath },\n      package: { name: packageName, version: packageVersion, rootDir, distDir },\n      command: { canonical: commandInfo.canonical, aliases: commandInfo.aliases },\n      nativeHost: { hostName: HOST_NAME, expectedPort: EXPECTED_PORT },\n    },\n    fixes,\n    checks,\n    nextSteps: Array.from(new Set(nextSteps)).slice(0, 10),\n  };\n\n  return report;\n}\n\n/**\n * Run doctor command with console output.\n */\nexport async function runDoctor(options: DoctorOptions): Promise<number> {\n  const report = await collectDoctorReport(options);\n  const packageVersion = report.environment.package.version;\n\n  // Output\n  if (options.json) {\n    process.stdout.write(JSON.stringify(report, null, 2) + '\\n');\n  } else {\n    console.log(`${COMMAND_NAME} doctor v${packageVersion}\\n`);\n    for (const check of report.checks) {\n      console.log(`${statusBadge(check.status)}    ${check.title}: ${check.message}`);\n      const fix = (check.details as Record<string, unknown> | undefined)?.fix as\n        | string[]\n        | undefined;\n      if (check.status !== 'ok' && fix && fix.length > 0) {\n        console.log(`        Fix: ${fix[0]}`);\n      }\n    }\n    if (report.fixes.length > 0) {\n      console.log('\\nFix attempts:');\n      for (const f of report.fixes) {\n        const badge = f.success ? colorText('[OK]', 'green') : colorText('[ERROR]', 'red');\n        console.log(`${badge} ${f.description}${f.success ? '' : ` (${f.error})`}`);\n      }\n    }\n    if (report.nextSteps.length > 0) {\n      console.log('\\nNext steps:');\n      report.nextSteps.forEach((s, i) => console.log(`  ${i + 1}. ${s}`));\n    }\n  }\n\n  return report.ok ? 0 : 1;\n}\n"
  },
  {
    "path": "app/native-server/src/scripts/postinstall.ts",
    "content": "#!/usr/bin/env node\n\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { COMMAND_NAME } from './constant';\nimport { colorText, tryRegisterUserLevelHost, writeNodePathFile } from './utils';\n\n// Check if this script is run directly\nconst isDirectRun = require.main === module;\n\n// Detect global installation for both npm and pnpm\nfunction detectGlobalInstall(): boolean {\n  // npm uses npm_config_global\n  if (process.env.npm_config_global === 'true') {\n    return true;\n  }\n\n  // pnpm detection methods\n  // Method 1: Check if PNPM_HOME is set and current path contains it\n  if (process.env.PNPM_HOME && __dirname.includes(process.env.PNPM_HOME)) {\n    return true;\n  }\n\n  // Method 2: Check if we're in a global pnpm directory structure\n  // pnpm global packages are typically installed in ~/.local/share/pnpm/global/5/node_modules\n  // Windows: %APPDATA%\\pnpm\\global\\5\\node_modules\n  const globalPnpmPatterns =\n    process.platform === 'win32'\n      ? ['\\\\pnpm\\\\global\\\\', '\\\\pnpm-global\\\\', '\\\\AppData\\\\Roaming\\\\pnpm\\\\']\n      : ['/pnpm/global/', '/.local/share/pnpm/', '/pnpm-global/'];\n\n  if (globalPnpmPatterns.some((pattern) => __dirname.includes(pattern))) {\n    return true;\n  }\n\n  // Method 3: Check npm_config_prefix for pnpm\n  if (process.env.npm_config_prefix && __dirname.includes(process.env.npm_config_prefix)) {\n    return true;\n  }\n\n  // Method 4: Windows-specific global installation paths\n  if (process.platform === 'win32') {\n    const windowsGlobalPatterns = [\n      '\\\\npm\\\\node_modules\\\\',\n      '\\\\AppData\\\\Roaming\\\\npm\\\\node_modules\\\\',\n      '\\\\Program Files\\\\nodejs\\\\node_modules\\\\',\n      '\\\\nodejs\\\\node_modules\\\\',\n    ];\n\n    if (windowsGlobalPatterns.some((pattern) => __dirname.includes(pattern))) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\nconst isGlobalInstall = detectGlobalInstall();\n\n/**\n * Detect if running with elevated privileges (sudo/admin)\n * This can cause issues because user-level registration will go to root's home directory\n */\nfunction isRunningElevated(): boolean {\n  if (process.platform === 'win32') {\n    // On Windows, check common admin indicators\n    // Note: Full admin check requires is-admin package which is ESM\n    return false; // Skip for now, Windows npm usually doesn't run as admin by default\n  } else {\n    // On Unix, check if running as root (UID 0)\n    return process.getuid?.() === 0;\n  }\n}\n\n/**\n * 确保执行权限（无论是否为全局安装）\n */\nasync function ensureExecutionPermissions(): Promise<void> {\n  if (process.platform === 'win32') {\n    // Windows 平台处理\n    await ensureWindowsFilePermissions();\n    return;\n  }\n\n  // Unix/Linux 平台处理\n  const filesToCheck = [\n    path.join(__dirname, '..', 'index.js'),\n    path.join(__dirname, '..', 'run_host.sh'),\n    path.join(__dirname, '..', 'cli.js'),\n  ];\n\n  for (const filePath of filesToCheck) {\n    if (fs.existsSync(filePath)) {\n      try {\n        fs.chmodSync(filePath, '755');\n        console.log(\n          colorText(`✓ Set execution permissions for ${path.basename(filePath)}`, 'green'),\n        );\n      } catch (err: any) {\n        console.warn(\n          colorText(\n            `⚠️ Unable to set execution permissions for ${path.basename(filePath)}: ${err.message}`,\n            'yellow',\n          ),\n        );\n      }\n    } else {\n      console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));\n    }\n  }\n}\n\n/**\n * Windows 平台文件权限处理\n */\nasync function ensureWindowsFilePermissions(): Promise<void> {\n  const filesToCheck = [\n    path.join(__dirname, '..', 'index.js'),\n    path.join(__dirname, '..', 'run_host.bat'),\n    path.join(__dirname, '..', 'cli.js'),\n  ];\n\n  for (const filePath of filesToCheck) {\n    if (fs.existsSync(filePath)) {\n      try {\n        // 检查文件是否为只读，如果是则移除只读属性\n        const stats = fs.statSync(filePath);\n        if (!(stats.mode & parseInt('200', 8))) {\n          // 检查写权限\n          // 尝试移除只读属性\n          fs.chmodSync(filePath, stats.mode | parseInt('200', 8));\n          console.log(\n            colorText(`✓ Removed read-only attribute from ${path.basename(filePath)}`, 'green'),\n          );\n        }\n\n        // 验证文件可读性\n        fs.accessSync(filePath, fs.constants.R_OK);\n        console.log(\n          colorText(`✓ Verified file accessibility for ${path.basename(filePath)}`, 'green'),\n        );\n      } catch (err: any) {\n        console.warn(\n          colorText(\n            `⚠️ Unable to verify file permissions for ${path.basename(filePath)}: ${err.message}`,\n            'yellow',\n          ),\n        );\n      }\n    } else {\n      console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));\n    }\n  }\n}\n\nasync function tryRegisterNativeHost(): Promise<void> {\n  try {\n    console.log(colorText('Attempting to register Chrome Native Messaging host...', 'blue'));\n\n    // Always ensure execution permissions, regardless of installation type\n    await ensureExecutionPermissions();\n\n    // Check if running with elevated privileges\n    if (isRunningElevated()) {\n      console.log(\n        colorText('\\n⚠️  WARNING: Running with elevated privileges (sudo/root)', 'yellow'),\n      );\n      console.log(\n        colorText(\"   User-level registration will be written to root's home directory,\", 'yellow'),\n      );\n      console.log(\n        colorText('   which may not work correctly for your normal user account.', 'yellow'),\n      );\n      console.log(\n        colorText(\n          '\\n   Please run the following command as your normal user after installation:',\n          'blue',\n        ),\n      );\n      console.log(`   ${COMMAND_NAME} register`);\n      console.log(colorText('\\n   Or if you need system-level installation, use:', 'blue'));\n      console.log(`   sudo ${COMMAND_NAME} register --system`);\n      // Skip automatic registration when running as root\n      return;\n    }\n\n    if (isGlobalInstall) {\n      // First try user-level installation (no elevated permissions required)\n      const userLevelSuccess = await tryRegisterUserLevelHost();\n\n      if (!userLevelSuccess) {\n        // User-level installation failed, suggest using register command\n        console.log(\n          colorText(\n            'User-level installation failed, system-level installation may be needed',\n            'yellow',\n          ),\n        );\n        console.log(\n          colorText('Please run the following command for system-level installation:', 'blue'),\n        );\n        console.log(`  ${COMMAND_NAME} register --system`);\n        printManualInstructions();\n      }\n    } else {\n      // Local installation mode, don't attempt automatic registration\n      console.log(\n        colorText('Local installation detected, skipping automatic registration', 'yellow'),\n      );\n      printManualInstructions();\n    }\n  } catch (error) {\n    console.log(\n      colorText(\n        `注册过程中出现错误: ${error instanceof Error ? error.message : String(error)}`,\n        'red',\n      ),\n    );\n    printManualInstructions();\n  }\n}\n\n/**\n * 打印手动安装指南\n */\nfunction printManualInstructions(): void {\n  console.log('\\n' + colorText('===== Manual Registration Guide =====', 'blue'));\n\n  console.log(colorText('1. Try user-level installation (recommended):', 'yellow'));\n  if (isGlobalInstall) {\n    console.log(`  ${COMMAND_NAME} register`);\n  } else {\n    console.log(`  npx ${COMMAND_NAME} register`);\n  }\n\n  console.log(\n    colorText('\\n2. If user-level installation fails, try system-level installation:', 'yellow'),\n  );\n\n  console.log(colorText('   Use --system parameter (requires admin privileges):', 'yellow'));\n  if (isGlobalInstall) {\n    console.log(`  ${COMMAND_NAME} register --system`);\n  } else {\n    console.log(`  npx ${COMMAND_NAME} register --system`);\n  }\n\n  console.log(colorText('\\n   Or use administrator privileges directly:', 'yellow'));\n  if (os.platform() === 'win32') {\n    console.log(\n      colorText(\n        '   Please run Command Prompt or PowerShell as administrator and execute:',\n        'yellow',\n      ),\n    );\n    if (isGlobalInstall) {\n      console.log(`  ${COMMAND_NAME} register`);\n    } else {\n      console.log(`  npx ${COMMAND_NAME} register`);\n    }\n  } else {\n    console.log(colorText('   Please run the following command in terminal:', 'yellow'));\n    if (isGlobalInstall) {\n      console.log(`  sudo ${COMMAND_NAME} register`);\n    } else {\n      console.log(`  sudo npx ${COMMAND_NAME} register`);\n    }\n  }\n\n  console.log(\n    '\\n' +\n      colorText(\n        'Ensure Chrome extension is installed and refresh the extension to connect to local service.',\n        'blue',\n      ),\n  );\n}\n\n/**\n * 主函数\n */\nasync function main(): Promise<void> {\n  console.log(colorText(`Installing ${COMMAND_NAME}...`, 'green'));\n\n  // Debug information\n  console.log(colorText('Installation environment debug info:', 'blue'));\n  console.log(`  __dirname: ${__dirname}`);\n  console.log(`  npm_config_global: ${process.env.npm_config_global}`);\n  console.log(`  PNPM_HOME: ${process.env.PNPM_HOME}`);\n  console.log(`  npm_config_prefix: ${process.env.npm_config_prefix}`);\n  console.log(`  isGlobalInstall: ${isGlobalInstall}`);\n\n  // Always ensure execution permissions first\n  await ensureExecutionPermissions();\n\n  // Write Node.js path for run_host scripts to use\n  writeNodePathFile(path.join(__dirname, '..'));\n\n  // If global installation, try automatic registration\n  if (isGlobalInstall) {\n    await tryRegisterNativeHost();\n  } else {\n    console.log(colorText('Local installation detected', 'yellow'));\n    printManualInstructions();\n  }\n}\n\n// Only execute main function when running this script directly\nif (isDirectRun) {\n  main().catch((error) => {\n    console.error(\n      colorText(\n        `Installation script error: ${error instanceof Error ? error.message : String(error)}`,\n        'red',\n      ),\n    );\n    // Set non-zero exit code to indicate installation failure\n    process.exitCode = 1;\n  });\n}\n"
  },
  {
    "path": "app/native-server/src/scripts/register-dev.ts",
    "content": "import { registerUserLevelHostWithNodePath } from './utils';\n\nregisterUserLevelHostWithNodePath();\n"
  },
  {
    "path": "app/native-server/src/scripts/register.ts",
    "content": "#!/usr/bin/env node\nimport path from 'path';\nimport { COMMAND_NAME } from './constant';\nimport { colorText, registerWithElevatedPermissions, writeNodePathFile } from './utils';\n\n/**\n * 主函数\n */\nasync function main(): Promise<void> {\n  console.log(colorText(`正在注册 ${COMMAND_NAME} Native Messaging主机...`, 'blue'));\n\n  try {\n    // Write Node.js path before registration\n    writeNodePathFile(path.join(__dirname, '..'));\n\n    await registerWithElevatedPermissions();\n    console.log(\n      colorText('注册成功！现在Chrome扩展可以通过Native Messaging与本地服务通信。', 'green'),\n    );\n  } catch (error: any) {\n    console.error(colorText(`注册失败: ${error.message}`, 'red'));\n    process.exit(1);\n  }\n}\n\n// 执行主函数\nmain();\n"
  },
  {
    "path": "app/native-server/src/scripts/report.ts",
    "content": "#!/usr/bin/env node\n\n/**\n * report.ts\n *\n * Export a diagnostic report for GitHub Issues.\n * Collects system info, doctor output, logs, manifests, and registry info.\n */\n\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { execFileSync, spawnSync } from 'child_process';\nimport { COMMAND_NAME } from './constant';\nimport {\n  BrowserType,\n  detectInstalledBrowsers,\n  getBrowserConfig,\n  parseBrowserType,\n} from './browser-config';\nimport { getLogDir } from './utils';\nimport { collectDoctorReport, DoctorReport } from './doctor';\n\nconst REPORT_SCHEMA_VERSION = 1;\nconst DEFAULT_LOG_LINES = 200;\nconst DEFAULT_TAIL_BYTES = 256 * 1024;\nconst MAX_LOG_FILES = 6;\nconst MAX_FULL_LOG_BYTES = 1024 * 1024;\n\ntype IncludeLogsMode = 'none' | 'tail' | 'full';\n\nexport interface ReportOptions {\n  json?: boolean;\n  output?: string;\n  copy?: boolean;\n  redact?: boolean;\n  includeLogs?: string;\n  logLines?: number;\n  browser?: string;\n}\n\ninterface VersionResult {\n  version?: string;\n  error?: string;\n}\n\ninterface ManifestSnapshot {\n  browser: string;\n  scope: 'user' | 'system';\n  path: string;\n  exists: boolean;\n  json?: unknown;\n  raw?: string;\n  error?: string;\n}\n\ninterface LogFileSnapshot {\n  name: string;\n  path: string;\n  mtime?: string;\n  size?: number;\n  note?: string;\n  content?: string;\n  truncated?: boolean;\n  error?: string;\n}\n\ninterface WrapperLogsSnapshot {\n  dir: string;\n  mode: IncludeLogsMode;\n  files: LogFileSnapshot[];\n  error?: string;\n}\n\ninterface WindowsRegistryEntrySnapshot {\n  browser: string;\n  scope: 'user' | 'system';\n  key: string;\n  expectedManifestPath: string;\n  value?: string;\n  raw?: string;\n  error?: string;\n}\n\ninterface WindowsRegistrySnapshot {\n  entries: WindowsRegistryEntrySnapshot[];\n}\n\nexport interface DiagnosticReport {\n  schemaVersion: number;\n  timestamp: string;\n  tool: {\n    name: string;\n    version: string;\n  };\n  environment: {\n    platform: NodeJS.Platform;\n    arch: string;\n    node: {\n      version: string;\n      execPath: string;\n    };\n    os: {\n      type: string;\n      release: string;\n      version?: string;\n    };\n    cwd: string;\n    env: Record<string, string | null>;\n  };\n  packageManager: {\n    npm: VersionResult;\n    pnpm: VersionResult;\n  };\n  doctor?: DoctorReport;\n  doctorError?: string;\n  manifests: ManifestSnapshot[];\n  wrapperLogs: WrapperLogsSnapshot;\n  windowsRegistry?: WindowsRegistrySnapshot;\n  redaction: {\n    enabled: boolean;\n  };\n}\n\nfunction stringifyError(err: unknown): string {\n  if (err instanceof Error) return err.message;\n  return String(err);\n}\n\nfunction readPackageJson(): Record<string, unknown> {\n  try {\n    return require('../../package.json') as Record<string, unknown>;\n  } catch {\n    return {};\n  }\n}\n\nfunction getToolVersion(): { name: string; version: string } {\n  const pkg = readPackageJson();\n  const name = typeof pkg.name === 'string' ? pkg.name : COMMAND_NAME;\n  const version = typeof pkg.version === 'string' ? pkg.version : 'unknown';\n  return { name, version };\n}\n\nfunction safeOsVersion(): string | undefined {\n  try {\n    return os.version();\n  } catch {\n    return undefined;\n  }\n}\n\nfunction safeExecVersion(command: string): VersionResult {\n  try {\n    const out = execFileSync(command, ['-v'], {\n      encoding: 'utf8',\n      stdio: ['ignore', 'pipe', 'pipe'],\n      timeout: 2500,\n      windowsHide: true,\n    });\n    return { version: out.trim() };\n  } catch (e) {\n    return { error: stringifyError(e) };\n  }\n}\n\nfunction parseIncludeLogsMode(raw: unknown): IncludeLogsMode {\n  const v = typeof raw === 'string' ? raw.toLowerCase() : '';\n  if (v === 'none' || v === 'tail' || v === 'full') return v;\n  return 'tail';\n}\n\nfunction parsePositiveInt(raw: unknown, fallback: number): number {\n  if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.floor(raw);\n  if (typeof raw === 'string') {\n    const parsed = Number.parseInt(raw, 10);\n    if (Number.isFinite(parsed) && parsed > 0) return parsed;\n  }\n  return fallback;\n}\n\nfunction resolveBrowsers(browserArg: string | undefined): BrowserType[] {\n  if (!browserArg) {\n    const detected = detectInstalledBrowsers();\n    return detected.length > 0 ? detected : [BrowserType.CHROME, BrowserType.CHROMIUM];\n  }\n\n  const normalized = browserArg.toLowerCase();\n  if (normalized === 'all') return [BrowserType.CHROME, BrowserType.CHROMIUM];\n  if (normalized === 'detect' || normalized === 'auto') {\n    const detected = detectInstalledBrowsers();\n    return detected.length > 0 ? detected : [BrowserType.CHROME, BrowserType.CHROMIUM];\n  }\n\n  const parsed = parseBrowserType(normalized);\n  if (!parsed) {\n    throw new Error(`Invalid browser: ${browserArg}. Use 'chrome', 'chromium', or 'all'`);\n  }\n  return [parsed];\n}\n\nfunction readJsonSnapshot(filePath: string): {\n  exists: boolean;\n  json?: unknown;\n  raw?: string;\n  error?: string;\n} {\n  try {\n    if (!fs.existsSync(filePath)) return { exists: false };\n    const raw = fs.readFileSync(filePath, 'utf8');\n    try {\n      const json = JSON.parse(raw) as unknown;\n      return { exists: true, json };\n    } catch (e) {\n      return { exists: true, raw, error: `Failed to parse JSON: ${stringifyError(e)}` };\n    }\n  } catch (e) {\n    return { exists: fs.existsSync(filePath), error: stringifyError(e) };\n  }\n}\n\nfunction collectManifests(browsers: BrowserType[]): ManifestSnapshot[] {\n  const results: ManifestSnapshot[] = [];\n  for (const browser of browsers) {\n    const config = getBrowserConfig(browser);\n    for (const scope of ['user', 'system'] as const) {\n      const manifestPath = scope === 'user' ? config.userManifestPath : config.systemManifestPath;\n      const snap = readJsonSnapshot(manifestPath);\n      results.push({\n        browser,\n        scope,\n        path: manifestPath,\n        exists: snap.exists,\n        json: snap.json,\n        raw: snap.raw,\n        error: snap.error,\n      });\n    }\n  }\n  return results;\n}\n\nfunction readFileTail(\n  filePath: string,\n  maxBytes: number,\n  maxLines: number,\n): { content: string; truncated: boolean } {\n  const stat = fs.statSync(filePath);\n  const size = stat.size;\n  const bytesToRead = Math.min(size, maxBytes);\n  const start = Math.max(0, size - bytesToRead);\n\n  const fd = fs.openSync(filePath, 'r');\n  try {\n    const buf = Buffer.alloc(bytesToRead);\n    fs.readSync(fd, buf, 0, bytesToRead, start);\n    const text = buf.toString('utf8');\n    const lines = text.split(/\\r?\\n/);\n    const tail = lines.slice(Math.max(0, lines.length - maxLines));\n    return { content: tail.join('\\n'), truncated: size > maxBytes || lines.length > maxLines };\n  } finally {\n    fs.closeSync(fd);\n  }\n}\n\nfunction readFileLastBytes(\n  filePath: string,\n  maxBytes: number,\n): { content: string; truncated: boolean } {\n  const stat = fs.statSync(filePath);\n  const size = stat.size;\n  if (size <= maxBytes) {\n    const content = fs.readFileSync(filePath, 'utf8');\n    return { content, truncated: false };\n  }\n\n  const bytesToRead = maxBytes;\n  const start = Math.max(0, size - bytesToRead);\n\n  const fd = fs.openSync(filePath, 'r');\n  try {\n    const buf = Buffer.alloc(bytesToRead);\n    fs.readSync(fd, buf, 0, bytesToRead, start);\n    const content = buf.toString('utf8');\n    return { content, truncated: true };\n  } finally {\n    fs.closeSync(fd);\n  }\n}\n\nfunction collectWrapperLogs(\n  logDir: string,\n  mode: IncludeLogsMode,\n  logLines: number,\n): WrapperLogsSnapshot {\n  if (!fs.existsSync(logDir)) {\n    return { dir: logDir, mode, files: [], error: 'Log directory does not exist' };\n  }\n\n  const prefixes = ['native_host_wrapper_', 'native_host_stderr_'];\n  let entries: fs.Dirent[] = [];\n  try {\n    entries = fs.readdirSync(logDir, { withFileTypes: true });\n  } catch (e) {\n    return { dir: logDir, mode, files: [], error: stringifyError(e) };\n  }\n\n  const candidates = entries\n    .filter((ent) => ent.isFile())\n    .map((ent) => ent.name)\n    .filter((name) => name.endsWith('.log') && prefixes.some((p) => name.startsWith(p)));\n\n  const filesWithStat: Array<{ name: string; fullPath: string; mtimeMs: number; size: number }> =\n    [];\n  for (const name of candidates) {\n    const fullPath = path.join(logDir, name);\n    try {\n      const stat = fs.statSync(fullPath);\n      filesWithStat.push({ name, fullPath, mtimeMs: stat.mtimeMs, size: stat.size });\n    } catch {\n      // ignore\n    }\n  }\n\n  filesWithStat.sort((a, b) => b.mtimeMs - a.mtimeMs);\n\n  const selected = filesWithStat.slice(0, MAX_LOG_FILES);\n  const snapshots: LogFileSnapshot[] = [];\n\n  for (const file of selected) {\n    const snap: LogFileSnapshot = {\n      name: file.name,\n      path: file.fullPath,\n      mtime: new Date(file.mtimeMs).toISOString(),\n      size: file.size,\n    };\n\n    if (mode !== 'none') {\n      try {\n        if (mode === 'tail') {\n          const read = readFileTail(file.fullPath, DEFAULT_TAIL_BYTES, logLines);\n          snap.content = read.content;\n          snap.truncated = read.truncated;\n          snap.note = `Tail: last ${logLines} lines (from last ${DEFAULT_TAIL_BYTES} bytes)`;\n        } else {\n          const read = readFileLastBytes(file.fullPath, MAX_FULL_LOG_BYTES);\n          snap.content = read.content;\n          snap.truncated = read.truncated;\n          snap.note = read.truncated\n            ? `Truncated: showing last ${MAX_FULL_LOG_BYTES} bytes`\n            : 'Full file';\n        }\n      } catch (e) {\n        snap.error = stringifyError(e);\n      }\n    } else {\n      snap.note = 'Content omitted';\n    }\n\n    snapshots.push(snap);\n  }\n\n  return { dir: logDir, mode, files: snapshots };\n}\n\nfunction queryWindowsRegistryDefaultValue(registryKey: string): {\n  value?: string;\n  raw?: string;\n  error?: string;\n} {\n  try {\n    const output = execFileSync('reg', ['query', registryKey, '/ve'], {\n      encoding: 'utf8',\n      stdio: ['ignore', 'pipe', 'pipe'],\n      timeout: 2500,\n      windowsHide: true,\n    });\n    const lines = output\n      .split(/\\r?\\n/)\n      .map((l) => l.trim())\n      .filter(Boolean);\n    for (const line of lines) {\n      const match = line.match(/REG_SZ\\s+(.*)$/i);\n      if (match?.[1]) return { value: match[1].trim(), raw: output };\n    }\n    return { raw: output, error: 'No REG_SZ default value found' };\n  } catch (e) {\n    return { error: stringifyError(e) };\n  }\n}\n\nfunction collectWindowsRegistry(browsers: BrowserType[]): WindowsRegistrySnapshot {\n  const entries: WindowsRegistryEntrySnapshot[] = [];\n\n  for (const browser of browsers) {\n    const config = getBrowserConfig(browser);\n    const keySpecs = [\n      config.registryKey\n        ? { key: config.registryKey, scope: 'user' as const, expected: config.userManifestPath }\n        : null,\n      config.systemRegistryKey\n        ? {\n            key: config.systemRegistryKey,\n            scope: 'system' as const,\n            expected: config.systemManifestPath,\n          }\n        : null,\n    ].filter(Boolean) as Array<{ key: string; scope: 'user' | 'system'; expected: string }>;\n\n    for (const spec of keySpecs) {\n      const res = queryWindowsRegistryDefaultValue(spec.key);\n      entries.push({\n        browser,\n        scope: spec.scope,\n        key: spec.key,\n        expectedManifestPath: spec.expected,\n        value: res.value,\n        raw: res.raw,\n        error: res.error,\n      });\n    }\n  }\n\n  return { entries };\n}\n\n// ============================================================================\n// Redaction\n// ============================================================================\n\nfunction escapeRegExp(input: string): string {\n  return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nfunction buildLiteralReplacements(): Array<[RegExp, string]> {\n  const replacements: Array<[RegExp, string]> = [];\n  const ignoreCase = process.platform === 'win32';\n\n  const addLiteral = (literal: string | undefined, replacement: string): void => {\n    if (!literal) return;\n    const variants = new Set<string>();\n    variants.add(literal);\n    variants.add(literal.replace(/\\\\/g, '/'));\n    variants.add(literal.replace(/\\//g, '\\\\'));\n\n    for (const v of variants) {\n      if (!v) continue;\n      replacements.push([new RegExp(escapeRegExp(v), ignoreCase ? 'gi' : 'g'), replacement]);\n    }\n  };\n\n  addLiteral(os.homedir(), '<HOME>');\n  addLiteral(process.env.USERPROFILE, '<USERPROFILE>');\n  addLiteral(process.env.HOME, '<HOME>');\n\n  try {\n    const username = os.userInfo().username;\n    if (username) {\n      replacements.push([\n        new RegExp(`\\\\b${escapeRegExp(username)}\\\\b`, ignoreCase ? 'gi' : 'g'),\n        '<USER>',\n      ]);\n    }\n  } catch {\n    // ignore\n  }\n\n  return replacements;\n}\n\nfunction createRedactor(enabled: boolean): (input: string) => string {\n  if (!enabled) return (s) => s;\n\n  const literalReplacements = buildLiteralReplacements();\n  const patternReplacements: Array<[RegExp, string]> = [\n    // Sensitive key=value patterns (supports JSON-style \"key\": \"value\" and env-style KEY=value)\n    [\n      /(\\b[A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|API_KEY|ACCESS_KEY|PRIVATE_KEY)\\b)(\\s*[\"']?\\s*[:=]\\s*[\"']?)([^\\s\"']+)/gi,\n      '$1$2<REDACTED>',\n    ],\n    // HTTP Authorization headers\n    [/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1<REDACTED>'],\n    [/(Authorization:\\s*Basic\\s+)[^\\s]+/gi, '$1<REDACTED>'],\n    // JSON-style Authorization fields (\"Authorization\": \"Bearer ...\")\n    [\n      /(\\bAuthorization\\b)(\\s*[\"']?\\s*[:=]\\s*[\"']?)(Bearer\\s+|Basic\\s+)?[^\\s\"']+/gi,\n      '$1$2$3<REDACTED>',\n    ],\n    // Cookies\n    [/(Cookie:\\s*)[^\\r\\n]+/gi, '$1<REDACTED>'],\n    [/(Set-Cookie:\\s*)[^\\r\\n]+/gi, '$1<REDACTED>'],\n    // JSON-style Cookie fields (\"Cookie\": \"...\")\n    [/(\\b(?:Cookie|Set-Cookie)\\b)(\\s*[\"']?\\s*[:=]\\s*[\"']?)[^\\r\\n\"']+/gi, '$1$2<REDACTED>'],\n    // Common API header patterns (supports JSON-style)\n    [\n      /(\\b(?:x-api-key|api-key|x-auth-token|x-access-token)\\b)(\\s*[\"']?\\s*[:=]\\s*[\"']?)([^\\s\"']+)/gi,\n      '$1$2<REDACTED>',\n    ],\n    // Email addresses\n    [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi, '<EMAIL>'],\n    // User paths (Windows and macOS/Linux)\n    [/[A-Z]:\\\\Users\\\\[^\\\\]+/gi, '<USERPROFILE>'],\n    [/\\/Users\\/[^/]+/g, '/Users/<USER>'],\n  ];\n\n  return (input: string): string => {\n    let out = input;\n    for (const [re, replacement] of literalReplacements) {\n      out = out.replace(re, replacement);\n    }\n    for (const [re, replacement] of patternReplacements) {\n      out = out.replace(re, replacement);\n    }\n    return out;\n  };\n}\n\nfunction redactDeep(value: unknown, redact: (s: string) => string): unknown {\n  if (typeof value === 'string') return redact(value);\n  if (Array.isArray(value)) return value.map((v) => redactDeep(v, redact));\n  if (value && typeof value === 'object') {\n    const obj = value as Record<string, unknown>;\n    const out: Record<string, unknown> = {};\n    for (const [k, v] of Object.entries(obj)) {\n      out[k] = redactDeep(v, redact);\n    }\n    return out;\n  }\n  return value;\n}\n\n// ============================================================================\n// Output Rendering\n// ============================================================================\n\nfunction renderMarkdown(report: DiagnosticReport): string {\n  const lines: string[] = [];\n\n  lines.push(`# ${report.tool.name} Diagnostic Report`);\n  lines.push('');\n  lines.push(`**Generated:** ${report.timestamp}`);\n  lines.push(`**Redaction:** ${report.redaction.enabled ? 'enabled (default)' : 'disabled'}`);\n  lines.push('');\n\n  lines.push('## Environment');\n  lines.push('');\n  lines.push(`- **Platform:** ${report.environment.platform} (${report.environment.arch})`);\n  lines.push(\n    `- **OS:** ${report.environment.os.type} ${report.environment.os.release}${\n      report.environment.os.version ? ` (${report.environment.os.version})` : ''\n    }`,\n  );\n  lines.push(`- **Node:** ${report.environment.node.version}`);\n  lines.push(`- **Node execPath:** \\`${report.environment.node.execPath}\\``);\n  lines.push(`- **CWD:** \\`${report.environment.cwd}\\``);\n  lines.push('');\n\n  lines.push('## Package Managers');\n  lines.push('');\n  lines.push(\n    `- **npm:** ${\n      report.packageManager.npm.version ?? `ERROR: ${report.packageManager.npm.error ?? 'unknown'}`\n    }`,\n  );\n  lines.push(\n    `- **pnpm:** ${\n      report.packageManager.pnpm.version ??\n      `ERROR: ${report.packageManager.pnpm.error ?? 'unknown'}`\n    }`,\n  );\n  lines.push('');\n\n  lines.push('## Relevant Environment Variables');\n  lines.push('');\n  for (const [k, v] of Object.entries(report.environment.env)) {\n    lines.push(`- \\`${k}\\`: ${v ?? '<unset>'}`);\n  }\n  lines.push('');\n\n  lines.push('## Doctor Output');\n  lines.push('');\n  if (report.doctor) {\n    lines.push('<details>');\n    lines.push('<summary>Click to expand doctor JSON</summary>');\n    lines.push('');\n    lines.push('```json');\n    lines.push(JSON.stringify(report.doctor, null, 2));\n    lines.push('```');\n    lines.push('</details>');\n  } else {\n    lines.push(`**Doctor failed:** ${report.doctorError ?? 'unknown error'}`);\n  }\n  lines.push('');\n\n  lines.push('## Wrapper Logs');\n  lines.push('');\n  lines.push(`**Log directory:** \\`${report.wrapperLogs.dir}\\``);\n  lines.push(`**Mode:** ${report.wrapperLogs.mode}`);\n  if (report.wrapperLogs.error) {\n    lines.push(`**Error:** ${report.wrapperLogs.error}`);\n  }\n  lines.push('');\n  if (report.wrapperLogs.files.length === 0) {\n    lines.push('No wrapper logs found.');\n  } else {\n    for (const f of report.wrapperLogs.files) {\n      lines.push(`### ${f.name}`);\n      lines.push('');\n      lines.push(`- **Path:** \\`${f.path}\\``);\n      if (f.mtime) lines.push(`- **Modified:** ${f.mtime}`);\n      if (typeof f.size === 'number') lines.push(`- **Size:** ${f.size} bytes`);\n      if (f.note) lines.push(`- **Note:** ${f.note}`);\n      if (f.error) {\n        lines.push(`- **Error:** ${f.error}`);\n        lines.push('');\n        continue;\n      }\n      if (typeof f.content === 'string') {\n        if (f.truncated) lines.push('*(Truncated)*');\n        lines.push('');\n        lines.push('<details>');\n        lines.push('<summary>Click to expand log content</summary>');\n        lines.push('');\n        lines.push('```text');\n        lines.push(f.content);\n        lines.push('```');\n        lines.push('</details>');\n      } else {\n        lines.push('*(Content omitted)*');\n      }\n      lines.push('');\n    }\n  }\n  lines.push('');\n\n  lines.push('## Manifests');\n  lines.push('');\n  for (const m of report.manifests) {\n    lines.push(`### ${m.browser} (${m.scope})`);\n    lines.push('');\n    lines.push(`- **Path:** \\`${m.path}\\``);\n    if (!m.exists) {\n      lines.push('- **Status:** not found');\n      lines.push('');\n      continue;\n    }\n    if (m.error) {\n      lines.push(`- **Status:** error (${m.error})`);\n    }\n    if (m.json !== undefined) {\n      lines.push('');\n      lines.push('```json');\n      lines.push(JSON.stringify(m.json, null, 2));\n      lines.push('```');\n    } else if (typeof m.raw === 'string') {\n      lines.push('');\n      lines.push('```text');\n      lines.push(m.raw);\n      lines.push('```');\n    }\n    lines.push('');\n  }\n\n  if (report.windowsRegistry) {\n    lines.push('## Windows Registry');\n    lines.push('');\n    for (const entry of report.windowsRegistry.entries) {\n      lines.push(`### ${entry.browser} (${entry.scope})`);\n      lines.push('');\n      lines.push(`- **Key:** \\`${entry.key}\\``);\n      lines.push(`- **Expected manifest:** \\`${entry.expectedManifestPath}\\``);\n      if (entry.error) {\n        lines.push(`- **Error:** ${entry.error}`);\n        lines.push('');\n        continue;\n      }\n      if (entry.value) lines.push(`- **Default value:** \\`${entry.value}\\``);\n      if (entry.raw) {\n        lines.push('');\n        lines.push('```text');\n        lines.push(entry.raw);\n        lines.push('```');\n      }\n      lines.push('');\n    }\n  }\n\n  lines.push('---');\n  lines.push('');\n  lines.push(\n    '> If you are opening a GitHub Issue, paste everything above. ' +\n      `You can disable redaction with: \\`${report.tool.name} report --no-redact\\``,\n  );\n\n  return lines.join('\\n');\n}\n\nfunction writeOutput(\n  outputPath: string | undefined,\n  content: string,\n): { ok: true; destination: string } | { ok: false; error: string } {\n  if (!outputPath || outputPath === '-' || outputPath.toLowerCase() === 'stdout') {\n    process.stdout.write(content);\n    return { ok: true, destination: 'stdout' };\n  }\n\n  try {\n    const resolved = path.resolve(outputPath);\n    fs.writeFileSync(resolved, content, 'utf8');\n    return { ok: true, destination: resolved };\n  } catch (e) {\n    return { ok: false, error: stringifyError(e) };\n  }\n}\n\nfunction tryCopyToClipboard(text: string): { ok: boolean; method?: string; error?: string } {\n  const spawn = (cmd: string, args: string[]): { ok: boolean; error?: string } => {\n    const res = spawnSync(cmd, args, {\n      input: text,\n      encoding: 'utf8',\n      timeout: 3000,\n      windowsHide: true,\n    });\n    if (res.error) return { ok: false, error: stringifyError(res.error) };\n    if (res.status !== 0) return { ok: false, error: `Exit code ${res.status ?? 'unknown'}` };\n    return { ok: true };\n  };\n\n  if (process.platform === 'darwin') {\n    const r = spawn('pbcopy', []);\n    return r.ok ? { ok: true, method: 'pbcopy' } : { ok: false, method: 'pbcopy', error: r.error };\n  }\n  if (process.platform === 'win32') {\n    const r = spawn('clip', []);\n    return r.ok ? { ok: true, method: 'clip' } : { ok: false, method: 'clip', error: r.error };\n  }\n\n  // Linux: try wl-copy, xclip, xsel\n  for (const cmd of [\n    { cmd: 'wl-copy', args: [] as string[] },\n    { cmd: 'xclip', args: ['-selection', 'clipboard'] as string[] },\n    { cmd: 'xsel', args: ['--clipboard', '--input'] as string[] },\n  ]) {\n    const r = spawn(cmd.cmd, cmd.args);\n    if (r.ok) return { ok: true, method: cmd.cmd };\n  }\n\n  return { ok: false, error: 'No clipboard command available (tried wl-copy, xclip, xsel)' };\n}\n\n// ============================================================================\n// Main Report Function\n// ============================================================================\n\nexport async function runReport(options: ReportOptions): Promise<number> {\n  try {\n    const includeLogs = parseIncludeLogsMode(options.includeLogs);\n    const logLines = parsePositiveInt(options.logLines, DEFAULT_LOG_LINES);\n    const redactionEnabled = options.redact !== false;\n\n    const tool = getToolVersion();\n    const browsers = resolveBrowsers(options.browser);\n\n    // Collect doctor report\n    let doctor: DoctorReport | undefined;\n    let doctorError: string | undefined;\n    try {\n      doctor = await collectDoctorReport({\n        json: true,\n        fix: false,\n        browser: options.browser,\n      });\n    } catch (e) {\n      doctorError = stringifyError(e);\n    }\n\n    // Build the report\n    const report: DiagnosticReport = {\n      schemaVersion: REPORT_SCHEMA_VERSION,\n      timestamp: new Date().toISOString(),\n      tool,\n      environment: {\n        platform: process.platform,\n        arch: process.arch,\n        node: { version: process.version, execPath: process.execPath },\n        os: { type: os.type(), release: os.release(), version: safeOsVersion() },\n        cwd: process.cwd(),\n        env: {\n          CHROME_MCP_NODE_PATH: process.env.CHROME_MCP_NODE_PATH ?? null,\n          VOLTA_HOME: process.env.VOLTA_HOME ?? null,\n          ASDF_DATA_DIR: process.env.ASDF_DATA_DIR ?? null,\n          FNM_DIR: process.env.FNM_DIR ?? null,\n          NVM_DIR: process.env.NVM_DIR ?? null,\n          // nvm-windows uses different environment variables\n          NVM_HOME: process.env.NVM_HOME ?? null,\n          NVM_SYMLINK: process.env.NVM_SYMLINK ?? null,\n          npm_config_user_agent: process.env.npm_config_user_agent ?? null,\n        },\n      },\n      packageManager: {\n        npm: safeExecVersion('npm'),\n        pnpm: safeExecVersion('pnpm'),\n      },\n      doctor,\n      doctorError,\n      manifests: collectManifests(browsers),\n      wrapperLogs: collectWrapperLogs(getLogDir(), includeLogs, logLines),\n      windowsRegistry: process.platform === 'win32' ? collectWindowsRegistry(browsers) : undefined,\n      redaction: { enabled: redactionEnabled },\n    };\n\n    // Apply redaction\n    const redact = createRedactor(redactionEnabled);\n    const finalReport = redactionEnabled\n      ? (redactDeep(report, redact) as DiagnosticReport)\n      : report;\n\n    // Render output\n    const output = options.json\n      ? JSON.stringify(finalReport, null, 2) + '\\n'\n      : renderMarkdown(finalReport) + '\\n';\n\n    // Write output\n    const write = writeOutput(options.output, output);\n    if (!write.ok) {\n      process.stderr.write(`Failed to write report: ${write.error}\\n`);\n      process.stdout.write(output);\n    } else if (write.destination !== 'stdout') {\n      process.stderr.write(`Report written to: ${write.destination}\\n`);\n    }\n\n    // Copy to clipboard if requested\n    if (options.copy) {\n      const copied = tryCopyToClipboard(output);\n      if (copied.ok) {\n        process.stderr.write(`Copied to clipboard (${copied.method})\\n`);\n      } else {\n        process.stderr.write(`Failed to copy to clipboard: ${copied.error ?? 'unknown error'}\\n`);\n      }\n    }\n\n    return 0;\n  } catch (e) {\n    process.stderr.write(`Report failed: ${stringifyError(e)}\\n`);\n    return 1;\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/scripts/run_host.bat",
    "content": "@echo off\nsetlocal enabledelayedexpansion\n\nREM Setup paths\nset \"SCRIPT_DIR=%~dp0\"\nif \"%SCRIPT_DIR:~-1%\"==\"\\\" set \"SCRIPT_DIR=%SCRIPT_DIR:~0,-1%\"\nset \"NODE_SCRIPT=%SCRIPT_DIR%\\index.js\"\n\nREM Setup log directory - prefer user-writable locations\nREM Windows: %LOCALAPPDATA%\\mcp-chrome-bridge\\logs\nset \"LOG_DIR=%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\"\nif not exist \"%LOG_DIR%\" mkdir \"%LOG_DIR%\" 2>nul\nif not exist \"%LOG_DIR%\" (\n    REM Fallback to package directory if user directory not writable\n    set \"LOG_DIR=%SCRIPT_DIR%\\logs\"\n    if not exist \"!LOG_DIR!\" mkdir \"!LOG_DIR!\" 2>nul\n)\n\nREM Generate timestamp\nfor /f %%i in ('powershell -NoProfile -Command \"Get-Date -Format 'yyyyMMdd_HHmmss'\"') do set \"TIMESTAMP=%%i\"\nset \"WRAPPER_LOG=%LOG_DIR%\\native_host_wrapper_windows_%TIMESTAMP%.log\"\nset \"STDERR_LOG=%LOG_DIR%\\native_host_stderr_windows_%TIMESTAMP%.log\"\n\nREM Initial logging\necho Wrapper script called at %DATE% %TIME% > \"%WRAPPER_LOG%\"\necho SCRIPT_DIR: %SCRIPT_DIR% >> \"%WRAPPER_LOG%\"\necho LOG_DIR: %LOG_DIR% >> \"%WRAPPER_LOG%\"\necho NODE_SCRIPT: %NODE_SCRIPT% >> \"%WRAPPER_LOG%\"\necho Initial PATH: %PATH% >> \"%WRAPPER_LOG%\"\necho CHROME_MCP_NODE_PATH: %CHROME_MCP_NODE_PATH% >> \"%WRAPPER_LOG%\"\necho VOLTA_HOME: %VOLTA_HOME% >> \"%WRAPPER_LOG%\"\necho ASDF_DATA_DIR: %ASDF_DATA_DIR% >> \"%WRAPPER_LOG%\"\necho FNM_DIR: %FNM_DIR% >> \"%WRAPPER_LOG%\"\necho User: %USERNAME% >> \"%WRAPPER_LOG%\"\necho Current PWD: %CD% >> \"%WRAPPER_LOG%\"\n\nREM Node.js discovery\nset \"NODE_EXEC=\"\nset \"NODE_EXEC_SOURCE=\"\n\nREM Priority 0: CHROME_MCP_NODE_PATH environment variable override\necho [Priority 0] Checking CHROME_MCP_NODE_PATH override >> \"%WRAPPER_LOG%\"\nif defined CHROME_MCP_NODE_PATH (\n    set \"CANDIDATE_NODE=%CHROME_MCP_NODE_PATH%\"\n    REM Check if it's a directory, then append node.exe\n    if exist \"!CANDIDATE_NODE!\\*\" (\n        set \"CANDIDATE_NODE=!CANDIDATE_NODE!\\node.exe\"\n    )\n    if exist \"!CANDIDATE_NODE!\" (\n        set \"NODE_EXEC=!CANDIDATE_NODE!\"\n        set \"NODE_EXEC_SOURCE=CHROME_MCP_NODE_PATH\"\n        echo Found node via CHROME_MCP_NODE_PATH: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    ) else (\n        echo CHROME_MCP_NODE_PATH is set but not found: !CANDIDATE_NODE! >> \"%WRAPPER_LOG%\"\n    )\n)\n\nREM Priority 1: Installation-time node path\nset \"NODE_PATH_FILE=%SCRIPT_DIR%\\node_path.txt\"\necho [Priority 1] Checking installation-time node path >> \"%WRAPPER_LOG%\"\nif not defined NODE_EXEC (\n    if exist \"%NODE_PATH_FILE%\" (\n        set /p EXPECTED_NODE=<\"%NODE_PATH_FILE%\"\n        if exist \"!EXPECTED_NODE!\" (\n            set \"NODE_EXEC=!EXPECTED_NODE!\"\n            set \"NODE_EXEC_SOURCE=node_path.txt\"\n            echo Found installation-time node at !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n        ) else (\n            echo node_path.txt exists but path invalid: !EXPECTED_NODE! >> \"%WRAPPER_LOG%\"\n        )\n    )\n)\n\nREM Priority 1.5: Fallback to relative path\nif not defined NODE_EXEC (\n    set \"EXPECTED_NODE=%SCRIPT_DIR%\\..\\..\\..\\node.exe\"\n    echo [Priority 1.5] Checking relative path >> \"%WRAPPER_LOG%\"\n    if exist \"%EXPECTED_NODE%\" (\n        set \"NODE_EXEC=%EXPECTED_NODE%\"\n        set \"NODE_EXEC_SOURCE=relative\"\n        echo Found node at relative path: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    )\n)\n\nREM Priority 2: Volta\nif not defined NODE_EXEC (\n    echo [Priority 2] Checking Volta >> \"%WRAPPER_LOG%\"\n    if defined VOLTA_HOME (\n        if exist \"%VOLTA_HOME%\\bin\\node.exe\" (\n            set \"NODE_EXEC=%VOLTA_HOME%\\bin\\node.exe\"\n            set \"NODE_EXEC_SOURCE=volta\"\n            echo Found Volta node: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n        )\n    ) else (\n        if exist \"%USERPROFILE%\\.volta\\bin\\node.exe\" (\n            set \"NODE_EXEC=%USERPROFILE%\\.volta\\bin\\node.exe\"\n            set \"NODE_EXEC_SOURCE=volta\"\n            echo Found Volta node: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n        )\n    )\n)\n\nREM Priority 3: asdf (use PowerShell to find latest version)\nif not defined NODE_EXEC (\n    echo [Priority 3] Checking asdf >> \"%WRAPPER_LOG%\"\n    set \"ASDF_NODE=\"\n    for /f \"delims=\" %%i in ('powershell -NoProfile -Command \"$base=$env:ASDF_DATA_DIR; if(-not $base){$base=Join-Path $env:USERPROFILE '.asdf'}; $root=Join-Path $base 'installs\\nodejs'; $best=$null; if(Test-Path $root){ foreach($d in (Get-ChildItem -Directory -Path $root -ErrorAction SilentlyContinue)){ if($d.Name -match '^v?\\d+(\\.\\d+){1,3}$'){ $v=[version]($d.Name -replace '^v',''); if(-not $best -or $v -gt $best.Ver){ $best=[pscustomobject]@{Ver=$v;Dir=$d.FullName} } } } }; if($best){ $p=Join-Path $best.Dir 'bin\\node.exe'; if(Test-Path $p){ Write-Output $p } }\" 2^>nul') do set \"ASDF_NODE=%%i\"\n    if defined ASDF_NODE (\n        set \"NODE_EXEC=!ASDF_NODE!\"\n        set \"NODE_EXEC_SOURCE=asdf\"\n        echo Found asdf node: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    )\n)\n\nREM Priority 4: fnm (use PowerShell to find latest version)\nif not defined NODE_EXEC (\n    echo [Priority 4] Checking fnm >> \"%WRAPPER_LOG%\"\n    set \"FNM_NODE=\"\n    for /f \"delims=\" %%i in ('powershell -NoProfile -Command \"$base=$env:FNM_DIR; if(-not $base){$base=Join-Path $env:USERPROFILE '.fnm'}; $root=Join-Path $base 'node-versions'; $best=$null; if(Test-Path $root){ foreach($d in (Get-ChildItem -Directory -Path $root -ErrorAction SilentlyContinue)){ if($d.Name -match '^v?\\d+(\\.\\d+){1,3}$'){ $v=[version]($d.Name -replace '^v',''); if(-not $best -or $v -gt $best.Ver){ $best=[pscustomobject]@{Ver=$v;Dir=$d.FullName} } } } }; if($best){ $p=Join-Path $best.Dir 'installation\\node.exe'; if(Test-Path $p){ Write-Output $p } }\" 2^>nul') do set \"FNM_NODE=%%i\"\n    if defined FNM_NODE (\n        set \"NODE_EXEC=!FNM_NODE!\"\n        set \"NODE_EXEC_SOURCE=fnm\"\n        echo Found fnm node: !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    )\n)\n\nREM Priority 5: where command\nif not defined NODE_EXEC (\n    echo [Priority 5] Trying 'where node.exe' >> \"%WRAPPER_LOG%\"\n    for /f \"delims=\" %%i in ('where node.exe 2^>nul') do (\n        if not defined NODE_EXEC (\n            set \"NODE_EXEC=%%i\"\n            set \"NODE_EXEC_SOURCE=where\"\n            echo Found node using 'where': !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n        )\n    )\n)\n\nREM Priority 6: Common paths\nif not defined NODE_EXEC (\n    echo [Priority 6] Checking common paths >> \"%WRAPPER_LOG%\"\n    if exist \"%ProgramFiles%\\nodejs\\node.exe\" (\n        set \"NODE_EXEC=%ProgramFiles%\\nodejs\\node.exe\"\n        set \"NODE_EXEC_SOURCE=common\"\n        echo Found node at !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    ) else if exist \"%ProgramFiles(x86)%\\nodejs\\node.exe\" (\n        set \"NODE_EXEC=%ProgramFiles(x86)%\\nodejs\\node.exe\"\n        set \"NODE_EXEC_SOURCE=common\"\n        echo Found node at !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    ) else if exist \"%LOCALAPPDATA%\\Programs\\nodejs\\node.exe\" (\n        set \"NODE_EXEC=%LOCALAPPDATA%\\Programs\\nodejs\\node.exe\"\n        set \"NODE_EXEC_SOURCE=common\"\n        echo Found node at !NODE_EXEC! >> \"%WRAPPER_LOG%\"\n    )\n)\n\nREM Validation\nif not defined NODE_EXEC (\n    echo ERROR: Node.js executable not found! >> \"%WRAPPER_LOG%\"\n    echo Searched: CHROME_MCP_NODE_PATH, node_path.txt, relative, Volta, asdf, fnm, where, common paths >> \"%WRAPPER_LOG%\"\n    echo To fix: Set CHROME_MCP_NODE_PATH environment variable or run 'mcp-chrome-bridge doctor --fix' >> \"%WRAPPER_LOG%\"\n    exit /B 1\n)\n\necho Using Node executable: %NODE_EXEC% >> \"%WRAPPER_LOG%\"\necho Node discovery source: %NODE_EXEC_SOURCE% >> \"%WRAPPER_LOG%\"\ncall \"%NODE_EXEC%\" -v >> \"%WRAPPER_LOG%\" 2>>&1\n\nif not exist \"%NODE_SCRIPT%\" (\n    echo ERROR: Node.js script not found at %NODE_SCRIPT% >> \"%WRAPPER_LOG%\"\n    exit /B 1\n)\n\nREM Add Node.js bin directory to PATH for child processes\nfor %%I in (\"%NODE_EXEC%\") do set \"NODE_BIN_DIR=%%~dpI\"\nif defined PATH (set \"PATH=%NODE_BIN_DIR%;%PATH%\") else (set \"PATH=%NODE_BIN_DIR%\")\necho Added %NODE_BIN_DIR% to PATH >> \"%WRAPPER_LOG%\"\n\nREM Log Claude Code Router (CCR) related env vars for debugging\nREM These are set via System Properties or PowerShell profile\nif defined ANTHROPIC_BASE_URL (\n    echo ANTHROPIC_BASE_URL is set: %ANTHROPIC_BASE_URL% >> \"%WRAPPER_LOG%\"\n)\nif defined ANTHROPIC_AUTH_TOKEN (\n    echo ANTHROPIC_AUTH_TOKEN is set (value hidden) >> \"%WRAPPER_LOG%\"\n)\n\necho Executing: \"%NODE_EXEC%\" \"%NODE_SCRIPT%\" >> \"%WRAPPER_LOG%\"\ncall \"%NODE_EXEC%\" \"%NODE_SCRIPT%\" 2>> \"%STDERR_LOG%\"\nset \"EXIT_CODE=%ERRORLEVEL%\"\n\necho Exit code: %EXIT_CODE% >> \"%WRAPPER_LOG%\"\nendlocal\nexit /B %EXIT_CODE%\n"
  },
  {
    "path": "app/native-server/src/scripts/run_host.sh",
    "content": "#!/usr/bin/env bash\n\n# Configuration\nENABLE_LOG_ROTATION=\"true\"\nLOG_RETENTION_COUNT=5\n\n# Setup paths\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nNODE_SCRIPT=\"${SCRIPT_DIR}/index.js\"\n\n# Setup log directory - prefer user-writable locations\n# macOS: ~/Library/Logs/mcp-chrome-bridge\n# Linux: $XDG_STATE_HOME/mcp-chrome-bridge/logs or ~/.local/state/mcp-chrome-bridge/logs\nif [ \"$(uname)\" = \"Darwin\" ]; then\n    LOG_DIR=\"${HOME}/Library/Logs/mcp-chrome-bridge\"\nelse\n    LOG_DIR=\"${XDG_STATE_HOME:-${HOME}/.local/state}/mcp-chrome-bridge/logs\"\nfi\n\n# Fallback: if user directory is not writable, use package directory\nif ! mkdir -p \"${LOG_DIR}\" 2>/dev/null; then\n    LOG_DIR=\"${SCRIPT_DIR}/logs\"\n    mkdir -p \"${LOG_DIR}\" 2>/dev/null || true\nfi\n\n# Log rotation\nif [ \"${ENABLE_LOG_ROTATION}\" = \"true\" ]; then\n    # Clean up old logs (both legacy _macos_ and new _unix_ naming)\n    ls -tp \"${LOG_DIR}/native_host_wrapper_\"* 2>/dev/null | tail -n +$((LOG_RETENTION_COUNT + 1)) | xargs -I {} rm -- {}\n    ls -tp \"${LOG_DIR}/native_host_stderr_\"* 2>/dev/null | tail -n +$((LOG_RETENTION_COUNT + 1)) | xargs -I {} rm -- {}\nfi\n\n# Logging setup\nTIMESTAMP=$(date +\"%Y%m%d_%H%M%S\")\nWRAPPER_LOG=\"${LOG_DIR}/native_host_wrapper_unix_${TIMESTAMP}.log\"\nSTDERR_LOG=\"${LOG_DIR}/native_host_stderr_unix_${TIMESTAMP}.log\"\n\n# Initial logging\n{\n    echo \"--- Wrapper script called at $(date) ---\"\n    echo \"SCRIPT_DIR: ${SCRIPT_DIR}\"\n    echo \"LOG_DIR: ${LOG_DIR}\"\n    echo \"NODE_SCRIPT: ${NODE_SCRIPT}\"\n    echo \"Initial PATH: ${PATH}\"\n    echo \"CHROME_MCP_NODE_PATH: ${CHROME_MCP_NODE_PATH:-<unset>}\"\n    echo \"VOLTA_HOME: ${VOLTA_HOME:-<unset>}\"\n    echo \"ASDF_DATA_DIR: ${ASDF_DATA_DIR:-<unset>}\"\n    echo \"FNM_DIR: ${FNM_DIR:-<unset>}\"\n    echo \"NVM_DIR: ${NVM_DIR:-<unset>}\"\n    echo \"User: $(whoami)\"\n    echo \"Current PWD: $(pwd)\"\n} > \"${WRAPPER_LOG}\"\n\n# Node.js discovery\nNODE_EXEC=\"\"\nNODE_EXEC_SOURCE=\"\"\nNODE_PATH_FILE=\"${SCRIPT_DIR}/node_path.txt\"\n\necho \"Searching for Node.js...\" >> \"${WRAPPER_LOG}\"\n\n# Priority 0: CHROME_MCP_NODE_PATH environment variable override\necho \"[Priority 0] Checking CHROME_MCP_NODE_PATH override\" >> \"${WRAPPER_LOG}\"\nif [ -n \"${CHROME_MCP_NODE_PATH:-}\" ]; then\n    CANDIDATE_NODE=\"${CHROME_MCP_NODE_PATH}\"\n    # Expand tilde\n    if [[ \"${CANDIDATE_NODE}\" == \"~/\"* ]]; then\n        CANDIDATE_NODE=\"${HOME}/${CANDIDATE_NODE#~/}\"\n    fi\n    # If directory, append /node\n    if [ -d \"${CANDIDATE_NODE}\" ]; then\n        CANDIDATE_NODE=\"${CANDIDATE_NODE%/}/node\"\n    fi\n    if [ -x \"${CANDIDATE_NODE}\" ]; then\n        NODE_EXEC=\"${CANDIDATE_NODE}\"\n        NODE_EXEC_SOURCE=\"CHROME_MCP_NODE_PATH\"\n        echo \"Found node via CHROME_MCP_NODE_PATH: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n    else\n        echo \"CHROME_MCP_NODE_PATH is set but not executable: ${CANDIDATE_NODE}\" >> \"${WRAPPER_LOG}\"\n    fi\nfi\n\n# Priority 1: Installation-time node path\necho \"[Priority 1] Checking installation-time node path\" >> \"${WRAPPER_LOG}\"\nif [ -z \"${NODE_EXEC}\" ] && [ -f \"${NODE_PATH_FILE}\" ]; then\n    EXPECTED_NODE=$(cat \"${NODE_PATH_FILE}\" 2>/dev/null | tr -d '\\n\\r')\n    if [ -n \"${EXPECTED_NODE}\" ] && [ -x \"${EXPECTED_NODE}\" ]; then\n        NODE_EXEC=\"${EXPECTED_NODE}\"\n        NODE_EXEC_SOURCE=\"node_path.txt\"\n        echo \"Found installation-time node at ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n    else\n        echo \"node_path.txt exists but path invalid or not executable: ${EXPECTED_NODE}\" >> \"${WRAPPER_LOG}\"\n    fi\nfi\n\n# Priority 1.5: Fallback to relative path\nif [ -z \"${NODE_EXEC}\" ]; then\n    EXPECTED_NODE=\"${SCRIPT_DIR}/../../../bin/node\"\n    echo \"[Priority 1.5] Checking relative path\" >> \"${WRAPPER_LOG}\"\n    if [ -x \"${EXPECTED_NODE}\" ]; then\n        NODE_EXEC=\"${EXPECTED_NODE}\"\n        NODE_EXEC_SOURCE=\"relative\"\n        echo \"Found node at relative path: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n    fi\nfi\n\n# Priority 2: Volta\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 2] Checking Volta\" >> \"${WRAPPER_LOG}\"\n    VOLTA_HOME_CANDIDATE=\"${VOLTA_HOME:-$HOME/.volta}\"\n    VOLTA_NODE=\"${VOLTA_HOME_CANDIDATE}/bin/node\"\n    if [ -x \"${VOLTA_NODE}\" ]; then\n        NODE_EXEC=\"${VOLTA_NODE}\"\n        NODE_EXEC_SOURCE=\"volta\"\n        echo \"Found Volta node: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n    fi\nfi\n\n# Priority 3: asdf\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 3] Checking asdf\" >> \"${WRAPPER_LOG}\"\n    ASDF_DIR=\"${ASDF_DATA_DIR:-$HOME/.asdf}\"\n    ASDF_NODEJS_DIR=\"${ASDF_DIR}/installs/nodejs\"\n    if [ -d \"${ASDF_NODEJS_DIR}\" ]; then\n        # Find the latest version directory\n        LATEST_ASDF_NODE_DIR=$(ls -1d \"${ASDF_NODEJS_DIR}/\"* 2>/dev/null | sort -V | tail -n 1)\n        if [ -n \"${LATEST_ASDF_NODE_DIR}\" ] && [ -x \"${LATEST_ASDF_NODE_DIR}/bin/node\" ]; then\n            NODE_EXEC=\"${LATEST_ASDF_NODE_DIR}/bin/node\"\n            NODE_EXEC_SOURCE=\"asdf\"\n            echo \"Found asdf node: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n        fi\n    fi\nfi\n\n# Priority 4: fnm\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 4] Checking fnm\" >> \"${WRAPPER_LOG}\"\n    FNM_HOME_CANDIDATE=\"${FNM_DIR:-$HOME/.fnm}\"\n    FNM_NODE_VERSIONS_DIR=\"${FNM_HOME_CANDIDATE}/node-versions\"\n    if [ -d \"${FNM_NODE_VERSIONS_DIR}\" ]; then\n        # Find the latest version directory\n        LATEST_FNM_NODE_DIR=$(ls -1d \"${FNM_NODE_VERSIONS_DIR}/\"* 2>/dev/null | sort -V | tail -n 1)\n        if [ -n \"${LATEST_FNM_NODE_DIR}\" ] && [ -x \"${LATEST_FNM_NODE_DIR}/installation/bin/node\" ]; then\n            NODE_EXEC=\"${LATEST_FNM_NODE_DIR}/installation/bin/node\"\n            NODE_EXEC_SOURCE=\"fnm\"\n            echo \"Found fnm node: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n        fi\n    fi\nfi\n\n# Priority 5: NVM\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 5] Checking NVM\" >> \"${WRAPPER_LOG}\"\n    NVM_DIR=\"${NVM_DIR:-$HOME/.nvm}\"\n    if [ -d \"${NVM_DIR}\" ]; then\n        # Try default version first (check both symlink and file)\n        NVM_DEFAULT_ALIAS=\"${NVM_DIR}/alias/default\"\n        if [ -e \"${NVM_DEFAULT_ALIAS}\" ]; then\n            if [ -L \"${NVM_DEFAULT_ALIAS}\" ]; then\n                NVM_DEFAULT_VERSION=$(readlink \"${NVM_DEFAULT_ALIAS}\")\n            else\n                NVM_DEFAULT_VERSION=$(cat \"${NVM_DEFAULT_ALIAS}\" 2>/dev/null | tr -d '\\n\\r')\n            fi\n            NVM_DEFAULT_NODE=\"${NVM_DIR}/versions/node/${NVM_DEFAULT_VERSION}/bin/node\"\n            if [ -x \"${NVM_DEFAULT_NODE}\" ]; then\n                NODE_EXEC=\"${NVM_DEFAULT_NODE}\"\n                NODE_EXEC_SOURCE=\"nvm-default\"\n                echo \"Found NVM default node: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n            fi\n        fi\n\n        # Fallback to latest version\n        if [ -z \"${NODE_EXEC}\" ]; then\n            LATEST_NVM_VERSION_PATH=$(ls -d \"${NVM_DIR}\"/versions/node/v* 2>/dev/null | sort -V | tail -n 1)\n            if [ -n \"${LATEST_NVM_VERSION_PATH}\" ] && [ -x \"${LATEST_NVM_VERSION_PATH}/bin/node\" ]; then\n                NODE_EXEC=\"${LATEST_NVM_VERSION_PATH}/bin/node\"\n                NODE_EXEC_SOURCE=\"nvm-latest\"\n                echo \"Found NVM latest node: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n            fi\n        fi\n    fi\nfi\n\n# Priority 6: Common paths\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 6] Checking common paths\" >> \"${WRAPPER_LOG}\"\n    COMMON_NODE_PATHS=(\n        \"/opt/homebrew/bin/node\"\n        \"/usr/local/bin/node\"\n        \"/usr/bin/node\"\n    )\n    for path_to_node in \"${COMMON_NODE_PATHS[@]}\"; do\n        if [ -x \"${path_to_node}\" ]; then\n            NODE_EXEC=\"${path_to_node}\"\n            NODE_EXEC_SOURCE=\"common\"\n            echo \"Found node at: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n            break\n        fi\n    done\nfi\n\n# Priority 7: command -v\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 7] Trying 'command -v node'\" >> \"${WRAPPER_LOG}\"\n    if command -v node &>/dev/null; then\n        NODE_EXEC=$(command -v node)\n        NODE_EXEC_SOURCE=\"command -v\"\n        echo \"Found node using 'command -v': ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n    fi\nfi\n\n# Priority 8: PATH search\nif [ -z \"${NODE_EXEC}\" ]; then\n    echo \"[Priority 8] Searching PATH\" >> \"${WRAPPER_LOG}\"\n    OLD_IFS=$IFS\n    IFS=:\n    for path_in_env in $PATH; do\n        if [ -x \"${path_in_env}/node\" ]; then\n            NODE_EXEC=\"${path_in_env}/node\"\n            NODE_EXEC_SOURCE=\"PATH\"\n            echo \"Found node in PATH: ${NODE_EXEC}\" >> \"${WRAPPER_LOG}\"\n            break\n        fi\n    done\n    IFS=$OLD_IFS\nfi\n\n# Execution\nif [ -z \"${NODE_EXEC}\" ]; then\n    {\n        echo \"ERROR: Node.js executable not found!\"\n        echo \"Searched: CHROME_MCP_NODE_PATH, node_path.txt, relative path, Volta, asdf, fnm, NVM, common paths, command -v, PATH\"\n        echo \"To fix: Set CHROME_MCP_NODE_PATH environment variable or run 'mcp-chrome-bridge doctor --fix'\"\n    } >> \"${WRAPPER_LOG}\"\n    exit 1\nfi\n\nif [ ! -f \"${NODE_SCRIPT}\" ]; then\n    echo \"ERROR: Node.js script not found at ${NODE_SCRIPT}\" >> \"${WRAPPER_LOG}\"\n    exit 1\nfi\n\n{\n    echo \"Using Node executable: ${NODE_EXEC}\"\n    echo \"Node discovery source: ${NODE_EXEC_SOURCE:-unknown}\"\n    echo \"Node version: $(${NODE_EXEC} -v)\"\n    echo \"Executing: ${NODE_EXEC} ${NODE_SCRIPT}\"\n} >> \"${WRAPPER_LOG}\"\n\n# Add Node.js bin directory to PATH so child processes can find node and related tools\nNODE_BIN_DIR=\"$(dirname \"${NODE_EXEC}\")\"\n# Use ${PATH:+:${PATH}} to avoid trailing colon when PATH is empty (security concern)\nexport PATH=\"${NODE_BIN_DIR}${PATH:+:${PATH}}\"\necho \"Added ${NODE_BIN_DIR} to PATH\" >> \"${WRAPPER_LOG}\"\n\n# Log Claude Code Router (CCR) related env vars for debugging\n# These are set by `eval \"$(ccr activate)\"` or in shell profile\nif [ -n \"${ANTHROPIC_BASE_URL:-}\" ]; then\n    echo \"ANTHROPIC_BASE_URL is set: ${ANTHROPIC_BASE_URL}\" >> \"${WRAPPER_LOG}\"\nfi\nif [ -n \"${ANTHROPIC_AUTH_TOKEN:-}\" ]; then\n    echo \"ANTHROPIC_AUTH_TOKEN is set (value hidden)\" >> \"${WRAPPER_LOG}\"\nfi\n\nexec \"${NODE_EXEC}\" \"${NODE_SCRIPT}\" 2>> \"${STDERR_LOG}\"\n"
  },
  {
    "path": "app/native-server/src/scripts/utils.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { execSync } from 'child_process';\nimport { promisify } from 'util';\nimport { COMMAND_NAME, DESCRIPTION, EXTENSION_ID, HOST_NAME } from './constant';\nimport { BrowserType, getBrowserConfig, detectInstalledBrowsers } from './browser-config';\n\nexport const access = promisify(fs.access);\nexport const mkdir = promisify(fs.mkdir);\nexport const writeFile = promisify(fs.writeFile);\n\n/**\n * Get the log directory path for wrapper scripts.\n * Uses platform-appropriate user directories to avoid permission issues.\n *\n * - macOS: ~/Library/Logs/mcp-chrome-bridge\n * - Windows: %LOCALAPPDATA%/mcp-chrome-bridge/logs\n * - Linux: $XDG_STATE_HOME/mcp-chrome-bridge/logs or ~/.local/state/mcp-chrome-bridge/logs\n */\nexport function getLogDir(): string {\n  const homedir = os.homedir();\n\n  if (os.platform() === 'darwin') {\n    return path.join(homedir, 'Library', 'Logs', 'mcp-chrome-bridge');\n  } else if (os.platform() === 'win32') {\n    return path.join(\n      process.env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local'),\n      'mcp-chrome-bridge',\n      'logs',\n    );\n  } else {\n    // Linux: XDG_STATE_HOME or ~/.local/state\n    const xdgState = process.env.XDG_STATE_HOME || path.join(homedir, '.local', 'state');\n    return path.join(xdgState, 'mcp-chrome-bridge', 'logs');\n  }\n}\n\n/**\n * 打印彩色文本\n */\nexport function colorText(text: string, color: string): string {\n  const colors: Record<string, string> = {\n    red: '\\x1b[31m',\n    green: '\\x1b[32m',\n    yellow: '\\x1b[33m',\n    blue: '\\x1b[34m',\n    reset: '\\x1b[0m',\n  };\n\n  return colors[color] + text + colors.reset;\n}\n\n/**\n * Get user-level manifest file path\n */\nexport function getUserManifestPath(): string {\n  if (os.platform() === 'win32') {\n    // Windows: %APPDATA%\\Google\\Chrome\\NativeMessagingHosts\\\n    return path.join(\n      process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),\n      'Google',\n      'Chrome',\n      'NativeMessagingHosts',\n      `${HOST_NAME}.json`,\n    );\n  } else if (os.platform() === 'darwin') {\n    // macOS: ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/\n    return path.join(\n      os.homedir(),\n      'Library',\n      'Application Support',\n      'Google',\n      'Chrome',\n      'NativeMessagingHosts',\n      `${HOST_NAME}.json`,\n    );\n  } else {\n    // Linux: ~/.config/google-chrome/NativeMessagingHosts/\n    return path.join(\n      os.homedir(),\n      '.config',\n      'google-chrome',\n      'NativeMessagingHosts',\n      `${HOST_NAME}.json`,\n    );\n  }\n}\n\n/**\n * Get system-level manifest file path\n */\nexport function getSystemManifestPath(): string {\n  if (os.platform() === 'win32') {\n    // Windows: %ProgramFiles%\\Google\\Chrome\\NativeMessagingHosts\\\n    return path.join(\n      process.env.ProgramFiles || 'C:\\\\Program Files',\n      'Google',\n      'Chrome',\n      'NativeMessagingHosts',\n      `${HOST_NAME}.json`,\n    );\n  } else if (os.platform() === 'darwin') {\n    // macOS: /Library/Google/Chrome/NativeMessagingHosts/\n    return path.join('/Library', 'Google', 'Chrome', 'NativeMessagingHosts', `${HOST_NAME}.json`);\n  } else {\n    // Linux: /etc/opt/chrome/native-messaging-hosts/\n    return path.join('/etc', 'opt', 'chrome', 'native-messaging-hosts', `${HOST_NAME}.json`);\n  }\n}\n\n/**\n * Get native host startup script file path\n */\nexport async function getMainPath(): Promise<string> {\n  try {\n    const packageDistDir = path.join(__dirname, '..');\n    const wrapperScriptName = process.platform === 'win32' ? 'run_host.bat' : 'run_host.sh';\n    const absoluteWrapperPath = path.resolve(packageDistDir, wrapperScriptName);\n    return absoluteWrapperPath;\n  } catch (error) {\n    console.log(colorText('Cannot find global package path, using current directory', 'yellow'));\n    throw error;\n  }\n}\n\n/**\n * Write Node.js executable path to node_path.txt for run_host scripts.\n * This ensures the native host uses the same Node.js version that was used during installation,\n * avoiding NODE_MODULE_VERSION mismatch errors with native modules like better-sqlite3.\n *\n * @param distDir - The dist directory where node_path.txt should be written\n * @param nodeExecPath - The Node.js executable path to write (defaults to current process.execPath)\n */\nexport function writeNodePathFile(distDir: string, nodeExecPath = process.execPath): void {\n  try {\n    const nodePathFile = path.join(distDir, 'node_path.txt');\n    fs.mkdirSync(distDir, { recursive: true });\n\n    console.log(colorText(`Writing Node.js path: ${nodeExecPath}`, 'blue'));\n    fs.writeFileSync(nodePathFile, nodeExecPath, 'utf8');\n    console.log(colorText('✓ Node.js path written for run_host scripts', 'green'));\n  } catch (error: unknown) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(colorText(`⚠️ Failed to write Node.js path: ${message}`, 'yellow'));\n  }\n}\n\n/**\n * 确保关键文件具有执行权限\n */\nexport async function ensureExecutionPermissions(): Promise<void> {\n  try {\n    const packageDistDir = path.join(__dirname, '..');\n\n    if (process.platform === 'win32') {\n      // Windows 平台处理\n      await ensureWindowsFilePermissions(packageDistDir);\n      return;\n    }\n\n    // Unix/Linux 平台处理\n    const filesToCheck = [\n      path.join(packageDistDir, 'index.js'),\n      path.join(packageDistDir, 'run_host.sh'),\n      path.join(packageDistDir, 'cli.js'),\n    ];\n\n    for (const filePath of filesToCheck) {\n      if (fs.existsSync(filePath)) {\n        try {\n          fs.chmodSync(filePath, '755');\n          console.log(\n            colorText(`✓ Set execution permissions for ${path.basename(filePath)}`, 'green'),\n          );\n        } catch (err: any) {\n          console.warn(\n            colorText(\n              `⚠️ Unable to set execution permissions for ${path.basename(filePath)}: ${err.message}`,\n              'yellow',\n            ),\n          );\n        }\n      } else {\n        console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));\n      }\n    }\n  } catch (error: any) {\n    console.warn(colorText(`⚠️ Error ensuring execution permissions: ${error.message}`, 'yellow'));\n  }\n}\n\n/**\n * Windows 平台文件权限处理\n */\nasync function ensureWindowsFilePermissions(packageDistDir: string): Promise<void> {\n  const filesToCheck = [\n    path.join(packageDistDir, 'index.js'),\n    path.join(packageDistDir, 'run_host.bat'),\n    path.join(packageDistDir, 'cli.js'),\n  ];\n\n  for (const filePath of filesToCheck) {\n    if (fs.existsSync(filePath)) {\n      try {\n        // 检查文件是否为只读，如果是则移除只读属性\n        const stats = fs.statSync(filePath);\n        if (!(stats.mode & parseInt('200', 8))) {\n          // 检查写权限\n          // 尝试移除只读属性\n          fs.chmodSync(filePath, stats.mode | parseInt('200', 8));\n          console.log(\n            colorText(`✓ Removed read-only attribute from ${path.basename(filePath)}`, 'green'),\n          );\n        }\n\n        // 验证文件可读性\n        fs.accessSync(filePath, fs.constants.R_OK);\n        console.log(\n          colorText(`✓ Verified file accessibility for ${path.basename(filePath)}`, 'green'),\n        );\n      } catch (err: any) {\n        console.warn(\n          colorText(\n            `⚠️ Unable to verify file permissions for ${path.basename(filePath)}: ${err.message}`,\n            'yellow',\n          ),\n        );\n      }\n    } else {\n      console.warn(colorText(`⚠️ File not found: ${filePath}`, 'yellow'));\n    }\n  }\n}\n\n/**\n * Create Native Messaging host manifest content\n */\nexport async function createManifestContent(): Promise<any> {\n  const mainPath = await getMainPath();\n\n  return {\n    name: HOST_NAME,\n    description: DESCRIPTION,\n    path: mainPath, // Node.js可执行文件路径\n    type: 'stdio',\n    allowed_origins: [`chrome-extension://${EXTENSION_ID}/`],\n  };\n}\n\n/**\n * 验证Windows注册表项是否存在且指向正确路径\n */\nfunction verifyWindowsRegistryEntry(registryKey: string, expectedPath: string): boolean {\n  if (os.platform() !== 'win32') {\n    return true; // 非Windows平台跳过验证\n  }\n\n  const normalizeForCompare = (filePath: string): string => path.normalize(filePath).toLowerCase();\n\n  try {\n    const output = execSync(`reg query \"${registryKey}\" /ve`, {\n      encoding: 'utf8',\n      stdio: 'pipe',\n    });\n    const lines = output\n      .split(/\\r?\\n/)\n      .map((l) => l.trim())\n      .filter(Boolean);\n\n    for (const line of lines) {\n      const match = line.match(/REG_SZ\\s+(.*)$/i);\n      if (!match?.[1]) continue;\n      const actualPath = match[1].trim();\n      return normalizeForCompare(actualPath) === normalizeForCompare(expectedPath);\n    }\n  } catch {\n    // ignore\n  }\n\n  return false;\n}\n\n/**\n * Write node_path.txt and then register user-level Native Messaging host.\n * This is the recommended entry point for development and production registration,\n * as it ensures the Node.js path is captured before registration.\n *\n * @param browsers - Optional list of browsers to register for\n * @returns true if at least one browser was registered successfully\n */\nexport async function registerUserLevelHostWithNodePath(\n  browsers?: BrowserType[],\n): Promise<boolean> {\n  writeNodePathFile(path.join(__dirname, '..'));\n  return tryRegisterUserLevelHost(browsers);\n}\n\n/**\n * 尝试注册用户级别的Native Messaging主机\n */\nexport async function tryRegisterUserLevelHost(targetBrowsers?: BrowserType[]): Promise<boolean> {\n  try {\n    console.log(colorText('Attempting to register user-level Native Messaging host...', 'blue'));\n\n    // 1. 确保执行权限\n    await ensureExecutionPermissions();\n\n    // 2. 确定要注册的浏览器\n    const browsersToRegister = targetBrowsers || detectInstalledBrowsers();\n    if (browsersToRegister.length === 0) {\n      // 如果没有检测到浏览器，默认注册Chrome和Chromium\n      browsersToRegister.push(BrowserType.CHROME, BrowserType.CHROMIUM);\n      console.log(\n        colorText('No browsers detected, registering for Chrome and Chromium by default', 'yellow'),\n      );\n    } else {\n      console.log(colorText(`Detected browsers: ${browsersToRegister.join(', ')}`, 'blue'));\n    }\n\n    // 3. 创建清单内容\n    const manifest = await createManifestContent();\n\n    let successCount = 0;\n    const results: { browser: string; success: boolean; error?: string }[] = [];\n\n    // 4. 为每个浏览器注册\n    for (const browserType of browsersToRegister) {\n      const config = getBrowserConfig(browserType);\n      console.log(colorText(`\\nRegistering for ${config.displayName}...`, 'blue'));\n\n      try {\n        // 确保目录存在\n        await mkdir(path.dirname(config.userManifestPath), { recursive: true });\n\n        // 写入清单文件\n        await writeFile(config.userManifestPath, JSON.stringify(manifest, null, 2));\n        console.log(colorText(`✓ Manifest written to ${config.userManifestPath}`, 'green'));\n\n        // Windows需要额外注册表项\n        if (os.platform() === 'win32' && config.registryKey) {\n          try {\n            // 注意：不需要手动双写反斜杠，reg 命令会正确处理 Windows 路径\n            const regCommand = `reg add \"${config.registryKey}\" /ve /t REG_SZ /d \"${config.userManifestPath}\" /f`;\n            execSync(regCommand, { stdio: 'pipe' });\n\n            if (verifyWindowsRegistryEntry(config.registryKey, config.userManifestPath)) {\n              console.log(colorText(`✓ Registry entry created for ${config.displayName}`, 'green'));\n            } else {\n              throw new Error('Registry verification failed');\n            }\n          } catch (error: any) {\n            throw new Error(`Registry error: ${error.message}`);\n          }\n        }\n\n        successCount++;\n        results.push({ browser: config.displayName, success: true });\n        console.log(colorText(`✓ Successfully registered ${config.displayName}`, 'green'));\n      } catch (error: any) {\n        results.push({ browser: config.displayName, success: false, error: error.message });\n        console.log(\n          colorText(`✗ Failed to register ${config.displayName}: ${error.message}`, 'red'),\n        );\n      }\n    }\n\n    // 5. 报告结果\n    console.log(colorText('\\n===== Registration Summary =====', 'blue'));\n    for (const result of results) {\n      if (result.success) {\n        console.log(colorText(`✓ ${result.browser}: Success`, 'green'));\n      } else {\n        console.log(colorText(`✗ ${result.browser}: Failed - ${result.error}`, 'red'));\n      }\n    }\n\n    return successCount > 0;\n  } catch (error) {\n    console.log(\n      colorText(\n        `User-level registration failed: ${error instanceof Error ? error.message : String(error)}`,\n        'yellow',\n      ),\n    );\n    return false;\n  }\n}\n\n// 导入is-admin包（仅在Windows平台使用）\nlet isAdmin: () => boolean = () => false;\nif (process.platform === 'win32') {\n  try {\n    isAdmin = require('is-admin');\n  } catch (error) {\n    console.warn('缺少is-admin依赖，Windows平台下可能无法正确检测管理员权限');\n    console.warn(error);\n  }\n}\n\n/**\n * 使用提升权限注册系统级清单\n */\nexport async function registerWithElevatedPermissions(): Promise<void> {\n  try {\n    console.log(colorText('Attempting to register system-level manifest...', 'blue'));\n\n    // 1. 确保执行权限\n    await ensureExecutionPermissions();\n\n    // 2. 准备清单内容\n    const manifest = await createManifestContent();\n\n    // 3. 获取系统级清单路径\n    const manifestPath = getSystemManifestPath();\n\n    // 4. 创建临时清单文件\n    const tempManifestPath = path.join(os.tmpdir(), `${HOST_NAME}.json`);\n    await writeFile(tempManifestPath, JSON.stringify(manifest, null, 2));\n\n    // 5. 检测是否已经有管理员权限\n    const isRoot = process.getuid && process.getuid() === 0; // Unix/Linux/Mac\n    const hasAdminRights = process.platform === 'win32' ? isAdmin() : false; // Windows平台检测管理员权限\n    const hasElevatedPermissions = isRoot || hasAdminRights;\n\n    // 准备命令\n    const command =\n      os.platform() === 'win32'\n        ? `if not exist \"${path.dirname(manifestPath)}\" mkdir \"${path.dirname(manifestPath)}\" && copy \"${tempManifestPath}\" \"${manifestPath}\"`\n        : `mkdir -p \"${path.dirname(manifestPath)}\" && cp \"${tempManifestPath}\" \"${manifestPath}\" && chmod 644 \"${manifestPath}\"`;\n\n    if (hasElevatedPermissions) {\n      // 已经有管理员权限，直接执行命令\n      try {\n        // 创建目录\n        if (!fs.existsSync(path.dirname(manifestPath))) {\n          fs.mkdirSync(path.dirname(manifestPath), { recursive: true });\n        }\n\n        // 复制文件\n        fs.copyFileSync(tempManifestPath, manifestPath);\n\n        // 设置权限（非Windows平台）\n        if (os.platform() !== 'win32') {\n          fs.chmodSync(manifestPath, '644');\n        }\n\n        console.log(colorText('System-level manifest registration successful!', 'green'));\n      } catch (error: any) {\n        console.error(\n          colorText(`System-level manifest installation failed: ${error.message}`, 'red'),\n        );\n        throw error;\n      }\n    } else {\n      // 没有管理员权限，打印手动操作提示\n      console.log(\n        colorText('⚠️ Administrator privileges required for system-level installation', 'yellow'),\n      );\n      console.log(\n        colorText(\n          'Please run one of the following commands with administrator privileges:',\n          'blue',\n        ),\n      );\n\n      if (os.platform() === 'win32') {\n        console.log(colorText('  1. Open Command Prompt as Administrator and run:', 'blue'));\n        console.log(colorText(`     ${command}`, 'cyan'));\n      } else {\n        console.log(colorText('  1. Run with sudo:', 'blue'));\n        console.log(colorText(`     sudo ${command}`, 'cyan'));\n      }\n\n      console.log(\n        colorText('  2. Or run the registration command with elevated privileges:', 'blue'),\n      );\n      console.log(colorText(`     sudo ${COMMAND_NAME} register --system`, 'cyan'));\n\n      throw new Error('Administrator privileges required for system-level installation');\n    }\n\n    // 6. Windows特殊处理 - 设置系统级注册表\n    if (os.platform() === 'win32') {\n      const registryKey = `HKLM\\\\Software\\\\Google\\\\Chrome\\\\NativeMessagingHosts\\\\${HOST_NAME}`;\n      // 注意：不需要手动双写反斜杠，reg 命令会正确处理 Windows 路径\n      const regCommand = `reg add \"${registryKey}\" /ve /t REG_SZ /d \"${manifestPath}\" /f`;\n\n      console.log(colorText(`Creating system registry entry: ${registryKey}`, 'blue'));\n      console.log(colorText(`Manifest path: ${manifestPath}`, 'blue'));\n\n      if (hasElevatedPermissions) {\n        // 已经有管理员权限，直接执行注册表命令\n        try {\n          execSync(regCommand, { stdio: 'pipe' });\n\n          // 验证注册表项是否创建成功\n          if (verifyWindowsRegistryEntry(registryKey, manifestPath)) {\n            console.log(colorText('Windows registry entry created successfully!', 'green'));\n          } else {\n            console.log(colorText('⚠️ Registry entry created but verification failed', 'yellow'));\n          }\n        } catch (error: any) {\n          console.error(\n            colorText(`Windows registry entry creation failed: ${error.message}`, 'red'),\n          );\n          console.error(colorText(`Command: ${regCommand}`, 'red'));\n          throw error;\n        }\n      } else {\n        // 没有管理员权限，打印手动操作提示\n        console.log(\n          colorText(\n            '⚠️ Administrator privileges required for Windows registry modification',\n            'yellow',\n          ),\n        );\n        console.log(colorText('Please run the following command as Administrator:', 'blue'));\n        console.log(colorText(`  ${regCommand}`, 'cyan'));\n        console.log(colorText('Or run the registration command with elevated privileges:', 'blue'));\n        console.log(\n          colorText(\n            `  Run Command Prompt as Administrator and execute: ${COMMAND_NAME} register --system`,\n            'cyan',\n          ),\n        );\n\n        throw new Error('Administrator privileges required for Windows registry modification');\n      }\n    }\n  } catch (error: any) {\n    console.error(colorText(`注册失败: ${error.message}`, 'red'));\n    throw error;\n  }\n}\n"
  },
  {
    "path": "app/native-server/src/server/index.ts",
    "content": "/**\n * HTTP Server - Core server implementation.\n *\n * Responsibilities:\n * - Fastify instance management\n * - Plugin registration (CORS, etc.)\n * - Route delegation to specialized modules\n * - MCP transport handling\n * - Server lifecycle management\n */\nimport Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';\nimport cors from '@fastify/cors';\nimport {\n  NATIVE_SERVER_PORT,\n  TIMEOUTS,\n  SERVER_CONFIG,\n  HTTP_STATUS,\n  ERROR_MESSAGES,\n} from '../constant';\nimport { NativeMessagingHost } from '../native-messaging-host';\nimport { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\nimport { randomUUID } from 'node:crypto';\nimport { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';\nimport { getMcpServer } from '../mcp/mcp-server';\nimport { AgentStreamManager } from '../agent/stream-manager';\nimport { AgentChatService } from '../agent/chat-service';\nimport { CodexEngine } from '../agent/engines/codex';\nimport { ClaudeEngine } from '../agent/engines/claude';\nimport { closeDb } from '../agent/db';\nimport { registerAgentRoutes } from './routes';\n\n// ============================================================\n// Types\n// ============================================================\n\ninterface ExtensionRequestPayload {\n  data?: unknown;\n}\n\n// ============================================================\n// Server Class\n// ============================================================\n\nexport class Server {\n  private fastify: FastifyInstance;\n  public isRunning = false;\n  private nativeHost: NativeMessagingHost | null = null;\n  private transportsMap: Map<string, StreamableHTTPServerTransport | SSEServerTransport> =\n    new Map();\n  private agentStreamManager: AgentStreamManager;\n  private agentChatService: AgentChatService;\n\n  constructor() {\n    this.fastify = Fastify({ logger: SERVER_CONFIG.LOGGER_ENABLED });\n    this.agentStreamManager = new AgentStreamManager();\n    this.agentChatService = new AgentChatService({\n      engines: [new CodexEngine(), new ClaudeEngine()],\n      streamManager: this.agentStreamManager,\n    });\n    this.setupPlugins();\n    this.setupRoutes();\n  }\n\n  /**\n   * Associate NativeMessagingHost instance.\n   */\n  public setNativeHost(nativeHost: NativeMessagingHost): void {\n    this.nativeHost = nativeHost;\n  }\n\n  private async setupPlugins(): Promise<void> {\n    await this.fastify.register(cors, {\n      origin: (origin, cb) => {\n        // Allow requests with no origin (e.g., curl, server-to-server)\n        if (!origin) {\n          return cb(null, true);\n        }\n        // Check if origin matches any pattern in whitelist\n        const allowed = SERVER_CONFIG.CORS_ORIGIN.some((pattern) =>\n          pattern instanceof RegExp ? pattern.test(origin) : origin.startsWith(pattern),\n        );\n        cb(null, allowed);\n      },\n      methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],\n      credentials: true,\n    });\n  }\n\n  private setupRoutes(): void {\n    // Health check\n    this.setupHealthRoutes();\n\n    // Extension communication\n    this.setupExtensionRoutes();\n\n    // Agent routes (delegated to separate module)\n    registerAgentRoutes(this.fastify, {\n      streamManager: this.agentStreamManager,\n      chatService: this.agentChatService,\n    });\n\n    // MCP routes\n    this.setupMcpRoutes();\n  }\n\n  // ============================================================\n  // Health Routes\n  // ============================================================\n\n  private setupHealthRoutes(): void {\n    this.fastify.get('/ping', async (_request: FastifyRequest, reply: FastifyReply) => {\n      reply.status(HTTP_STATUS.OK).send({\n        status: 'ok',\n        message: 'pong',\n      });\n    });\n  }\n\n  // ============================================================\n  // Extension Routes\n  // ============================================================\n\n  private setupExtensionRoutes(): void {\n    this.fastify.get(\n      '/ask-extension',\n      async (request: FastifyRequest<{ Body: ExtensionRequestPayload }>, reply: FastifyReply) => {\n        if (!this.nativeHost) {\n          return reply\n            .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n            .send({ error: ERROR_MESSAGES.NATIVE_HOST_NOT_AVAILABLE });\n        }\n        if (!this.isRunning) {\n          return reply\n            .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n            .send({ error: ERROR_MESSAGES.SERVER_NOT_RUNNING });\n        }\n\n        try {\n          const extensionResponse = await this.nativeHost.sendRequestToExtensionAndWait(\n            request.query,\n            'process_data',\n            TIMEOUTS.EXTENSION_REQUEST_TIMEOUT,\n          );\n          return reply.status(HTTP_STATUS.OK).send({ status: 'success', data: extensionResponse });\n        } catch (error: unknown) {\n          const err = error as Error;\n          if (err.message.includes('timed out')) {\n            return reply\n              .status(HTTP_STATUS.GATEWAY_TIMEOUT)\n              .send({ status: 'error', message: ERROR_MESSAGES.REQUEST_TIMEOUT });\n          } else {\n            return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n              status: 'error',\n              message: `Failed to get response from extension: ${err.message}`,\n            });\n          }\n        }\n      },\n    );\n  }\n\n  // ============================================================\n  // MCP Routes\n  // ============================================================\n\n  private setupMcpRoutes(): void {\n    // SSE endpoint\n    this.fastify.get('/sse', async (_, reply) => {\n      try {\n        reply.raw.writeHead(HTTP_STATUS.OK, {\n          'Content-Type': 'text/event-stream',\n          'Cache-Control': 'no-cache',\n          Connection: 'keep-alive',\n        });\n\n        const transport = new SSEServerTransport('/messages', reply.raw);\n        this.transportsMap.set(transport.sessionId, transport);\n\n        reply.raw.on('close', () => {\n          this.transportsMap.delete(transport.sessionId);\n        });\n\n        const server = getMcpServer();\n        await server.connect(transport);\n\n        reply.raw.write(':\\n\\n');\n      } catch (error) {\n        if (!reply.sent) {\n          reply.code(HTTP_STATUS.INTERNAL_SERVER_ERROR).send(ERROR_MESSAGES.INTERNAL_SERVER_ERROR);\n        }\n      }\n    });\n\n    // SSE messages endpoint\n    this.fastify.post('/messages', async (req, reply) => {\n      try {\n        const { sessionId } = req.query as { sessionId?: string };\n        const transport = this.transportsMap.get(sessionId || '') as SSEServerTransport;\n        if (!sessionId || !transport) {\n          reply.code(HTTP_STATUS.BAD_REQUEST).send('No transport found for sessionId');\n          return;\n        }\n\n        await transport.handlePostMessage(req.raw, reply.raw, req.body);\n      } catch (error) {\n        if (!reply.sent) {\n          reply.code(HTTP_STATUS.INTERNAL_SERVER_ERROR).send(ERROR_MESSAGES.INTERNAL_SERVER_ERROR);\n        }\n      }\n    });\n\n    // MCP POST endpoint\n    this.fastify.post('/mcp', async (request, reply) => {\n      const sessionId = request.headers['mcp-session-id'] as string | undefined;\n      let transport: StreamableHTTPServerTransport | undefined = this.transportsMap.get(\n        sessionId || '',\n      ) as StreamableHTTPServerTransport;\n\n      if (transport) {\n        // Transport found, proceed\n      } else if (!sessionId && isInitializeRequest(request.body)) {\n        const newSessionId = randomUUID();\n        transport = new StreamableHTTPServerTransport({\n          sessionIdGenerator: () => newSessionId,\n          onsessioninitialized: (initializedSessionId) => {\n            if (transport && initializedSessionId === newSessionId) {\n              this.transportsMap.set(initializedSessionId, transport);\n            }\n          },\n        });\n\n        transport.onclose = () => {\n          if (transport?.sessionId && this.transportsMap.get(transport.sessionId)) {\n            this.transportsMap.delete(transport.sessionId);\n          }\n        };\n        await getMcpServer().connect(transport);\n      } else {\n        reply.code(HTTP_STATUS.BAD_REQUEST).send({ error: ERROR_MESSAGES.INVALID_MCP_REQUEST });\n        return;\n      }\n\n      try {\n        await transport.handleRequest(request.raw, reply.raw, request.body);\n      } catch (error) {\n        if (!reply.sent) {\n          reply\n            .code(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n            .send({ error: ERROR_MESSAGES.MCP_REQUEST_PROCESSING_ERROR });\n        }\n      }\n    });\n\n    // MCP GET endpoint (SSE stream)\n    this.fastify.get('/mcp', async (request, reply) => {\n      const sessionId = request.headers['mcp-session-id'] as string | undefined;\n      const transport = sessionId\n        ? (this.transportsMap.get(sessionId) as StreamableHTTPServerTransport)\n        : undefined;\n\n      if (!transport) {\n        reply.code(HTTP_STATUS.BAD_REQUEST).send({ error: ERROR_MESSAGES.INVALID_SSE_SESSION });\n        return;\n      }\n\n      reply.raw.setHeader('Content-Type', 'text/event-stream');\n      reply.raw.setHeader('Cache-Control', 'no-cache');\n      reply.raw.setHeader('Connection', 'keep-alive');\n      reply.raw.flushHeaders();\n\n      try {\n        await transport.handleRequest(request.raw, reply.raw);\n        if (!reply.sent) {\n          reply.hijack();\n        }\n      } catch (error) {\n        if (!reply.raw.writableEnded) {\n          reply.raw.end();\n        }\n      }\n\n      request.socket.on('close', () => {\n        request.log.info(`SSE client disconnected for session: ${sessionId}`);\n      });\n    });\n\n    // MCP DELETE endpoint\n    this.fastify.delete('/mcp', async (request, reply) => {\n      const sessionId = request.headers['mcp-session-id'] as string | undefined;\n      const transport = sessionId\n        ? (this.transportsMap.get(sessionId) as StreamableHTTPServerTransport)\n        : undefined;\n\n      if (!transport) {\n        reply.code(HTTP_STATUS.BAD_REQUEST).send({ error: ERROR_MESSAGES.INVALID_SESSION_ID });\n        return;\n      }\n\n      try {\n        await transport.handleRequest(request.raw, reply.raw);\n        if (!reply.sent) {\n          reply.code(HTTP_STATUS.NO_CONTENT).send();\n        }\n      } catch (error) {\n        if (!reply.sent) {\n          reply\n            .code(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n            .send({ error: ERROR_MESSAGES.MCP_SESSION_DELETION_ERROR });\n        }\n      }\n    });\n  }\n\n  // ============================================================\n  // Server Lifecycle\n  // ============================================================\n\n  public async start(port = NATIVE_SERVER_PORT, nativeHost: NativeMessagingHost): Promise<void> {\n    if (!this.nativeHost) {\n      this.nativeHost = nativeHost;\n    } else if (this.nativeHost !== nativeHost) {\n      this.nativeHost = nativeHost;\n    }\n\n    if (this.isRunning) {\n      return;\n    }\n\n    try {\n      await this.fastify.listen({ port, host: SERVER_CONFIG.HOST });\n\n      // Set port environment variables after successful listen for Chrome MCP URL resolution\n      process.env.CHROME_MCP_PORT = String(port);\n      process.env.MCP_HTTP_PORT = String(port);\n\n      this.isRunning = true;\n    } catch (err) {\n      this.isRunning = false;\n      throw err;\n    }\n  }\n\n  public async stop(): Promise<void> {\n    if (!this.isRunning) {\n      return;\n    }\n\n    try {\n      await this.fastify.close();\n      closeDb();\n      this.isRunning = false;\n    } catch (err) {\n      this.isRunning = false;\n      closeDb();\n      throw err;\n    }\n  }\n\n  public getInstance(): FastifyInstance {\n    return this.fastify;\n  }\n}\n\nconst serverInstance = new Server();\nexport default serverInstance;\n"
  },
  {
    "path": "app/native-server/src/server/routes/agent.ts",
    "content": "/**\n * Agent Routes - All agent-related HTTP endpoints.\n *\n * Handles:\n * - Projects CRUD\n * - Chat messages CRUD\n * - Chat streaming (SSE)\n * - Chat actions (act, cancel)\n * - Engine listing\n */\nimport type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';\nimport { HTTP_STATUS, ERROR_MESSAGES } from '../../constant';\nimport { AgentStreamManager } from '../../agent/stream-manager';\nimport { AgentChatService } from '../../agent/chat-service';\nimport type { AgentActRequest, AgentActResponse, RealtimeEvent } from '../../agent/types';\nimport type { CreateOrUpdateProjectInput } from '../../agent/project-types';\nimport {\n  createProjectDirectory,\n  deleteProject,\n  listProjects,\n  upsertProject,\n  validateRootPath,\n} from '../../agent/project-service';\nimport {\n  createMessage as createStoredMessage,\n  deleteMessagesByProjectId,\n  deleteMessagesBySessionId,\n  getMessagesByProjectId,\n  getMessagesCountByProjectId,\n  getMessagesBySessionId,\n  getMessagesCountBySessionId,\n} from '../../agent/message-service';\nimport {\n  createSession,\n  deleteSession,\n  getSession,\n  getSessionsByProject,\n  getSessionsByProjectAndEngine,\n  getAllSessions,\n  updateSession,\n  type CreateSessionOptions,\n  type UpdateSessionInput,\n} from '../../agent/session-service';\nimport { getProject } from '../../agent/project-service';\nimport { getDefaultWorkspaceDir, getDefaultProjectRoot } from '../../agent/storage';\nimport { openDirectoryPicker } from '../../agent/directory-picker';\nimport type { EngineName } from '../../agent/engines/types';\nimport { attachmentService } from '../../agent/attachment-service';\nimport { openProjectDirectory, openFileInVSCode } from '../../agent/open-project';\nimport type {\n  AttachmentStatsResponse,\n  AttachmentCleanupRequest,\n  AttachmentCleanupResponse,\n  OpenProjectRequest,\n  OpenProjectTarget,\n} from 'chrome-mcp-shared';\n\n// Valid engine names for validation\nconst VALID_ENGINE_NAMES: readonly EngineName[] = ['claude', 'codex', 'cursor', 'qwen', 'glm'];\n\nfunction isValidEngineName(name: string): name is EngineName {\n  return VALID_ENGINE_NAMES.includes(name as EngineName);\n}\n\n// Valid open project targets\nconst VALID_OPEN_TARGETS: readonly OpenProjectTarget[] = ['vscode', 'terminal'];\n\nfunction isValidOpenTarget(target: string): target is OpenProjectTarget {\n  return VALID_OPEN_TARGETS.includes(target as OpenProjectTarget);\n}\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface AgentRoutesOptions {\n  streamManager: AgentStreamManager;\n  chatService: AgentChatService;\n}\n\n// ============================================================\n// Route Registration\n// ============================================================\n\n/**\n * Register all agent-related routes on the Fastify instance.\n */\nexport function registerAgentRoutes(fastify: FastifyInstance, options: AgentRoutesOptions): void {\n  const { streamManager, chatService } = options;\n\n  // ============================================================\n  // Engine Routes\n  // ============================================================\n\n  fastify.get('/agent/engines', async (_request, reply) => {\n    try {\n      const engines = chatService.getEngineInfos();\n      reply.status(HTTP_STATUS.OK).send({ engines });\n    } catch (error) {\n      fastify.log.error({ err: error }, 'Failed to list agent engines');\n      if (!reply.sent) {\n        reply\n          .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n          .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    }\n  });\n\n  // ============================================================\n  // Project Routes\n  // ============================================================\n\n  fastify.get('/agent/projects', async (_request, reply) => {\n    try {\n      const projects = await listProjects();\n      reply.status(HTTP_STATUS.OK).send({ projects });\n    } catch (error) {\n      if (!reply.sent) {\n        reply\n          .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n          .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    }\n  });\n\n  fastify.post(\n    '/agent/projects',\n    async (request: FastifyRequest<{ Body: CreateOrUpdateProjectInput }>, reply: FastifyReply) => {\n      try {\n        const body = request.body;\n        if (!body || !body.name || !body.rootPath) {\n          reply\n            .status(HTTP_STATUS.BAD_REQUEST)\n            .send({ error: 'name and rootPath are required to create a project' });\n          return;\n        }\n        const project = await upsertProject(body);\n        reply.status(HTTP_STATUS.OK).send({ project });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        reply\n          .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n          .send({ error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    },\n  );\n\n  fastify.delete(\n    '/agent/projects/:id',\n    async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {\n      const { id } = request.params;\n      if (!id) {\n        reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'project id is required' });\n        return;\n      }\n      try {\n        await deleteProject(id);\n        reply.status(HTTP_STATUS.NO_CONTENT).send();\n      } catch (error) {\n        if (!reply.sent) {\n          reply\n            .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n            .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n        }\n      }\n    },\n  );\n\n  // Path validation API\n  fastify.post(\n    '/agent/projects/validate-path',\n    async (request: FastifyRequest<{ Body: { rootPath: string } }>, reply: FastifyReply) => {\n      const { rootPath } = request.body || {};\n      if (!rootPath || typeof rootPath !== 'string') {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'rootPath is required' });\n      }\n      try {\n        const result = await validateRootPath(rootPath);\n        return reply.send(result);\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });\n      }\n    },\n  );\n\n  // Create directory API\n  fastify.post(\n    '/agent/projects/create-directory',\n    async (request: FastifyRequest<{ Body: { absolutePath: string } }>, reply: FastifyReply) => {\n      const { absolutePath } = request.body || {};\n      if (!absolutePath || typeof absolutePath !== 'string') {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'absolutePath is required' });\n      }\n      try {\n        await createProjectDirectory(absolutePath);\n        return reply.send({ success: true, path: absolutePath });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: message });\n      }\n    },\n  );\n\n  // Get default workspace directory\n  fastify.get('/agent/projects/default-workspace', async (_request, reply) => {\n    try {\n      const workspaceDir = getDefaultWorkspaceDir();\n      return reply.send({ success: true, path: workspaceDir });\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });\n    }\n  });\n\n  // Get default project root for a given project name\n  fastify.post(\n    '/agent/projects/default-root',\n    async (request: FastifyRequest<{ Body: { projectName: string } }>, reply: FastifyReply) => {\n      const { projectName } = request.body || {};\n      if (!projectName || typeof projectName !== 'string') {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectName is required' });\n      }\n      try {\n        const rootPath = getDefaultProjectRoot(projectName);\n        return reply.send({ success: true, path: rootPath });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });\n      }\n    },\n  );\n\n  // Open directory picker dialog\n  fastify.post('/agent/projects/pick-directory', async (_request, reply) => {\n    try {\n      const result = await openDirectoryPicker('Select Project Directory');\n      if (result.success && result.path) {\n        return reply.send({ success: true, path: result.path });\n      } else if (result.cancelled) {\n        return reply.send({ success: false, cancelled: true });\n      } else {\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: result.error || 'Failed to open directory picker',\n        });\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({ error: message });\n    }\n  });\n\n  // ============================================================\n  // Session Routes\n  // ============================================================\n\n  // List all sessions across all projects\n  fastify.get('/agent/sessions', async (_request: FastifyRequest, reply: FastifyReply) => {\n    try {\n      const sessions = await getAllSessions();\n      return reply.status(HTTP_STATUS.OK).send({ sessions });\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      fastify.log.error({ err: error }, 'Failed to list all sessions');\n      return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n        error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n      });\n    }\n  });\n\n  // List sessions for a project\n  fastify.get(\n    '/agent/projects/:projectId/sessions',\n    async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {\n      const { projectId } = request.params;\n      if (!projectId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n      }\n\n      try {\n        const sessions = await getSessionsByProject(projectId);\n        return reply.status(HTTP_STATUS.OK).send({ sessions });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to list sessions');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Create a new session for a project\n  fastify.post(\n    '/agent/projects/:projectId/sessions',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Body: CreateSessionOptions & { engineName: string };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      const body = request.body || {};\n\n      if (!projectId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n      }\n      if (!body.engineName) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'engineName is required' });\n      }\n      if (!isValidEngineName(body.engineName)) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({\n          error: `Invalid engineName. Must be one of: ${VALID_ENGINE_NAMES.join(', ')}`,\n        });\n      }\n\n      try {\n        // Verify project exists\n        const project = await getProject(projectId);\n        if (!project) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });\n        }\n\n        const session = await createSession(projectId, body.engineName, {\n          name: body.name,\n          model: body.model,\n          permissionMode: body.permissionMode,\n          allowDangerouslySkipPermissions: body.allowDangerouslySkipPermissions,\n          systemPromptConfig: body.systemPromptConfig,\n          optionsConfig: body.optionsConfig,\n        });\n        return reply.status(HTTP_STATUS.CREATED).send({ session });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to create session');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Get a specific session\n  fastify.get(\n    '/agent/sessions/:sessionId',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      try {\n        const session = await getSession(sessionId);\n        if (!session) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });\n        }\n        return reply.status(HTTP_STATUS.OK).send({ session });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to get session');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Update a session\n  fastify.patch(\n    '/agent/sessions/:sessionId',\n    async (\n      request: FastifyRequest<{\n        Params: { sessionId: string };\n        Body: UpdateSessionInput;\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { sessionId } = request.params;\n      const updates = request.body || {};\n\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      try {\n        const existing = await getSession(sessionId);\n        if (!existing) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });\n        }\n\n        await updateSession(sessionId, updates);\n        const updated = await getSession(sessionId);\n        return reply.status(HTTP_STATUS.OK).send({ session: updated });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to update session');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Delete a session\n  fastify.delete(\n    '/agent/sessions/:sessionId',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      try {\n        await deleteSession(sessionId);\n        return reply.status(HTTP_STATUS.NO_CONTENT).send();\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to delete session');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Get message history for a session\n  fastify.get(\n    '/agent/sessions/:sessionId/history',\n    async (\n      request: FastifyRequest<{\n        Params: { sessionId: string };\n        Querystring: { limit?: string; offset?: string };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      const limitRaw = request.query.limit;\n      const offsetRaw = request.query.offset;\n      const limit = Number.parseInt(limitRaw || '', 10);\n      const offset = Number.parseInt(offsetRaw || '', 10);\n      const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 0;\n      const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;\n\n      try {\n        const session = await getSession(sessionId);\n        if (!session) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });\n        }\n\n        const [messages, totalCount] = await Promise.all([\n          getMessagesBySessionId(sessionId, safeLimit, safeOffset),\n          getMessagesCountBySessionId(sessionId),\n        ]);\n\n        return reply.status(HTTP_STATUS.OK).send({\n          success: true,\n          sessionId,\n          messages,\n          totalCount,\n          pagination: {\n            limit: safeLimit,\n            offset: safeOffset,\n            count: messages.length,\n            hasMore: safeLimit > 0 ? safeOffset + messages.length < totalCount : false,\n          },\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to get session history');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Reset a session conversation (clear messages + engineSessionId)\n  fastify.post(\n    '/agent/sessions/:sessionId/reset',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      try {\n        const existing = await getSession(sessionId);\n        if (!existing) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });\n        }\n\n        // Clear resume state first, then delete messages\n        await updateSession(sessionId, { engineSessionId: null });\n        const deletedMessages = await deleteMessagesBySessionId(sessionId);\n        const updated = await getSession(sessionId);\n\n        return reply.status(HTTP_STATUS.OK).send({\n          success: true,\n          sessionId,\n          deletedMessages,\n          clearedEngineSessionId: Boolean(existing.engineSessionId),\n          session: updated || null,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to reset session');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Get Claude management info for a session\n  fastify.get(\n    '/agent/sessions/:sessionId/claude-info',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n      }\n\n      try {\n        const session = await getSession(sessionId);\n        if (!session) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });\n        }\n\n        return reply.status(HTTP_STATUS.OK).send({\n          managementInfo: session.managementInfo || null,\n          sessionId,\n          engineName: session.engineName,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to get Claude info');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // Get aggregated Claude management info for a project\n  // Returns the most recent management info from any Claude session in the project\n  fastify.get(\n    '/agent/projects/:projectId/claude-info',\n    async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {\n      const { projectId } = request.params;\n      if (!projectId) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n      }\n\n      try {\n        const project = await getProject(projectId);\n        if (!project) {\n          return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });\n        }\n\n        // Get only Claude sessions (more efficient than fetching all and filtering)\n        const claudeSessions = await getSessionsByProjectAndEngine(projectId, 'claude');\n        const sessionsWithInfo = claudeSessions.filter((s) => s.managementInfo);\n\n        // Sort by lastUpdated in management info (fallback to session.updatedAt for old data)\n        sessionsWithInfo.sort((a, b) => {\n          const aTime = a.managementInfo?.lastUpdated || a.updatedAt || '';\n          const bTime = b.managementInfo?.lastUpdated || b.updatedAt || '';\n          return bTime.localeCompare(aTime);\n        });\n\n        const latestInfo = sessionsWithInfo[0]?.managementInfo || null;\n        const sourceSessionId = sessionsWithInfo[0]?.id;\n\n        return reply.status(HTTP_STATUS.OK).send({\n          managementInfo: latestInfo,\n          sourceSessionId,\n          projectId,\n          sessionsWithInfo: sessionsWithInfo.length,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to get project Claude info');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // ============================================================\n  // Open Project Routes\n  // ============================================================\n\n  /**\n   * POST /agent/sessions/:sessionId/open\n   * Open session's project directory in VSCode or terminal.\n   */\n  fastify.post(\n    '/agent/sessions/:sessionId/open',\n    async (\n      request: FastifyRequest<{\n        Params: { sessionId: string };\n        Body: OpenProjectRequest;\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { sessionId } = request.params;\n      const { target } = request.body || {};\n\n      if (!sessionId) {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'sessionId is required' });\n      }\n      if (!target || typeof target !== 'string') {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'target is required' });\n      }\n      if (!isValidOpenTarget(target)) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({\n          success: false,\n          error: `Invalid target. Must be one of: ${VALID_OPEN_TARGETS.join(', ')}`,\n        });\n      }\n\n      try {\n        // Get session and its project\n        const session = await getSession(sessionId);\n        if (!session) {\n          return reply\n            .status(HTTP_STATUS.NOT_FOUND)\n            .send({ success: false, error: 'Session not found' });\n        }\n\n        const project = await getProject(session.projectId);\n        if (!project) {\n          return reply\n            .status(HTTP_STATUS.NOT_FOUND)\n            .send({ success: false, error: 'Project not found' });\n        }\n\n        // Open the project directory\n        const result = await openProjectDirectory(project.rootPath, target);\n        if (result.success) {\n          return reply.status(HTTP_STATUS.OK).send({ success: true });\n        }\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: result.error,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to open session project');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  /**\n   * POST /agent/projects/:projectId/open\n   * Open project directory in VSCode or terminal.\n   */\n  fastify.post(\n    '/agent/projects/:projectId/open',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Body: OpenProjectRequest;\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      const { target } = request.body || {};\n\n      if (!projectId) {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'projectId is required' });\n      }\n      if (!target || typeof target !== 'string') {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'target is required' });\n      }\n      if (!isValidOpenTarget(target)) {\n        return reply.status(HTTP_STATUS.BAD_REQUEST).send({\n          success: false,\n          error: `Invalid target. Must be one of: ${VALID_OPEN_TARGETS.join(', ')}`,\n        });\n      }\n\n      try {\n        const project = await getProject(projectId);\n        if (!project) {\n          return reply\n            .status(HTTP_STATUS.NOT_FOUND)\n            .send({ success: false, error: 'Project not found' });\n        }\n\n        // Open the project directory\n        const result = await openProjectDirectory(project.rootPath, target);\n        if (result.success) {\n          return reply.status(HTTP_STATUS.OK).send({ success: true });\n        }\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: result.error,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to open project');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  /**\n   * POST /agent/projects/:projectId/open-file\n   * Open a file in VSCode at a specific line/column.\n   *\n   * Request body:\n   * - filePath: string (required) - File path (relative or absolute)\n   * - line?: number - Line number (1-based)\n   * - column?: number - Column number (1-based)\n   */\n  fastify.post(\n    '/agent/projects/:projectId/open-file',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Body: { filePath?: string; line?: number; column?: number };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      const { filePath, line, column } = request.body || {};\n\n      if (!projectId) {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'projectId is required' });\n      }\n      if (!filePath || typeof filePath !== 'string') {\n        return reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'filePath is required' });\n      }\n\n      try {\n        const project = await getProject(projectId);\n        if (!project) {\n          return reply\n            .status(HTTP_STATUS.NOT_FOUND)\n            .send({ success: false, error: 'Project not found' });\n        }\n\n        // Open the file in VSCode\n        const result = await openFileInVSCode(project.rootPath, filePath, line, column);\n        if (result.success) {\n          return reply.status(HTTP_STATUS.OK).send({ success: true });\n        }\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: result.error,\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to open file in VSCode');\n        return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // ============================================================\n  // Chat Message Routes\n  // ============================================================\n\n  fastify.get(\n    '/agent/chat/:projectId/messages',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Querystring: { limit?: string; offset?: string };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      if (!projectId) {\n        reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n        return;\n      }\n\n      const limitRaw = request.query.limit;\n      const offsetRaw = request.query.offset;\n      const limit = Number.parseInt(limitRaw || '', 10);\n      const offset = Number.parseInt(offsetRaw || '', 10);\n      const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 50;\n      const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;\n\n      try {\n        const [messages, totalCount] = await Promise.all([\n          getMessagesByProjectId(projectId, safeLimit, safeOffset),\n          getMessagesCountByProjectId(projectId),\n        ]);\n\n        reply.status(HTTP_STATUS.OK).send({\n          success: true,\n          data: messages,\n          totalCount,\n          pagination: {\n            limit: safeLimit,\n            offset: safeOffset,\n            count: messages.length,\n            hasMore: safeOffset + messages.length < totalCount,\n          },\n        });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to load agent chat messages');\n        reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: 'Failed to fetch messages',\n          message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  fastify.post(\n    '/agent/chat/:projectId/messages',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Body: {\n          content?: string;\n          role?: string;\n          messageType?: string;\n          conversationId?: string;\n          sessionId?: string;\n          cliSource?: string;\n          metadata?: Record<string, unknown>;\n          requestId?: string;\n          id?: string;\n          createdAt?: string;\n        };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      if (!projectId) {\n        reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n        return;\n      }\n\n      const body = request.body || {};\n      const content = typeof body.content === 'string' ? body.content.trim() : '';\n      if (!content) {\n        reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ success: false, error: 'content is required' });\n        return;\n      }\n\n      const rawRole = typeof body.role === 'string' ? body.role.toLowerCase().trim() : 'user';\n      const role: 'assistant' | 'user' | 'system' | 'tool' =\n        rawRole === 'assistant' || rawRole === 'system' || rawRole === 'tool'\n          ? (rawRole as 'assistant' | 'system' | 'tool')\n          : 'user';\n\n      const rawType = typeof body.messageType === 'string' ? body.messageType.toLowerCase() : '';\n      const allowedTypes = ['chat', 'tool_use', 'tool_result', 'status'] as const;\n      const fallbackType: (typeof allowedTypes)[number] = role === 'system' ? 'status' : 'chat';\n      const messageType =\n        (allowedTypes as readonly string[]).includes(rawType) && rawType\n          ? (rawType as (typeof allowedTypes)[number])\n          : fallbackType;\n\n      try {\n        const stored = await createStoredMessage({\n          projectId,\n          role,\n          messageType,\n          content,\n          metadata: body.metadata,\n          sessionId: body.sessionId,\n          conversationId: body.conversationId,\n          cliSource: body.cliSource,\n          requestId: body.requestId,\n          id: body.id,\n          createdAt: body.createdAt,\n        });\n\n        reply.status(HTTP_STATUS.CREATED).send({ success: true, data: stored });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to create agent chat message');\n        reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: 'Failed to create message',\n          message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  fastify.delete(\n    '/agent/chat/:projectId/messages',\n    async (\n      request: FastifyRequest<{\n        Params: { projectId: string };\n        Querystring: { conversationId?: string };\n      }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId } = request.params;\n      if (!projectId) {\n        reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });\n        return;\n      }\n\n      const { conversationId } = request.query;\n\n      try {\n        const deleted = await deleteMessagesByProjectId(projectId, conversationId || undefined);\n        reply.status(HTTP_STATUS.OK).send({ success: true, deleted });\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        fastify.log.error({ err: error }, 'Failed to delete agent chat messages');\n        reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({\n          success: false,\n          error: 'Failed to delete messages',\n          message: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,\n        });\n      }\n    },\n  );\n\n  // ============================================================\n  // Chat Streaming Routes (SSE)\n  // ============================================================\n\n  fastify.get(\n    '/agent/chat/:sessionId/stream',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n      if (!sessionId) {\n        reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ error: 'sessionId is required for agent stream' });\n        return;\n      }\n\n      try {\n        reply.raw.writeHead(HTTP_STATUS.OK, {\n          'Content-Type': 'text/event-stream',\n          'Cache-Control': 'no-cache',\n          Connection: 'keep-alive',\n        });\n\n        // Ensure client immediately receives an open event\n        reply.raw.write(':\\n\\n');\n\n        streamManager.addSseStream(sessionId, reply.raw);\n\n        const connectedEvent: RealtimeEvent = {\n          type: 'connected',\n          data: {\n            sessionId,\n            transport: 'sse',\n            timestamp: new Date().toISOString(),\n          },\n        };\n        streamManager.publish(connectedEvent);\n\n        reply.raw.on('close', () => {\n          streamManager.removeSseStream(sessionId, reply.raw);\n        });\n      } catch (error) {\n        if (!reply.sent) {\n          reply.code(HTTP_STATUS.INTERNAL_SERVER_ERROR).send(ERROR_MESSAGES.INTERNAL_SERVER_ERROR);\n        }\n      }\n    },\n  );\n\n  // ============================================================\n  // Chat Action Routes\n  // ============================================================\n\n  fastify.post(\n    '/agent/chat/:sessionId/act',\n    {\n      // Increase body limit to support image attachments (base64 encoded)\n      // Default Fastify limit is 1MB, which is too small for images\n      config: {\n        rawBody: false,\n      },\n      bodyLimit: 50 * 1024 * 1024, // 50MB to support multiple images\n    },\n    async (\n      request: FastifyRequest<{ Params: { sessionId: string }; Body: AgentActRequest }>,\n      reply: FastifyReply,\n    ) => {\n      const { sessionId } = request.params;\n      const payload = request.body;\n\n      if (!sessionId) {\n        reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ error: 'sessionId is required for agent act' });\n        return;\n      }\n\n      try {\n        const { requestId } = await chatService.handleAct(sessionId, payload);\n        const response: AgentActResponse = {\n          requestId,\n          sessionId,\n          status: 'accepted',\n        };\n        reply.status(HTTP_STATUS.OK).send(response);\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n        reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    },\n  );\n\n  // Cancel specific request\n  fastify.delete(\n    '/agent/chat/:sessionId/cancel/:requestId',\n    async (\n      request: FastifyRequest<{ Params: { sessionId: string; requestId: string } }>,\n      reply: FastifyReply,\n    ) => {\n      const { sessionId, requestId } = request.params;\n\n      if (!sessionId || !requestId) {\n        reply\n          .status(HTTP_STATUS.BAD_REQUEST)\n          .send({ error: 'sessionId and requestId are required' });\n        return;\n      }\n\n      const cancelled = chatService.cancelExecution(requestId);\n      if (cancelled) {\n        reply.status(HTTP_STATUS.OK).send({\n          success: true,\n          message: 'Execution cancelled',\n          requestId,\n          sessionId,\n        });\n      } else {\n        reply.status(HTTP_STATUS.OK).send({\n          success: false,\n          message: 'No running execution found with this requestId',\n          requestId,\n          sessionId,\n        });\n      }\n    },\n  );\n\n  // Cancel all executions for a session\n  fastify.delete(\n    '/agent/chat/:sessionId/cancel',\n    async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {\n      const { sessionId } = request.params;\n\n      if (!sessionId) {\n        reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });\n        return;\n      }\n\n      const cancelledCount = chatService.cancelSessionExecutions(sessionId);\n      reply.status(HTTP_STATUS.OK).send({\n        success: true,\n        cancelledCount,\n        sessionId,\n      });\n    },\n  );\n\n  // ============================================================\n  // Attachment Routes\n  // ============================================================\n\n  /**\n   * GET /agent/attachments/stats\n   * Get statistics for all attachment caches.\n   */\n  fastify.get('/agent/attachments/stats', async (_request, reply) => {\n    try {\n      const stats = await attachmentService.getAttachmentStats();\n\n      // Enrich with project names from database\n      const projects = await listProjects();\n      const projectMap = new Map(projects.map((p) => [p.id, p.name]));\n      const dbProjectIds = new Set(projects.map((p) => p.id));\n\n      const enrichedProjects = stats.projects.map((p) => ({\n        ...p,\n        projectName: projectMap.get(p.projectId),\n        existsInDb: dbProjectIds.has(p.projectId),\n      }));\n\n      const orphanProjectIds = stats.projects\n        .filter((p) => !dbProjectIds.has(p.projectId))\n        .map((p) => p.projectId);\n\n      const response: AttachmentStatsResponse = {\n        success: true,\n        rootDir: stats.rootDir,\n        totalFiles: stats.totalFiles,\n        totalBytes: stats.totalBytes,\n        projects: enrichedProjects,\n        orphanProjectIds,\n      };\n\n      reply.status(HTTP_STATUS.OK).send(response);\n    } catch (error) {\n      fastify.log.error({ err: error }, 'Failed to get attachment stats');\n      reply\n        .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n        .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n    }\n  });\n\n  /**\n   * GET /agent/attachments/:projectId/:filename\n   * Serve an attachment file.\n   */\n  fastify.get(\n    '/agent/attachments/:projectId/:filename',\n    async (\n      request: FastifyRequest<{ Params: { projectId: string; filename: string } }>,\n      reply: FastifyReply,\n    ) => {\n      const { projectId, filename } = request.params;\n\n      try {\n        // Validate and get file\n        const buffer = await attachmentService.readAttachment(projectId, filename);\n\n        // Determine content type from filename extension\n        const ext = filename.split('.').pop()?.toLowerCase();\n        let contentType = 'application/octet-stream';\n        switch (ext) {\n          case 'png':\n            contentType = 'image/png';\n            break;\n          case 'jpg':\n          case 'jpeg':\n            contentType = 'image/jpeg';\n            break;\n          case 'gif':\n            contentType = 'image/gif';\n            break;\n          case 'webp':\n            contentType = 'image/webp';\n            break;\n        }\n\n        reply\n          .header('Content-Type', contentType)\n          .header('Cache-Control', 'public, max-age=31536000, immutable')\n          .send(buffer);\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error);\n\n        if (message.includes('Invalid') || message.includes('traversal')) {\n          reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: message });\n          return;\n        }\n\n        // File not found or read error\n        reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Attachment not found' });\n      }\n    },\n  );\n\n  /**\n   * DELETE /agent/attachments/:projectId\n   * Clean up attachments for a specific project.\n   */\n  fastify.delete(\n    '/agent/attachments/:projectId',\n    async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {\n      const { projectId } = request.params;\n\n      try {\n        const result = await attachmentService.cleanupAttachments({ projectIds: [projectId] });\n\n        const response: AttachmentCleanupResponse = {\n          success: true,\n          scope: 'project',\n          removedFiles: result.removedFiles,\n          removedBytes: result.removedBytes,\n          results: result.results,\n        };\n\n        reply.status(HTTP_STATUS.OK).send(response);\n      } catch (error) {\n        fastify.log.error({ err: error }, 'Failed to cleanup project attachments');\n        reply\n          .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n          .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    },\n  );\n\n  /**\n   * DELETE /agent/attachments\n   * Clean up attachments for all or selected projects.\n   */\n  fastify.delete(\n    '/agent/attachments',\n    async (request: FastifyRequest<{ Body?: AttachmentCleanupRequest }>, reply: FastifyReply) => {\n      try {\n        const body = request.body;\n        const projectIds = body?.projectIds;\n\n        const result = await attachmentService.cleanupAttachments(\n          projectIds ? { projectIds } : undefined,\n        );\n\n        const scope = projectIds && projectIds.length > 0 ? 'selected' : 'all';\n\n        const response: AttachmentCleanupResponse = {\n          success: true,\n          scope,\n          removedFiles: result.removedFiles,\n          removedBytes: result.removedBytes,\n          results: result.results,\n        };\n\n        reply.status(HTTP_STATUS.OK).send(response);\n      } catch (error) {\n        fastify.log.error({ err: error }, 'Failed to cleanup attachments');\n        reply\n          .status(HTTP_STATUS.INTERNAL_SERVER_ERROR)\n          .send({ error: ERROR_MESSAGES.INTERNAL_SERVER_ERROR });\n      }\n    },\n  );\n}\n"
  },
  {
    "path": "app/native-server/src/server/routes/index.ts",
    "content": "/**\n * Routes module exports.\n */\nexport { registerAgentRoutes, type AgentRoutesOptions } from './agent';\n"
  },
  {
    "path": "app/native-server/src/server/server.test.ts",
    "content": "import { describe, expect, test, afterAll, beforeAll } from '@jest/globals';\nimport supertest from 'supertest';\nimport Server from './index';\n\ndescribe('服务器测试', () => {\n  // 启动服务器测试实例\n  beforeAll(async () => {\n    await Server.getInstance().ready();\n  });\n\n  // 关闭服务器\n  afterAll(async () => {\n    await Server.stop();\n  });\n\n  test('GET /ping 应返回正确响应', async () => {\n    const response = await supertest(Server.getInstance().server)\n      .get('/ping')\n      .expect(200)\n      .expect('Content-Type', /json/);\n\n    expect(response.body).toEqual({\n      status: 'ok',\n      message: 'pong',\n    });\n  });\n});\n"
  },
  {
    "path": "app/native-server/src/shims/devtools.d.ts",
    "content": "// Single-file shim for all deep imports from chrome-devtools-frontend.\n// This prevents TypeScript from type-checking the upstream DevTools source tree.\n// Runtime still loads the real package; this file is types-only and local to TS.\ndeclare module 'chrome-devtools-frontend/*' {\n  const anyExport: any;\n  export = anyExport;\n}\n"
  },
  {
    "path": "app/native-server/src/trace-analyzer.ts",
    "content": "import * as fs from 'fs';\n\n// Import DevTools trace engine and formatters from chrome-devtools-frontend\n// We intentionally use deep imports to match the package structure.\n// These modules are ESM and require NodeNext module resolution.\n// Types are loosely typed to minimize coupling with DevTools internals.\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore\nimport * as TraceEngine from 'chrome-devtools-frontend/front_end/models/trace/trace.js';\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore\nimport { PerformanceTraceFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js';\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore\nimport { PerformanceInsightFormatter } from 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js';\n// eslint-disable-next-line @typescript-eslint/ban-ts-comment\n// @ts-ignore\nimport { AgentFocus } from 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js';\n\nconst engine = TraceEngine.TraceModel.Model.createWithAllHandlers();\n\nfunction readJsonFile(path: string): any {\n  const text = fs.readFileSync(path, 'utf-8');\n  return JSON.parse(text);\n}\n\nexport async function parseTrace(json: any): Promise<{\n  parsedTrace: any;\n  insights: any | null;\n}> {\n  engine.resetProcessor();\n  const events = Array.isArray(json) ? json : json.traceEvents;\n  if (!events || !Array.isArray(events)) {\n    throw new Error('Invalid trace format: expected array or {traceEvents: []}');\n  }\n  await engine.parse(events);\n  const parsedTrace = engine.parsedTrace();\n  const insights = parsedTrace?.insights ?? null;\n  if (!parsedTrace) throw new Error('No parsed trace returned by engine');\n  return { parsedTrace, insights };\n}\n\nexport function getTraceSummary(parsedTrace: any): string {\n  const focus = AgentFocus.fromParsedTrace(parsedTrace);\n  const formatter = new PerformanceTraceFormatter(focus);\n  return formatter.formatTraceSummary();\n}\n\nexport function getInsightText(parsedTrace: any, insights: any, insightName: string): string {\n  if (!insights) throw new Error('No insights available for this trace');\n  const mainNavId = parsedTrace.data?.Meta?.mainFrameNavigations?.at(0)?.args?.data?.navigationId;\n  const NO_NAV = TraceEngine.Types.Events.NO_NAVIGATION;\n  const set = insights.get(mainNavId ?? NO_NAV);\n  if (!set) throw new Error('No insights for selected navigation');\n  const model = set.model || {};\n  if (!(insightName in model)) throw new Error(`Insight not found: ${insightName}`);\n  const formatter = new PerformanceInsightFormatter(\n    AgentFocus.fromParsedTrace(parsedTrace),\n    model[insightName],\n  );\n  return formatter.formatInsight();\n}\n\nexport async function analyzeTraceFile(\n  filePath: string,\n  insightName?: string,\n): Promise<{\n  summary: string;\n  insight?: string;\n}> {\n  const json = readJsonFile(filePath);\n  const { parsedTrace, insights } = await parseTrace(json);\n  const summary = getTraceSummary(parsedTrace);\n  if (insightName) {\n    try {\n      const insight = getInsightText(parsedTrace, insights, insightName);\n      return { summary, insight };\n    } catch {\n      // If requested insight missing, still return summary\n      return { summary };\n    }\n  }\n  return { summary };\n}\n\nexport default { analyzeTraceFile };\n"
  },
  {
    "path": "app/native-server/src/types/devtools-frontend.d.ts",
    "content": "// Minimal ambient declarations to avoid compiling chrome-devtools-frontend sources.\n// We intentionally treat these modules as `any` to keep our build lightweight and decoupled\n// from DevTools' internal TypeScript and lib targets.\n\ndeclare module 'chrome-devtools-frontend/front_end/models/trace/trace.js' {\n  // Shape used by our code: TraceModel + Types + Insights\n  export const TraceModel: any;\n  export const Types: any;\n  export const Insights: any;\n}\n\ndeclare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceTraceFormatter.js' {\n  export class PerformanceTraceFormatter {\n    constructor(...args: any[]);\n    formatTraceSummary(): string;\n  }\n}\n\ndeclare module 'chrome-devtools-frontend/front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.js' {\n  export class PerformanceInsightFormatter {\n    constructor(...args: any[]);\n    formatInsight(): string;\n  }\n}\n\ndeclare module 'chrome-devtools-frontend/front_end/models/ai_assistance/performance/AIContext.js' {\n  export const AgentFocus: any;\n}\n"
  },
  {
    "path": "app/native-server/src/util/logger.ts",
    "content": "// import { stderr } from 'process';\n// import * as fs from 'fs';\n// import * as path from 'path';\n\n// // 设置日志文件路径\n// const LOG_DIR = path.join(\n//   '/Users/hang/code/ai/chrome-mcp-server/app/native-server/dist/',\n//   '.debug-log',\n// ); // 使用不同目录区分\n// const LOG_FILE = path.join(\n//   LOG_DIR,\n//   `native-host-${new Date().toISOString().replace(/:/g, '-')}.log`,\n// );\n// // 确保日志目录存在\n// if (!fs.existsSync(LOG_DIR)) {\n//   try {\n//     fs.mkdirSync(LOG_DIR, { recursive: true });\n//   } catch (err) {\n//     stderr.write(`[ERROR] 创建日志目录失败: ${err}\\n`);\n//   }\n// }\n\n// // 日志函数\n// function writeLog(level: string, message: string): void {\n//   const timestamp = new Date().toISOString();\n//   const logMessage = `[${timestamp}] [${level}] ${message}\\n`;\n\n//   // 写入到文件\n//   try {\n//     fs.appendFileSync(LOG_FILE, logMessage);\n//   } catch (err) {\n//     stderr.write(`[ERROR] 写入日志失败: ${err}\\n`);\n//   }\n\n//   // 同时输出到stderr（不影响native messaging协议）\n//   stderr.write(logMessage);\n// }\n\n// // 日志级别函数\n// export const logger = {\n//   debug: (message: string) => writeLog('DEBUG', message),\n//   info: (message: string) => writeLog('INFO', message),\n//   warn: (message: string) => writeLog('WARN', message),\n//   error: (message: string) => writeLog('ERROR', message),\n// };\n"
  },
  {
    "path": "app/native-server/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2018\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"lib\": [\"ES2018\", \"DOM\"],\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"chrome-devtools-frontend/*\": [\"src/shims/devtools.d.ts\"]\n    }\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/*.spec.ts\", \"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "commitlint.config.cjs",
    "content": "module.exports = {\n  extends: ['@commitlint/config-conventional'],\n};\n"
  },
  {
    "path": "docs/ARCHITECTURE.md",
    "content": "# Chrome MCP Server Architecture 🏗️\n\nThis document provides a detailed technical overview of the Chrome MCP Server architecture, design decisions, and implementation details.\n\n## 📋 Table of Contents\n\n- [Overview](#overview)\n- [System Architecture](#system-architecture)\n- [Component Details](#component-details)\n- [Data Flow](#data-flow)\n- [AI Integration](#ai-integration)\n- [Performance Optimizations](#performance-optimizations)\n- [Security Considerations](#security-considerations)\n\n## 🎯 Overview\n\nChrome MCP Server is a sophisticated browser automation platform that bridges AI assistants with Chrome browser capabilities through the Model Context Protocol (MCP). The architecture is designed for:\n\n- **High Performance**: SIMD-optimized AI operations and efficient native messaging\n- **Extensibility**: Modular tool system for easy feature additions\n- **Reliability**: Robust error handling and graceful degradation\n- **Security**: Sandboxed execution and permission-based access control\n\n## 🏗️ System Architecture\n\n```mermaid\ngraph TB\n    subgraph \"AI Assistant Layer\"\n        A[Claude Desktop]\n        B[Custom MCP Client]\n        C[Other AI Tools]\n    end\n\n    subgraph \"MCP Protocol Layer\"\n        D[HTTP/SSE Transport]\n        E[MCP Server Instance]\n        F[Tool Registry]\n    end\n\n    subgraph \"Native Server Layer\"\n        G[Fastify HTTP Server]\n        H[Native Messaging Host]\n        I[Session Management]\n    end\n\n    subgraph \"Chrome Extension Layer\"\n        J[Background Script]\n        K[Content Scripts]\n        L[Popup Interface]\n        M[Offscreen Documents]\n    end\n\n    subgraph \"Browser APIs Layer\"\n        N[Chrome APIs]\n        O[Web APIs]\n        P[Native Messaging]\n    end\n\n    subgraph \"AI Processing Layer\"\n        Q[Semantic Engine]\n        R[Vector Database]\n        S[SIMD Math Engine]\n        T[Web Workers]\n    end\n\n    A --> D\n    B --> D\n    C --> D\n    D --> E\n    E --> F\n    F --> G\n    G --> H\n    H --> P\n    P --> J\n    J --> K\n    J --> L\n    J --> M\n    J --> N\n    J --> O\n    J --> Q\n    Q --> R\n    Q --> S\n    Q --> T\n```\n\n## 🔧 Component Details\n\n### 1. Native Server (`app/native-server/`)\n\n**Purpose**: MCP protocol implementation and native messaging bridge\n\n**Key Components**:\n\n- **Fastify HTTP Server**: Handles MCP protocol over HTTP/SSE\n- **Native Messaging Host**: Communicates with Chrome extension\n- **Session Management**: Manages multiple MCP client sessions\n- **Tool Registry**: Routes tool calls to Chrome extension\n\n**Technologies**:\n\n- TypeScript + Fastify\n- MCP SDK (@modelcontextprotocol/sdk)\n- Native messaging protocol\n\n### 2. Chrome Extension (`app/chrome-extension/`)\n\n**Purpose**: Browser automation and AI-powered content analysis\n\n**Key Components**:\n\n- **Background Script**: Main orchestrator and tool executor\n- **Content Scripts**: Page interaction and content extraction\n- **Popup Interface**: User configuration and status display\n- **Offscreen Documents**: AI model processing in isolated context\n\n**Technologies**:\n\n- WXT Framework + Vue 3\n- Chrome Extension APIs\n- WebAssembly + SIMD\n- Transformers.js\n\n### 3. Shared Packages (`packages/`)\n\n#### 3.1 Shared Types (`packages/shared/`)\n\n- Tool schemas and type definitions\n- Common interfaces and utilities\n- MCP protocol types\n\n#### 3.2 WASM SIMD (`packages/wasm-simd/`)\n\n- Rust-based SIMD-optimized math functions\n- WebAssembly compilation with Emscripten\n- 4-8x performance improvement for vector operations\n\n## 🔄 Data Flow\n\n### Tool Execution Flow\n\n```\n┌─────────────┐    ┌──────────────┐    ┌─────────────────┐    ┌──────────────┐\n│ AI Assistant│    │ Native Server│    │ Chrome Extension│    │ Browser APIs │\n└─────┬───────┘    └──────┬───────┘    └─────────┬───────┘    └──────┬───────┘\n      │                   │                      │                   │\n      │ 1. Tool Call      │                      │                   │\n      ├──────────────────►│                      │                   │\n      │                   │ 2. Native Message   │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 3. Execute Tool   │\n      │                   │                      ├──────────────────►│\n      │                   │                      │ 4. API Response   │\n      │                   │                      │◄──────────────────┤\n      │                   │ 5. Tool Result      │                   │\n      │                   │◄─────────────────────┤                   │\n      │ 6. MCP Response   │                      │                   │\n      │◄──────────────────┤                      │                   │\n```\n\n### AI Processing Flow\n\n```\n┌─────────────┐    ┌──────────────┐    ┌─────────────────┐    ┌──────────────┐\n│ Content     │    │ Text Chunker │    │ Semantic Engine │    │ Vector DB    │\n│ Extraction  │    │              │    │                 │    │              │\n└─────┬───────┘    └──────┬───────┘    └─────────┬───────┘    └──────┬───────┘\n      │                   │                      │                   │\n      │ 1. Raw Content    │                      │                   │\n      ├──────────────────►│                      │                   │\n      │                   │ 2. Text Chunks      │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 3. Embeddings     │\n      │                   │                      ├──────────────────►│\n      │                   │                      │                   │\n      │                   │ 4. Search Query     │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 5. Query Vector   │\n      │                   │                      ├──────────────────►│\n      │                   │                      │ 6. Similar Docs   │\n      │                   │                      │◄──────────────────┤\n      │                   │ 7. Search Results   │                   │\n      │                   │◄─────────────────────┤                   │\n```\n\n## 🧠 AI Integration\n\n### Semantic Similarity Engine\n\n**Architecture**:\n\n- **Model Support**: BGE-small-en-v1.5, E5-small-v2, Universal Sentence Encoder\n- **Execution Context**: Web Workers for non-blocking processing\n- **Optimization**: SIMD acceleration for vector operations\n- **Caching**: LRU cache for embeddings and tokenization\n\n**Performance Optimizations**:\n\n```typescript\n// SIMD-accelerated cosine similarity\nconst similarity = await simdMath.cosineSimilarity(vecA, vecB);\n\n// Batch processing for efficiency\nconst similarities = await simdMath.batchSimilarity(vectors, query, dimension);\n\n// Memory-efficient matrix operations\nconst matrix = await simdMath.similarityMatrix(vectorsA, vectorsB, dimension);\n```\n\n### Vector Database (hnswlib-wasm)\n\n**Features**:\n\n- **Algorithm**: Hierarchical Navigable Small World (HNSW)\n- **Implementation**: WebAssembly for near-native performance\n- **Persistence**: IndexedDB storage with automatic cleanup\n- **Scalability**: Handles 10,000+ documents efficiently\n\n**Configuration**:\n\n```typescript\nconst config: VectorDatabaseConfig = {\n  dimension: 384, // Model embedding dimension\n  maxElements: 10000, // Maximum documents\n  efConstruction: 200, // Build-time accuracy\n  M: 16, // Connectivity parameter\n  efSearch: 100, // Search-time accuracy\n  enableAutoCleanup: true, // Automatic old data removal\n  maxRetentionDays: 30, // Data retention period\n};\n```\n\n## ⚡ Performance Optimizations\n\n### 1. SIMD Acceleration\n\n**Rust Implementation**:\n\n```rust\nuse wide::f32x4;\n\nfn cosine_similarity_simd(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {\n    let len = vec_a.len();\n    let simd_lanes = 4;\n    let simd_len = len - (len % simd_lanes);\n\n    let mut dot_sum_simd = f32x4::ZERO;\n    let mut norm_a_sum_simd = f32x4::ZERO;\n    let mut norm_b_sum_simd = f32x4::ZERO;\n\n    for i in (0..simd_len).step_by(simd_lanes) {\n        let a_chunk = f32x4::new(vec_a[i..i+4].try_into().unwrap());\n        let b_chunk = f32x4::new(vec_b[i..i+4].try_into().unwrap());\n\n        dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);\n        norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);\n        norm_b_sum_simd = b_chunk.mul_add(b_chunk, norm_b_sum_simd);\n    }\n\n    // Calculate final similarity\n    let dot_product = dot_sum_simd.reduce_add();\n    let norm_a = norm_a_sum_simd.reduce_add().sqrt();\n    let norm_b = norm_b_sum_simd.reduce_add().sqrt();\n\n    dot_product / (norm_a * norm_b)\n}\n```\n\n### 2. Memory Management\n\n**Strategies**:\n\n- **Object Pooling**: Reuse Float32Array buffers\n- **Lazy Loading**: Load AI models on-demand\n- **Cache Management**: LRU eviction for embeddings\n- **Garbage Collection**: Explicit cleanup of large objects\n\n### 3. Concurrent Processing\n\n**Web Workers**:\n\n- **AI Processing**: Separate worker for model inference\n- **Content Indexing**: Background indexing of tab content\n- **Network Capture**: Parallel request processing\n\n## 🔧 Extension Points\n\n### Adding New Tools\n\n1. **Define Schema** in `packages/shared/src/tools.ts`\n2. **Implement Tool** extending `BaseBrowserToolExecutor`\n3. **Register Tool** in tool index\n4. **Add Tests** for functionality\n\n### Custom AI Models\n\n1. **Model Integration** in `SemanticSimilarityEngine`\n2. **Worker Support** for processing\n3. **Configuration** in model presets\n4. **Performance Testing** with benchmarks\n\n### Protocol Extensions\n\n1. **MCP Extensions** for custom capabilities\n2. **Transport Layers** for different communication methods\n3. **Authentication** for secure connections\n4. **Monitoring** for performance metrics\n\nThis architecture enables Chrome MCP Server to deliver high-performance browser automation with advanced AI capabilities while maintaining security and extensibility.\n"
  },
  {
    "path": "docs/ARCHITECTURE_zh.md",
    "content": "# Chrome MCP Server 架构设计 🏗️\n\n本文档提供 Chrome MCP Server 架构、设计决策和实现细节的详细技术概述。\n\n## 📋 目录\n\n- [概述](#概述)\n- [系统架构](#系统架构)\n- [组件详情](#组件详情)\n- [数据流](#数据流)\n- [AI 集成](#ai-集成)\n- [性能优化](#性能优化)\n- [安全考虑](#安全考虑)\n\n## 🎯 概述\n\nChrome MCP Server 是一个复杂的浏览器自动化平台，通过模型上下文协议 (MCP) 将 AI 助手与 Chrome 浏览器功能连接起来。架构设计目标：\n\n- **高性能**：SIMD 优化的 AI 操作和高效的原生消息传递\n- **可扩展性**：模块化工具系统，便于添加新功能\n- **可靠性**：强大的错误处理和优雅降级\n- **安全性**：沙盒执行和基于权限的访问控制\n\n## 🏗️ 系统架构\n\n```mermaid\ngraph TB\n    subgraph \"AI 助手层\"\n        A[Claude Desktop]\n        B[自定义 MCP 客户端]\n        C[其他 AI 工具]\n    end\n\n    subgraph \"MCP 协议层\"\n        D[HTTP/SSE 传输]\n        E[MCP 服务器实例]\n        F[工具注册表]\n    end\n\n    subgraph \"原生服务器层\"\n        G[Fastify HTTP 服务器]\n        H[原生消息主机]\n        I[会话管理]\n    end\n\n    subgraph \"Chrome 扩展层\"\n        J[后台脚本]\n        K[内容脚本]\n        L[弹窗界面]\n        M[离屏文档]\n    end\n\n    subgraph \"浏览器 APIs 层\"\n        N[Chrome APIs]\n        O[Web APIs]\n        P[原生消息]\n    end\n\n    subgraph \"AI 处理层\"\n        Q[语义引擎]\n        R[向量数据库]\n        S[SIMD 数学引擎]\n        T[Web Workers]\n    end\n\n    A --> D\n    B --> D\n    C --> D\n    D --> E\n    E --> F\n    F --> G\n    G --> H\n    H --> P\n    P --> J\n    J --> K\n    J --> L\n    J --> M\n    J --> N\n    J --> O\n    J --> Q\n    Q --> R\n    Q --> S\n    Q --> T\n```\n\n## 🔧 组件详情\n\n### 1. 原生服务器 (`app/native-server/`)\n\n**目的**：MCP 协议实现和原生消息桥接\n\n**核心组件**：\n\n- **Fastify HTTP 服务器**：处理基于 HTTP/SSE 的 MCP 协议\n- **原生消息主机**：与 Chrome 扩展通信\n- **会话管理**：管理多个 MCP 客户端会话\n- **工具注册表**：将工具调用路由到 Chrome 扩展\n\n**技术栈**：\n\n- TypeScript + Fastify\n- MCP SDK (@modelcontextprotocol/sdk)\n- 原生消息协议\n\n### 2. Chrome 扩展 (`app/chrome-extension/`)\n\n**目的**：浏览器自动化和 AI 驱动的内容分析\n\n**核心组件**：\n\n- **后台脚本**：主要协调器和工具执行器\n- **内容脚本**：页面交互和内容提取\n- **弹窗界面**：用户配置和状态显示\n- **离屏文档**：在隔离环境中进行 AI 模型处理\n\n**技术栈**：\n\n- WXT 框架 + Vue 3\n- Chrome 扩展 APIs\n- WebAssembly + SIMD\n- Transformers.js\n\n### 3. 共享包 (`packages/`)\n\n#### 3.1 共享类型 (`packages/shared/`)\n\n- 工具模式和类型定义\n- 通用接口和工具\n- MCP 协议类型\n\n#### 3.2 WASM SIMD (`packages/wasm-simd/`)\n\n- 基于 Rust 的 SIMD 优化数学函数\n- 使用 Emscripten 编译 WebAssembly\n- 向量运算性能提升 4-8 倍\n\n## 🔄 数据流\n\n### 工具执行流程\n\n```\n┌─────────────┐    ┌──────────────┐    ┌─────────────────┐    ┌──────────────┐\n│ AI 助手     │    │ 原生服务器   │    │ Chrome 扩展     │    │ 浏览器 APIs  │\n└─────┬───────┘    └──────┬───────┘    └─────────┬───────┘    └──────┬───────┘\n      │                   │                      │                   │\n      │ 1. 工具调用       │                      │                   │\n      ├──────────────────►│                      │                   │\n      │                   │ 2. 原生消息          │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 3. 执行工具       │\n      │                   │                      ├──────────────────►│\n      │                   │                      │ 4. API 响应       │\n      │                   │                      │◄──────────────────┤\n      │                   │ 5. 工具结果          │                   │\n      │                   │◄─────────────────────┤                   │\n      │ 6. MCP 响应       │                      │                   │\n      │◄──────────────────┤                      │                   │\n```\n\n### AI 处理流程\n\n```\n┌─────────────┐    ┌──────────────┐    ┌─────────────────┐    ┌──────────────┐\n│ 内容提取    │    │ 文本分块器   │    │ 语义引擎        │    │ 向量数据库   │\n└─────┬───────┘    └──────┬───────┘    └─────────┬───────┘    └──────┬───────┘\n      │                   │                      │                   │\n      │ 1. 原始内容       │                      │                   │\n      ├──────────────────►│                      │                   │\n      │                   │ 2. 文本块            │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 3. 嵌入向量       │\n      │                   │                      ├──────────────────►│\n      │                   │                      │                   │\n      │                   │ 4. 搜索查询          │                   │\n      │                   ├─────────────────────►│                   │\n      │                   │                      │ 5. 查询向量       │\n      │                   │                      ├──────────────────►│\n      │                   │                      │ 6. 相似文档       │\n      │                   │                      │◄──────────────────┤\n      │                   │ 7. 搜索结果          │                   │\n      │                   │◄─────────────────────┤                   │\n```\n\n## 🧠 AI 集成\n\n### 语义相似度引擎\n\n**架构**：\n\n- **模型支持**：BGE-small-en-v1.5、E5-small-v2、Universal Sentence Encoder\n- **执行环境**：Web Workers 用于非阻塞处理\n- **优化**：向量运算的 SIMD 加速\n- **缓存**：嵌入和分词的 LRU 缓存\n\n**性能优化**：\n\n```typescript\n// SIMD 加速的余弦相似度\nconst similarity = await simdMath.cosineSimilarity(vecA, vecB);\n\n// 批处理提高效率\nconst similarities = await simdMath.batchSimilarity(vectors, query, dimension);\n\n// 内存高效的矩阵运算\nconst matrix = await simdMath.similarityMatrix(vectorsA, vectorsB, dimension);\n```\n\n### 向量数据库 (hnswlib-wasm)\n\n**特性**：\n\n- **算法**：分层导航小世界 (HNSW)\n- **实现**：WebAssembly 实现接近原生性能\n- **持久化**：IndexedDB 存储，自动清理\n- **可扩展性**：高效处理 10,000+ 文档\n\n**配置**：\n\n```typescript\nconst config: VectorDatabaseConfig = {\n  dimension: 384, // 模型嵌入维度\n  maxElements: 10000, // 最大文档数\n  efConstruction: 200, // 构建时精度\n  M: 16, // 连接参数\n  efSearch: 100, // 搜索时精度\n  enableAutoCleanup: true, // 自动清理旧数据\n  maxRetentionDays: 30, // 数据保留期\n};\n```\n\n## ⚡ 性能优化\n\n### 1. SIMD 加速\n\n**Rust 实现**：\n\n```rust\nuse wide::f32x4;\n\nfn cosine_similarity_simd(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {\n    let len = vec_a.len();\n    let simd_lanes = 4;\n    let simd_len = len - (len % simd_lanes);\n\n    let mut dot_sum_simd = f32x4::ZERO;\n    let mut norm_a_sum_simd = f32x4::ZERO;\n    let mut norm_b_sum_simd = f32x4::ZERO;\n\n    for i in (0..simd_len).step_by(simd_lanes) {\n        let a_chunk = f32x4::new(vec_a[i..i+4].try_into().unwrap());\n        let b_chunk = f32x4::new(vec_b[i..i+4].try_into().unwrap());\n\n        dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);\n        norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);\n        norm_b_sum_simd = b_chunk.mul_add(b_chunk, norm_b_sum_simd);\n    }\n\n    // 计算最终相似度\n    let dot_product = dot_sum_simd.reduce_add();\n    let norm_a = norm_a_sum_simd.reduce_add().sqrt();\n    let norm_b = norm_b_sum_simd.reduce_add().sqrt();\n\n    dot_product / (norm_a * norm_b)\n}\n```\n\n### 2. 内存管理\n\n**策略**：\n\n- **对象池**：重用 Float32Array 缓冲区\n- **延迟加载**：按需加载 AI 模型\n- **缓存管理**：嵌入的 LRU 淘汰\n- **垃圾回收**：显式清理大对象\n\n### 3. 并发处理\n\n**Web Workers**：\n\n- **AI 处理**：模型推理的独立 worker\n- **内容索引**：后台标签页内容索引\n- **网络捕获**：并行请求处理\n\n## 🔧 扩展点\n\n### 添加新工具\n\n1. **定义模式** 在 `packages/shared/src/tools.ts`\n2. **实现工具** 继承 `BaseBrowserToolExecutor`\n3. **注册工具** 在工具索引中\n4. **添加测试** 用于功能测试\n\n### 自定义 AI 模型\n\n1. **模型集成** 在 `SemanticSimilarityEngine`\n2. **Worker 支持** 用于处理\n3. **配置** 在模型预设中\n4. **性能测试** 使用基准测试\n\n### 协议扩展\n\n1. **MCP 扩展** 用于自定义功能\n2. **传输层** 用于不同通信方法\n3. **身份验证** 用于安全连接\n4. **监控** 用于性能指标\n\n此架构使 Chrome MCP Server 能够在保持安全性和可扩展性的同时，提供高性能的浏览器自动化和先进的 AI 功能。\n"
  },
  {
    "path": "docs/CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [v0.0.5]\n\n### Improved\n\n- **Image Compression**: Compress base64 images when using screenshot tool\n- **Interactive Elements Detection Optimization**: Enhanced interactive elements detection tool with expanded search scope, now supports finding interactive div elements\n\n## [v0.0.4]\n\n### Added\n\n- **STDIO Connection Support**: Added support for connecting to the MCP server via standard input/output (stdio) method\n- **Console Output Capture Tool**: New `chrome_console` tool for capturing browser console output\n\n## [v0.0.3]\n\n### Added\n\n- **Inject script tool**: For injecting content scripts into web page\n- **Send command to inject script tool**: For sending commands to the injected script\n\n## [v0.0.2]\n\n### Added\n\n- **Conditional Semantic Engine Initialization**: Smart cache-based initialization that only loads models when cached versions are available\n- **Enhanced Model Cache Management**: Comprehensive cache management system with automatic cleanup and size limits\n- **Windows Platform Compatibility**: Full support for Windows Chrome Native Messaging with registry-based manifest detection\n- **Cache Statistics and Manual Management**: User interface for viewing cache stats and manual cache cleanup\n- **Concurrent Initialization Protection**: Prevents duplicate initialization attempts across components\n\n### Improved\n\n- **Startup Performance**: Dramatically reduced startup time when no model cache exists (from ~3s to ~0.5s)\n- **Memory Usage**: Optimized memory consumption through on-demand model loading\n- **Cache Expiration Logic**: Intelligent cache expiration (14 days) with automatic cleanup\n- **Error Handling**: Enhanced error handling for model initialization failures\n- **Component Coordination**: Simplified initialization flow between semantic engine and content indexer\n\n### Fixed\n\n- **Windows Native Host Issues**: Resolved Node.js environment conflicts with multiple NVM installations\n- **Race Condition Prevention**: Eliminated concurrent initialization attempts that could cause conflicts\n- **Cache Size Management**: Automatic cleanup when cache exceeds 500MB limit\n- **Model Download Optimization**: Prevents unnecessary model downloads during plugin startup\n\n### Technical Improvements\n\n- **ModelCacheManager**: Added `isModelCached()` and `hasAnyValidCache()` methods for cache detection\n- **SemanticSimilarityEngine**: Added cache checking functions and conditional initialization logic\n- **Background Script**: Implemented smart initialization based on cache availability\n- **VectorSearchTool**: Simplified to passive initialization model\n- **ContentIndexer**: Enhanced with semantic engine readiness checks\n\n### Documentation\n\n- Added comprehensive conditional initialization documentation\n- Updated cache management system documentation\n- Created troubleshooting guides for Windows platform issues\n\n## [v0.0.1]\n\n### Added\n\n- **Core Browser Tools**: Complete set of browser automation tools for web interaction\n\n  - **Click Tool**: Intelligent element clicking with coordinate and selector support\n  - **Fill Tool**: Form filling with text input and selection capabilities\n  - **Screenshot Tool**: Full page and element-specific screenshot capture\n  - **Navigation Tools**: URL navigation and page interaction utilities\n  - **Keyboard Tool**: Keyboard input simulation and hotkey support\n\n- **Vector Search Engine**: Advanced semantic search capabilities\n\n  - **Content Indexing**: Automatic indexing of browser tab content\n  - **Semantic Similarity**: AI-powered text similarity matching\n  - **Vector Database**: Efficient storage and retrieval of embeddings\n  - **Multi-language Support**: Comprehensive multilingual text processing\n\n- **Native Host Integration**: Seamless communication with external applications\n\n  - **Chrome Native Messaging**: Bidirectional communication channel\n  - **Cross-platform Support**: Windows, macOS, and Linux compatibility\n  - **Message Protocol**: Structured messaging system for tool execution\n\n- **AI Model Integration**: State-of-the-art language models for semantic processing\n\n  - **Transformer Models**: Support for multiple pre-trained models\n  - **ONNX Runtime**: Optimized model inference with WebAssembly\n  - **Model Management**: Dynamic model loading and switching\n  - **Performance Optimization**: SIMD acceleration and memory pooling\n\n- **User Interface**: Intuitive popup interface for extension management\n  - **Model Selection**: Easy switching between different AI models\n  - **Status Monitoring**: Real-time initialization and download progress\n  - **Settings Management**: User preferences and configuration options\n  - **Cache Management**: Visual cache statistics and cleanup controls\n\n### Technical Foundation\n\n- **Extension Architecture**: Robust Chrome extension with background scripts and content injection\n- **Worker-based Processing**: Offscreen document for heavy computational tasks\n- **Memory Management**: LRU caching and efficient resource utilization\n- **Error Handling**: Comprehensive error reporting and recovery mechanisms\n- **TypeScript Implementation**: Full type safety and modern JavaScript features\n\n### Initial Features\n\n- Multi-tab content analysis and search\n- Real-time semantic similarity computation\n- Automated web page interaction\n- Cross-platform native messaging\n- Extensible tool framework for future enhancements\n"
  },
  {
    "path": "docs/CONTRIBUTING.md",
    "content": "# Contributing Guide 🤝\n\nThank you for your interest in contributing to Chrome MCP Server! This document provides guidelines and information for contributors.\n\n## 🎯 How to Contribute\n\nWe welcome contributions in many forms:\n\n- 🐛 Bug reports and fixes\n- ✨ New features and tools\n- 📚 Documentation improvements\n- 🧪 Tests and performance optimizations\n- 🌐 Translations and internationalization\n- 💡 Ideas and suggestions\n\n## 🚀 Getting Started\n\n### Prerequisites\n\n- **Node.js 20+** and **pnpm or npm** (latest version)\n- **Chrome/Chromium** browser for testing\n- **Git** for version control\n- **Rust** (for WASM development, optional)\n- **TypeScript** knowledge\n\n### Development Setup\n\n1. **Fork and clone the repository**\n\n```bash\ngit clone https://github.com/YOUR_USERNAME/chrome-mcp-server.git\ncd chrome-mcp-server\n```\n\n2. **Install dependencies**\n\n```bash\npnpm install\n```\n\n3. **Start the project**\n\n```bash\nnpm run dev\n```\n\n4. **Load the extension in Chrome**\n   - Open `chrome://extensions/`\n   - Enable \"Developer mode\"\n   - Click \"Load unpacked\" and select `your/extension/dist`\n\n## 🏗️ Project Structure\n\n```\nchrome-mcp-server/\n├── app/\n│   ├── chrome-extension/     # Chrome extension (WXT + Vue 3)\n│   │   ├── entrypoints/      # Background scripts, popup, content scripts\n│   │   ├── utils/            # AI models, vector database, utilities\n│   │   └── workers/          # Web Workers for AI processing\n│   └── native-server/        # Native messaging server (Fastify + TypeScript)\n│       ├── src/mcp/          # MCP protocol implementation\n│       └── src/server/       # HTTP server and native messaging\n├── packages/\n│   ├── shared/               # Shared types and utilities\n│   └── wasm-simd/           # SIMD-optimized WebAssembly math functions\n└── docs/                    # Documentation\n```\n\n## 🛠️ Development Workflow\n\n### Adding New Tools\n\n1. **Define the tool schema in `packages/shared/src/tools.ts`**:\n\n```typescript\n{\n  name: 'your_new_tool',\n  description: 'Description of what your tool does',\n  inputSchema: {\n    type: 'object',\n    properties: {\n      // Define parameters\n    },\n    required: ['param1']\n  }\n}\n```\n\n2. **Implement the tool in `app/chrome-extension/entrypoints/background/tools/browser/`**:\n\n```typescript\nclass YourNewTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.YOUR_NEW_TOOL;\n\n  async execute(args: YourToolParams): Promise<ToolResult> {\n    // Implementation\n  }\n}\n```\n\n3. **Export the tool in `app/chrome-extension/entrypoints/background/tools/browser/index.ts`**\n\n4. **Add tests in the appropriate test directory**\n\n### Code Style Guidelines\n\n- **TypeScript**: Use strict TypeScript with proper typing\n- **ESLint**: Follow the configured ESLint rules (`pnpm lint`)\n- **Prettier**: Format code with Prettier (`pnpm format`)\n- **Naming**: Use descriptive names and follow existing patterns\n- **Comments**: Add JSDoc comments for public APIs\n- **Error Handling**: Always handle errors gracefully\n\n## 📝 Pull Request Process\n\n1. **Create a feature branch**\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n2. **Make your changes**\n   - Follow the code style guidelines\n   - Add tests for new functionality\n   - Update documentation if needed\n\n3. **Test your changes**\n   - Ensure all existing tests pass\n   - Test the Chrome extension manually\n   - Verify MCP protocol compatibility\n\n4. **Commit your changes**\n\n```bash\ngit add .\ngit commit -m \"feat: add your feature description\"\n```\n\nWe use [Conventional Commits](https://www.conventionalcommits.org/):\n\n- `feat:` for new features\n- `fix:` for bug fixes\n- `docs:` for documentation changes\n- `test:` for adding tests\n- `refactor:` for code refactoring\n\n5. **Push and create a Pull Request**\n\n```bash\ngit push origin feature/your-feature-name\n```\n\n## 🐛 Bug Reports\n\nWhen reporting bugs, please include:\n\n- **Environment**: OS, Chrome version, Node.js version\n- **Steps to reproduce**: Clear, step-by-step instructions\n- **Expected behavior**: What should happen\n- **Actual behavior**: What actually happens\n- **Screenshots/logs**: If applicable\n- **MCP client**: Which MCP client you're using (Claude Desktop, etc.)\n\n## 💡 Feature Requests\n\nFor feature requests, please provide:\n\n- **Use case**: Why is this feature needed?\n- **Proposed solution**: How should it work?\n- **Alternatives**: Any alternative solutions considered?\n- **Additional context**: Screenshots, examples, etc.\n\n## 🔧 Development Tips\n\n### Using WASM SIMD\n\nIf you're contributing to the WASM SIMD package:\n\n```bash\ncd packages/wasm-simd\n# Install Rust and wasm-pack if not already installed\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\ncargo install wasm-pack\n\n# Build WASM package\npnpm build\n\n# The built files will be copied to app/chrome-extension/workers/\n```\n\n### Debugging Chrome Extension\n\n- Use Chrome DevTools for debugging extension popup and background scripts\n- Check `chrome://extensions/` for extension errors\n- Use `console.log` statements for debugging\n- Monitor the native messaging connection in the background script\n\n### Testing MCP Protocol\n\n- Use MCP Inspector for protocol debugging\n- Test with different MCP clients (Claude Desktop, custom clients)\n- Verify tool schemas and responses match MCP specifications\n\n## 📚 Resources\n\n- [Model Context Protocol Specification](https://modelcontextprotocol.io/)\n- [Chrome Extension Development](https://developer.chrome.com/docs/extensions/)\n- [WXT Framework Documentation](https://wxt.dev/)\n- [TypeScript Handbook](https://www.typescriptlang.org/docs/)\n\n## 🤝 Community\n\n- **GitHub Issues**: For bug reports and feature requests\n- **GitHub Discussions**: For questions and general discussion\n- **Pull Requests**: For code contributions\n\n## 📄 License\n\nBy contributing to Chrome MCP Server, you agree that your contributions will be licensed under the MIT License.\n\n## 🎯 Contributor Guidelines\n\n### New Contributors\n\nIf you're contributing to an open source project for the first time:\n\n1. **Start small**: Look for issues labeled \"good first issue\"\n2. **Read the code**: Familiarize yourself with the project structure and coding style\n3. **Ask questions**: Ask questions in GitHub Discussions\n4. **Learn the tools**: Get familiar with Git, GitHub, TypeScript, and other tools\n\n### Experienced Contributors\n\n- **Architecture improvements**: Propose system-level improvements\n- **Performance optimization**: Identify and fix performance bottlenecks\n- **New features**: Design and implement complex new features\n- **Mentor newcomers**: Help new contributors get started\n\n### Documentation Contributions\n\n- **API documentation**: Improve tool documentation and examples\n- **Tutorials**: Create usage guides and best practices\n- **Translations**: Help translate documentation to other languages\n- **Video content**: Create demo videos and tutorials\n\n### Testing Contributions\n\n- **Unit tests**: Write tests for new features\n- **Integration tests**: Test interactions between components\n- **Performance tests**: Benchmark testing and performance regression detection\n- **User testing**: Functional testing in real-world scenarios\n\n## 🏆 Contributor Recognition\n\nWe value every contribution, no matter how big or small. Contributors will be recognized in the following ways:\n\n- **README acknowledgments**: Contributors listed in the project README\n- **Release notes**: Contributors thanked in version release notes\n- **Contributor badges**: Contributor badges on GitHub profiles\n- **Community recognition**: Special thanks in community discussions\n\nThank you for considering contributing to Chrome MCP Server! Your participation makes this project better.\n"
  },
  {
    "path": "docs/CONTRIBUTING_zh.md",
    "content": "# 贡献指南 🤝\n\n感谢您对 Chrome MCP Server 项目的贡献兴趣！本文档为贡献者提供指南和信息。\n\n## 🎯 如何贡献\n\n我们欢迎多种形式的贡献：\n\n- 🐛 错误报告和修复\n- ✨ 新功能和工具\n- 📚 文档改进\n- 🧪 测试和性能优化\n- 🌐 翻译和国际化\n- 💡 想法和建议\n\n## 🚀 开始贡献\n\n### 环境要求\n\n- **Node.js 20+** 和 **pnpm**（最新版本）\n- **Chrome/Chromium** 浏览器用于测试\n- **Git** 版本控制\n- **Rust**（用于 WASM 开发，可选）\n- **TypeScript** 知识\n\n### 开发环境设置\n\n1. **Fork 并克隆仓库**\n\n```bash\ngit clone https://github.com/YOUR_USERNAME/chrome-mcp-server.git\ncd chrome-mcp-server\n```\n\n2. **安装依赖**\n\n```bash\npnpm install\n```\n\n3. **启动项目**\n\n```bash\nnpm run dev\n```\n\n4. **在 Chrome 中加载扩展**\n   - 打开 `chrome://extensions/`\n   - 启用\"开发者模式\"\n   - 点击\"加载已解压的扩展程序\"，选择 `your/extension/dist`\n\n## 🏗️ 项目结构\n\n```\nchrome-mcp-server/\n├── app/\n│   ├── chrome-extension/     # Chrome 扩展 (WXT + Vue 3)\n│   │   ├── entrypoints/      # 后台脚本、弹窗、内容脚本\n│   │   ├── utils/            # AI 模型、向量数据库、工具\n│   │   └── workers/          # 用于 AI 处理的 Web Workers\n│   └── native-server/        # 原生消息服务器 (Fastify + TypeScript)\n│       ├── src/mcp/          # MCP 协议实现\n│       └── src/server/       # HTTP 服务器和原生消息\n├── packages/\n│   ├── shared/               # 共享类型和工具\n│   └── wasm-simd/           # SIMD 优化的 WebAssembly 数学函数\n└── docs/                    # 文档\n```\n\n## 🛠️ 开发工作流\n\n### 添加新工具\n\n1. **在 `packages/shared/src/tools.ts` 中定义工具模式**：\n\n```typescript\n{\n  name: 'your_new_tool',\n  description: '描述您的工具功能',\n  inputSchema: {\n    type: 'object',\n    properties: {\n      // 定义参数\n    },\n    required: ['param1']\n  }\n}\n```\n\n2. **在 `app/chrome-extension/entrypoints/background/tools/browser/` 中实现工具**：\n\n```typescript\nclass YourNewTool extends BaseBrowserToolExecutor {\n  name = TOOL_NAMES.BROWSER.YOUR_NEW_TOOL;\n\n  async execute(args: YourToolParams): Promise<ToolResult> {\n    // 实现\n  }\n}\n```\n\n3. **在 `app/chrome-extension/entrypoints/background/tools/browser/index.ts` 中导出工具**\n\n4. **在相应的测试目录中添加测试**\n\n### 代码风格指南\n\n- **TypeScript**：使用严格的 TypeScript 和适当的类型\n- **ESLint**：遵循配置的 ESLint 规则（`pnpm lint`）\n- **Prettier**：使用 Prettier 格式化代码（`pnpm format`）\n- **命名**：使用描述性名称并遵循现有模式\n- **注释**：为公共 API 添加 JSDoc 注释\n- **错误处理**：始终优雅地处理错误\n\n## 📝 Pull Request 流程\n\n1. **创建功能分支**\n\n```bash\ngit checkout -b feature/your-feature-name\n```\n\n2. **进行更改**\n   - 遵循代码风格指南\n   - 为新功能添加测试\n   - 如需要，更新文档\n\n3. **测试您的更改**\n   - 确保所有现有测试通过\n   - 手动测试 Chrome 扩展\n   - 验证 MCP 协议兼容性\n\n4. **提交您的更改**\n\n```bash\ngit add .\ngit commit -m \"feat: 添加您的功能描述\"\n```\n\n我们使用 [约定式提交](https://www.conventionalcommits.org/)：\n\n- `feat:` 用于新功能\n- `fix:` 用于错误修复\n- `docs:` 用于文档更改\n- `test:` 用于添加测试\n- `refactor:` 用于代码重构\n\n5. **推送并创建 Pull Request**\n\n```bash\ngit push origin feature/your-feature-name\n```\n\n## 🐛 错误报告\n\n报告错误时，请包含：\n\n- **环境**：操作系统、Chrome 版本、Node.js 版本\n- **重现步骤**：清晰的分步说明\n- **预期行为**：应该发生什么\n- **实际行为**：实际发生了什么\n- **截图/日志**：如果适用\n- **MCP 客户端**：您使用的 MCP 客户端（Claude Desktop 等）\n\n## 💡 功能请求\n\n对于功能请求，请提供：\n\n- **用例**：为什么需要这个功能？\n- **建议解决方案**：它应该如何工作？\n- **替代方案**：考虑过的任何替代解决方案？\n- **附加上下文**：截图、示例等\n\n## 🔧 开发技巧\n\n### 使用 WASM SIMD\n\n如果您要为 WASM SIMD 包做贡献：\n\n```bash\ncd packages/wasm-simd\n# 如果尚未安装，请安装 Rust 和 wasm-pack\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\ncargo install wasm-pack\n\n# 构建 WASM 包\npnpm build\n\n# 构建的文件将复制到 app/chrome-extension/workers/\n```\n\n### 调试 Chrome 扩展\n\n- 使用 Chrome DevTools 调试扩展弹窗和后台脚本\n- 检查 `chrome://extensions/` 查看扩展错误\n- 使用 `console.log` 语句进行调试\n- 在后台脚本中监控原生消息连接\n\n### 测试 MCP 协议\n\n- 使用 MCP Inspector 进行协议调试\n- 使用不同的 MCP 客户端测试（Claude Desktop、自定义客户端）\n- 验证工具模式和响应符合 MCP 规范\n\n## 📚 资源\n\n- [模型上下文协议规范](https://modelcontextprotocol.io/)\n- [Chrome 扩展开发](https://developer.chrome.com/docs/extensions/)\n- [WXT 框架文档](https://wxt.dev/)\n- [TypeScript 手册](https://www.typescriptlang.org/docs/)\n\n## 🤝 社区\n\n- **GitHub Issues**：用于错误报告和功能请求\n- **GitHub Discussions**：用于问题和一般讨论\n- **Pull Requests**：用于代码贡献\n\n## 📄 许可证\n\n通过为 Chrome MCP Server 做贡献，您同意您的贡献将在 MIT 许可证下获得许可。\n\n## 🎯 贡献者指南\n\n### 新手贡献者\n\n如果您是第一次为开源项目做贡献：\n\n1. **从小处开始**：寻找标有 \"good first issue\" 的问题\n2. **阅读代码**：熟悉项目结构和编码风格\n3. **提问**：在 GitHub Discussions 中提出问题\n4. **学习工具**：了解 Git、GitHub、TypeScript 等工具\n\n### 经验丰富的贡献者\n\n- **架构改进**：提出系统级改进建议\n- **性能优化**：识别和修复性能瓶颈\n- **新功能**：设计和实现复杂的新功能\n- **指导新手**：帮助新贡献者入门\n\n### 文档贡献\n\n- **API 文档**：改进工具文档和示例\n- **教程**：创建使用指南和最佳实践\n- **翻译**：帮助翻译文档到其他语言\n- **视频内容**：创建演示视频和教程\n\n### 测试贡献\n\n- **单元测试**：为新功能编写测试\n- **集成测试**：测试组件间的交互\n- **性能测试**：基准测试和性能回归检测\n- **用户测试**：真实场景下的功能测试\n\n## 🏆 贡献者认可\n\n我们重视每一个贡献，无论大小。贡献者将在以下方式获得认可：\n\n- **README 致谢**：在项目 README 中列出贡献者\n- **发布说明**：在版本发布说明中感谢贡献者\n- **贡献者徽章**：GitHub 个人资料上的贡献者徽章\n- **社区认可**：在社区讨论中的特别感谢\n\n感谢您考虑为 Chrome MCP Server 做贡献！您的参与使这个项目变得更好。\n"
  },
  {
    "path": "docs/ISSUE.md",
    "content": "# Issues 总览\n\n## 📊 统计信息\n\n- **总Issue数**: 183\n- **开放中**: 116\n- **已关闭**: 67\n- **关闭率**: 36.6%\n- **最后更新**: 2025-10-11\n\n## 📑 目录\n\n- [功能请求](#功能请求)\n- [Bug报告](#bug报告)\n- [安装问题](#安装问题)\n- [配置问题](#配置问题)\n- [兼容性问题](#兼容性问题)\n- [文档改进](#文档改进)\n- [已解决的问题](#已解决的问题)\n\n---\n\n## 🚀 功能请求\n\n### 开放中\n\n#### #215 chrome_console获取的数据不完整\n\n- **状态**: OPEN\n- **作者**: africa1207\n- **日期**: 2025-09-30\n- **描述**: chrome_console获取的数据是浅拷贝数据，无法获取深层对象信息\n\n#### #207 Screenshots can't autosave? I have to manually click Save?\n\n- **状态**: OPEN\n- **作者**: FVEFWFE\n- **日期**: 2025-09-18\n- **描述**: 希望截图能自动保存，而不需要手动点击保存\n\n#### #205 希望支持从 clipboard 获取信息填入页面输入框\n\n- **状态**: OPEN\n- **作者**: sunzh231\n- **日期**: 2025-09-17\n- **描述**: 根据鼠标光标所在的输入框直接从clipboard获取信息填入，避免使用Inject Script被浏览器CSP阻止\n\n#### #202 Electron应用程序如何使用此插件\n\n- **状态**: OPEN\n- **作者**: lyl340321\n- **日期**: 2025-09-13\n- **描述**: 在electron中支持了简易浏览器功能，想复用此插件提供mcp服务\n\n#### #201 chrome-mcp无法从dialog中获取信息\n\n- **状态**: OPEN\n- **作者**: qphien\n- **日期**: 2025-09-12\n- **描述**: dialog中含有token敏感信息，通过js获取内容的时候，获得的值为空\n\n#### #200 如何滚动页面\n\n- **状态**: OPEN\n- **作者**: qphien\n- **日期**: 2025-09-12\n- **描述**: Mac上如何instruct chrome-mcp滚动页面，通过调用快捷键space，chrome页面并没有发生滚动\n\n#### #190 不支持离线加载本地模型吗？\n\n- **状态**: OPEN\n- **作者**: long36708\n- **日期**: 2025-09-02\n- **描述**: 内网环境下，无法自动下载hugeface上的模型，网络不通\n\n#### #183 how to save the HTML displayed in the Chrome browser using Chrome MCP\n\n- **状态**: OPEN\n- **作者**: sansanai\n- **日期**: 2025-08-28\n- **描述**: 如何保存Chrome浏览器中显示的HTML内容，特别是当HTML内容很大时\n\n#### #180 服务状态经常莫名其妙停止\n\n- **状态**: OPEN\n- **作者**: IAmKongHai\n- **日期**: 2025-08-28\n- **描述**: 希望提高稳定性，在浏览器退出前一直保持服务状态运行中\n\n#### #178 操作MCP打开谷歌浏览器的页面之后他会自动弹窗出来\n\n- **状态**: OPEN\n- **作者**: MiloQ\n- **日期**: 2025-08-27\n- **描述**: 希望浏览器能在后台静默运行\n\n#### #177 n8n integration\n\n- **状态**: OPEN\n- **作者**: judaemon\n- **日期**: 2025-08-27\n- **描述**: 是否可以在n8n工作流中使用\n\n#### #175 可以以sse模式启动mcp server么\n\n- **状态**: OPEN\n- **作者**: FriSeaSky\n- **日期**: 2025-08-25\n- **描述**: 当前看readme只支持其他两种模式，希望能实现sse模式\n\n#### #171 Tab group api controls\n\n- **状态**: OPEN\n- **作者**: danieliser\n- **日期**: 2025-08-21\n- **描述**: 允许MCP控制标签组，创建、删除、添加标签到组等\n\n#### #169 Feature Request: Support Environment Variables to Disable Specific Tools\n\n- **状态**: OPEN\n- **作者**: lathidadia\n- **日期**: 2025-08-20\n- **描述**: 支持通过环境变量禁用或过滤特定工具，解决工具名称冲突问题\n\n#### #162 Needs some rate limit logic from tools going rogue in the real browser\n\n- **状态**: OPEN\n- **作者**: neberej\n- **日期**: 2025-08-16\n- **描述**: 需要添加速率限制逻辑，防止工具失控\n\n#### #157 Chrome 商店\n\n- **状态**: OPEN\n- **作者**: nelzomal\n- **日期**: 2025-08-13\n- **描述**: 有计划上架Chrome web store吗\n\n#### #155 More intelligent\n\n- **状态**: OPEN\n- **作者**: nullCode666\n- **日期**: 2025-08-13\n- **描述**: 希望MCP能自动理解当前网页的源代码，找到对应的加密方法等\n\n#### #153 `chrome_inject_script` not working on some sites\n\n- **状态**: OPEN\n- **作者**: rmorse\n- **日期**: 2025-08-12\n- **描述**: 在某些网站上chrome_inject_script不工作，需要支持不同的注入点\n\n#### #141 功能支持鼠标悬停、多窗口mcp隔离\n\n- **状态**: OPEN\n- **作者**: lironghai\n- **日期**: 2025-08-07\n- **描述**: 支持鼠标悬停和多窗口MCP隔离功能\n\n### 已关闭\n\n#### #145 Add file upload capability for web forms\n\n- **状态**: CLOSED\n- **作者**: kaovilai\n- **日期**: 2025-08-08\n- **描述**: 添加文件上传功能以支持web表单\n\n#### #107 Support .dxt format\n\n- **状态**: CLOSED\n- **作者**: metalshanked\n- **日期**: 2025-07-16\n- **描述**: 支持Anthropic发布的.dxt格式，实现一键安装\n\n---\n\n## 🐛 Bug报告\n\n### 开放中\n\n#### #215 chrome_console获取的数据不完整\n\n- **状态**: OPEN\n- **作者**: africa1207\n- **日期**: 2025-09-30\n- **描述**: chrome_console获取的数据是浅拷贝，深层对象显示为\"object\"\n\n#### #212 调用工具错误\n\n- **状态**: OPEN\n- **作者**: zhaooa\n- **日期**: 2025-09-28\n- **描述**: 工具是打开状态，但是还是提示调用工具错误\n\n#### #209 运行第一个例子的时候，mcp工具调用了但是画图没有动静\n\n- **状态**: OPEN\n- **作者**: scwlkq\n- **日期**: 2025-09-26\n\n#### #206 请求报错\n\n- **状态**: OPEN\n- **作者**: lghxuelang\n- **日期**: 2025-09-18\n- **描述**: Invalid or missing MCP session ID for SSE\n\n#### #204 经常会打开 chrome-extension://hbdgbgagpkpjffpklnamcljpakneikee/true\n\n- **状态**: OPEN\n- **作者**: Wouldyouplace45\n- **日期**: 2025-09-15\n- **描述**: 浏览器显示无法访问您的文件\n\n#### #191 chrome_console要求当前页面没有打开dev tool\n\n- **状态**: OPEN\n- **作者**: string1225\n- **日期**: 2025-09-03\n- **描述**: 这是chrome浏览器的机制限制\n\n#### #184 trae显示个别工具名字超过60字符最大限制\n\n- **状态**: OPEN\n- **作者**: wangqi996\n- **日期**: 2025-08-29\n\n#### #163 chrome_screenshot always gives \"exceeds maximum allowed tokens\" error\n\n- **状态**: OPEN\n- **作者**: maddada\n- **日期**: 2025-08-18\n- **描述**: 截图响应超过最大允许的token数（25000）\n\n#### #152 并发执行过程中发生错乱\n\n- **状态**: OPEN\n- **作者**: shatang123\n- **日期**: 2025-08-12\n- **描述**: 并发爬取网页时tabId错位，标签未关闭等问题\n\n#### #149 一直提示脚本注入失败\n\n- **状态**: OPEN\n- **作者**: manzhonglu\n- **日期**: 2025-08-11\n\n#### #144 让它打开网页，打开之后，会一直等待，直到超时\n\n- **状态**: OPEN\n- **作者**: shopkeeper2020\n- **日期**: 2025-08-08\n\n#### #142 我打开了网页，让他帮我点击个东西他都不好使\n\n- **状态**: OPEN\n- **作者**: bbhxwl\n- **日期**: 2025-08-07\n- **描述**: 使用qweb3 4b，只是回答提问，不执行点击操作\n\n#### #139 错误: Error calling tool: Request timed out after 30000ms\n\n- **状态**: OPEN\n- **作者**: sunhao28256\n- **日期**: 2025-08-05\n\n#### #136 `chrome_keyboard` is not working with Claude Code\n\n- **状态**: OPEN\n- **作者**: hanayashiki\n- **日期**: 2025-08-03\n- **描述**: 虽然显示成功，但没有输入到textarea中\n\n#### #128 如果找不到网页元素的话，会一直重试\n\n- **状态**: OPEN\n- **作者**: GragonForce666\n- **日期**: 2025-07-29\n\n#### #122 各种各样的超时，自动停止\n\n- **状态**: OPEN\n- **作者**: fordiy\n- **日期**: 2025-07-26\n- **描述**: 已经把30秒超时改多10倍，还是有超时问题\n\n#### #118 无法自动点击 cloudflare 人机验证\n\n- **状态**: OPEN\n- **作者**: windzhu0514\n- **日期**: 2025-07-23\n\n#### #114 试了豆瓣、即刻，似乎抓取不了\n\n- **状态**: OPEN\n- **作者**: imHw\n- **日期**: 2025-07-20\n- **描述**: AI反馈访问这些网站遇到问题，可能是反爬机制\n\n#### #112 chrome_network_debugger的maxRequests太少了\n\n- **状态**: OPEN\n- **作者**: kanekanefy\n- **日期**: 2025-07-19\n- **描述**: maxRequests限制在100个请求后自动停止\n\n#### #111 使用CherryStudio进行网站截图时报错\n\n- **状态**: OPEN\n- **作者**: GehuaZhang\n- **日期**: 2025-07-18\n- **描述**: Cannot read properties of undefined (reading 'map')\n\n#### #99 chrome_get_web_content 工具获取的页面信息似乎不全\n\n- **状态**: OPEN\n- **作者**: Reviel\n- **日期**: 2025-07-13\n- **描述**: 获取PostGIS ticket页面时缺失Description部分内容\n\n#### #92 AI无法关闭alert提示框\n\n- **状态**: OPEN\n- **作者**: chgblog\n- **日期**: 2025-07-11\n- **描述**: 遇到alert、confirm弹窗后AI无法继续操作，显示MCP超时\n\n#### #67 windows function call 报超时错误\n\n- **状态**: OPEN\n- **作者**: zhiyu\n- **日期**: 2025-07-01\n\n### 已关闭\n\n#### #181 The extension stays disconnected\n\n- **状态**: CLOSED\n- **作者**: Arefinw\n- **日期**: 2025-08-28\n\n#### #140 语音引擎初始化失败\n\n- **状态**: CLOSED\n- **作者**: Demi555\n- **日期**: 2025-08-06\n\n#### #116 插件点击连接，然后失焦点，隐藏，会自动断开连接\n\n- **状态**: CLOSED\n- **作者**: BeginnerDone\n- **日期**: 2025-07-22\n\n#### #73 API Error: 413: Prompt is too long\n\n- **状态**: CLOSED\n- **作者**: Lehtien\n- **日期**: 2025-07-04\n\n#### #60 Claude code Chrome MCP服务器启动时输出包含emoji的console.log语句\n\n- **状态**: CLOSED\n- **作者**: gabyic\n- **日期**: 2025-06-28\n- **描述**: 导致MCP协议JSON解析错误\n\n---\n\n## 📦 安装问题\n\n### 开放中\n\n#### #198 关于该插件在谷歌浏览器连接不上的问题\n\n- **状态**: OPEN\n- **作者**: nice-nicegod\n- **日期**: 2025-09-09\n- **描述**: 插件显示\"已连接，服务未启动\"。如果Node.js安装时更改了默认路径会导致此问题\n\n#### #187 打开连接时显示 Connected, Service Not Started\n\n- **状态**: OPEN\n- **作者**: wyx66624\n- **日期**: 2025-08-31\n- **描述**: 已手动注册mcp-chrome-bridge，12306端口没有进程监听\n\n#### #174 Browser in Docker + Chrome MCP: troubleshooting\n\n- **状态**: OPEN\n- **作者**: f3l1x\n- **日期**: 2025-08-25\n- **描述**: 在Docker虚拟浏览器中预装扩展，显示\"Connected, Service Not Started\"\n\n#### #170 Claude Code integration on WSL\n\n- **状态**: OPEN\n- **作者**: TimHuey\n- **日期**: 2025-08-20\n- **描述**: WSL中Claude Code无法识别mcp server\n\n#### #159 WSL Support?\n\n- **状态**: OPEN\n- **作者**: D3OXY\n- **日期**: 2025-08-14\n\n#### #148 chrome插件已经成功启动，但是命令行显示failed\n\n- **状态**: OPEN\n- **作者**: joytianya\n- **日期**: 2025-08-10\n\n#### #147 有打算支持 docker 部署吗\n\n- **状态**: OPEN\n- **作者**: tgscan-dev\n- **日期**: 2025-08-10\n\n#### #143 服务器上怎么部署这个mcp服务\n\n- **状态**: OPEN\n- **作者**: no-bystander\n- **日期**: 2025-08-08\n\n#### #138 在chrome浏览器里已经安装上插件，可以配置端口\n\n- **状态**: OPEN\n- **作者**: KylanJimmy\n- **日期**: 2025-08-05\n- **描述**: 是否可以绑定0.0.0.0的端口，而不只是127.0.0.1\n\n#### #137 win上 已连接，服务未启动\n\n- **状态**: OPEN\n- **作者**: steven111920\n- **日期**: 2025-08-04\n- **描述**: 点击run_host.bat显示拒绝访问\n\n#### #127 已连接，服务未启动\n\n- **状态**: OPEN\n- **作者**: Fanzaijun\n- **日期**: 2025-07-29\n\n#### #115 已连接服务未启动\n\n- **状态**: OPEN\n- **作者**: yanghao112\n- **日期**: 2025-07-21\n- **描述**: 能排查的都排查了，还是不行\n\n#### #106 启动成功但是没法配置\n\n- **状态**: OPEN\n- **作者**: crxxxxxxx\n- **日期**: 2025-07-15\n\n#### #90 不能启动\n\n- **状态**: OPEN\n- **作者**: qiffang\n- **日期**: 2025-07-11\n- **描述**: 运行run_hosts.sh一直hang住\n\n#### #88 Failed to install on Apple Silicon Mac\n\n- **状态**: OPEN\n- **作者**: DaniloHandsOn\n- **日期**: 2025-07-10\n- **描述**: chrome-mcp-bridge命令未找到\n\n#### #85 一直报错 Session termination 400\n\n- **状态**: OPEN\n- **作者**: hcoona\n- **日期**: 2025-07-08\n\n#### #78 docs/CONTRIBUTING.md instructions to build missing packages/shared build\n\n- **状态**: OPEN\n- **作者**: adrianlzt\n- **日期**: 2025-07-06\n- **描述**: 文档缺少shared包的构建步骤\n\n#### #68 Execute mcp-chrome-bridge -v and report [ERR_REQUIRE_ESM]\n\n- **状态**: OPEN\n- **作者**: coisini6\n- **日期**: 2025-07-02\n- **描述**: Windows10下报ERR_REQUIRE_ESM错误\n\n#### #65 mac m4 浏览器插件服务未连接\n\n- **状态**: OPEN\n- **作者**: wzp-coding\n- **日期**: 2025-06-30\n- **描述**: 已按troubleshooting排查，执行index.js卡住无反应\n\n#### #62 无法启动\n\n- **状态**: OPEN\n- **作者**: Mocha-s\n- **日期**: 2025-06-28\n- **描述**: 直接不知道怎么启动\n\n### 已关闭\n\n#### #196 SOLUTION - Native Messaging not working in Chromium\n\n- **状态**: CLOSED (已有PR #195解决)\n- **作者**: gebeer\n- **日期**: 2025-09-07\n- **描述**: mcp-chrome-bridge npm包只安装到Chrome目录，不支持Chromium\n\n#### #161 unexpected error: Running Status --> \"Connected, Service Not Started\"\n\n- **状态**: CLOSED\n- **作者**: TonnyWong1052\n- **日期**: 2025-08-15\n\n#### #154 Chrome 未能成功加载扩展程序\n\n- **状态**: CLOSED\n- **作者**: mmhzlrj\n- **日期**: 2025-08-12\n- **描述**: Missing 'manifest_version' key\n\n#### #81 chromium浏览器启动失败的目录问题\n\n- **状态**: CLOSED\n- **作者**: lesszzen\n- **日期**: 2025-07-07\n- **描述**: Chromium在Linux下配置文件目录为.config/chromium\n\n#### #69 是否有适配firefox浏览器计划\n\n- **状态**: CLOSED\n- **作者**: Shuai-S\n- **日期**: 2025-07-02\n\n#### #64 不支持linux部署这个项目吧\n\n- **状态**: CLOSED\n- **作者**: caiji2019-cai\n- **日期**: 2025-06-30\n\n#### #22 Mac上运行失败，Native服务没有成功启动\n\n- **状态**: CLOSED\n- **作者**: DengKaiRong\n- **日期**: 2025-06-19\n\n#### #16 开发模式启动项目，server未成功启动\n\n- **状态**: CLOSED\n- **作者**: WSCZou\n- **日期**: 2025-06-18\n\n---\n\n## ⚙️ 配置问题\n\n### 开放中\n\n#### #203 INSTALL IN THE CURSOR, LOADING TOOLS,BUT NOT SUCESS\n\n- **状态**: OPEN\n- **作者**: chenhunhun\n- **日期**: 2025-09-14\n- **描述**: Cursor中配置后工具加载失败\n\n#### #199 Claude code cil 连上不上怎么回事\n\n- **状态**: OPEN\n- **作者**: 666xjs\n- **日期**: 2025-09-10\n- **描述**: 服务端运行成功了，但就是连上不上\n\n#### #188 windsurf中无法连接\n\n- **状态**: OPEN\n- **作者**: NoComments\n- **日期**: 2025-09-02\n- **描述**: Error: TransformStream is not defined\n\n#### #185 Kiro 提示 \"Enabled MCP Server chrome-mcp-server must specify a command\"\n\n- **状态**: OPEN\n- **作者**: Chris-C1108\n- **日期**: 2025-08-29\n- **描述**: 不清楚command是指什么，会不会是kiro不支持streamable-http类型\n\n#### #182 Claude CLI fails to connect to running server on macOS\n\n- **状态**: OPEN\n- **作者**: dreamreels\n- **日期**: 2025-08-28\n- **描述**: 扩展显示运行正常，但claude命令行工具无法连接\n\n#### #173 claude code 不支持streamableHttp\n\n- **状态**: OPEN\n- **作者**: Baddts\n- **日期**: 2025-08-24\n- **描述**: 配置streamableHttp后claude code不会加载这个mcp\n\n#### #168 Failed to parse MCP servers from JSON\n\n- **状态**: OPEN\n- **作者**: joyhu\n- **日期**: 2025-08-19\n\n#### #167 claude code mcp 链接不了\n\n- **状态**: OPEN\n- **作者**: TheBloodthirster\n- **日期**: 2025-08-18\n- **描述**: Native connection disconnected\n\n#### #160 在使用multilingual-e5-base时出错\n\n- **状态**: OPEN\n- **作者**: lcylcyll\n- **日期**: 2025-08-15\n- **描述**: 模型要求维度是768D，但在谷歌浏览器上出错\n\n#### #150 Readme Image not found - Installation- Step 3\n\n- **状态**: OPEN\n- **作者**: amritbanerjee\n- **日期**: 2025-08-12\n- **描述**: Readme文件第3步的图片链接404\n\n#### #135 callTool() 这个工具函数 在哪个库里\n\n- **状态**: OPEN\n- **作者**: hechengdu\n- **日期**: 2025-08-03\n\n#### #134 Cursor无法连接Chrome MCP\n\n- **状态**: OPEN\n- **作者**: shengcruz\n- **日期**: 2025-08-02\n- **描述**: 显示\"No connection to browser extension\"\n\n#### #132 trae 加载失败\n\n- **状态**: OPEN\n- **作者**: mimicode\n- **日期**: 2025-08-02\n- **描述**: chrome_send_command_to_inject_script长度超过60个字符\n\n#### #131 claude desktop 配置后不识别\n\n- **状态**: OPEN\n- **作者**: microxxx\n- **日期**: 2025-08-01\n\n#### #124 请看截图，说已经搞掂画图了，但Excalidraw永远都是空白\n\n- **状态**: OPEN\n- **作者**: fordiy\n- **日期**: 2025-07-27\n\n#### #123 在AI输出过程中，经常会自动停掉\n\n- **状态**: OPEN\n- **作者**: fordiy\n- **日期**: 2025-07-26\n- **描述**: 没法继续在原来页面excalidraw画图\n\n#### #121 cherrystudio升级1.5.3之后，无法调用了\n\n- **状态**: OPEN\n- **作者**: csfeng1\n- **日期**: 2025-07-26\n\n#### #109 cherrystudio无法正常使用MCP\n\n- **状态**: OPEN\n- **作者**: kksqwerc\n- **日期**: 2025-07-17\n- **描述**: 工具已罗列出来，但在对话过程中无法准确调用\n\n#### #103 报错 400 的一般是客户端配置方式不对\n\n- **状态**: OPEN\n- **作者**: ifastcc\n- **日期**: 2025-07-15\n- **描述**: 给出了Claude code、Gemini cli、Cursor的正确配置方式\n\n#### #102 Cherry-Studio 启动失败\n\n- **状态**: OPEN\n- **作者**: Bboossccoo\n- **日期**: 2025-07-14\n\n#### #100 cursor调用excalidraw 提示Error calling tool\n\n- **状态**: OPEN\n- **作者**: DevilMay-Cry\n- **日期**: 2025-07-14\n- **描述**: Request timed out after 30000ms\n\n#### #101 vscode使用：输入打开url，输入账号密码。一直卡在打开url中\n\n- **状态**: OPEN\n- **作者**: kkk123dm\n- **日期**: 2025-07-14\n\n### 已关闭\n\n#### #221 如何在VSC中配置mcp-chrome？\n\n- **状态**: CLOSED\n- **作者**: valuex\n- **日期**: 2025-10-04\n- **描述**: 配置后不能启动服务器\n\n#### #193 Cursor中添加mcp后一直显示loading tools\n\n- **状态**: CLOSED\n- **作者**: lixiaolong613\n- **日期**: 2025-09-04\n\n#### #192 部署到远程服务器之后访问连接被重置\n\n- **状态**: CLOSED\n- **作者**: wlxwlxwlx\n- **日期**: 2025-09-04\n\n#### #164 如何在claude desktop中也用上预定义的prompt template\n\n- **状态**: CLOSED\n- **作者**: WeiyangZhang\n- **日期**: 2025-08-18\n\n#### #133 issue with setting up the MCP in Claude Code\n\n- **状态**: CLOSED\n- **作者**: seldaneg\n- **日期**: 2025-08-02\n\n#### #113 Error invoking remote method 'mcp:restart-server'\n\n- **状态**: CLOSED\n- **作者**: Daiyuxin26\n- **日期**: 2025-07-19\n\n#### #102 Cherry-Studio 启动失败\n\n- **状态**: CLOSED\n- **作者**: Bboossccoo\n- **日期**: 2025-07-14\n\n#### #57 DIFY MCP调用失败\n\n- **状态**: CLOSED\n- **作者**: SpringMeta\n- **日期**: 2025-06-27\n\n#### #45 Cherry Studio 下连接 MCP报错\n\n- **状态**: CLOSED\n- **作者**: nooldey\n- **日期**: 2025-06-25\n- **描述**: serverType不正确，应使用小驼峰写法\n\n#### #32 vscode 中启动失败\n\n- **状态**: CLOSED\n- **作者**: linjinxing\n- **日期**: 2025-06-23\n\n#### #30 没法使用\n\n- **状态**: CLOSED\n- **作者**: 2513483494\n- **日期**: 2025-06-23\n- **描述**: unexpected status code: 400\n\n#### #19 cursor 里面配置后会出现报错\n\n- **状态**: CLOSED\n- **作者**: Sumouren1\n- **日期**: 2025-06-18\n\n#### #18 不支持cursor/cline么？\n\n- **状态**: CLOSED\n- **作者**: Rainmen-xia\n- **日期**: 2025-06-18\n\n#### #13 cherry studio addition failed\n\n- **状态**: CLOSED\n- **作者**: LLmoskk\n- **日期**: 2025-06-17\n\n#### #8 chrome_navigate调用报错\n\n- **状态**: CLOSED\n- **作者**: fcyf\n- **日期**: 2025-06-16\n\n---\n\n## 🔌 兼容性问题\n\n### 开放中\n\n#### #172 iframe页面元素not found\n\n- **状态**: OPEN\n- **作者**: Actor12\n- **日期**: 2025-08-22\n- **描述**: 使用iframe开发的网页，chrome_fill_or_selector总是not found\n\n#### #126 自动回复、自动发布 希望功能更强大一些\n\n- **状态**: OPEN\n- **作者**: smartchainark\n- **日期**: 2025-07-29\n- **描述**: 在x平台和小红书平台无法正常完成任务\n\n#### #93 动态的数据怎样获取\n\n- **状态**: OPEN\n- **作者**: carter115\n- **日期**: 2025-07-11\n- **描述**: 页面上滚动鼠标才调用接口的数据\n\n#### #43 【无数据输出】cursor+edge 测试绘制一个月的浏览记录\n\n- **状态**: OPEN\n- **作者**: 3377\n- **日期**: 2025-06-24\n\n#### #42 能否和automa一起联动制作工作流呢？\n\n- **状态**: OPEN\n- **作者**: 3377\n- **日期**: 2025-06-24\n\n#### #40 语义引擎初始化失败\n\n- **状态**: OPEN\n- **作者**: HY-Hu\n- **日期**: 2025-06-24\n\n#### #39 一直报权限问题\n\n- **状态**: OPEN\n- **作者**: mozhuangshu\n- **日期**: 2025-06-24\n\n#### #33 找不到元素\n\n- **状态**: OPEN\n- **作者**: 2513483494\n- **日期**: 2025-06-23\n- **描述**: 腾讯云控制台页面元素找不到\n\n### 已关闭\n\n---\n\n## 📚 文档改进\n\n### 开放中\n\n#### #197 指令里 无法执行\n\n- **状态**: OPEN\n- **作者**: lujuny328-cmyk\n- **日期**: 2025-09-08\n- **描述**: 把链接桥放到指令里无法执行\n\n#### #189 求拉群\n\n- **状态**: OPEN\n- **作者**: wwenj\n- **日期**: 2025-09-02\n- **描述**: 文档中的群二维码过期了\n\n#### #117 好像没有点击扩展程序的工具？\n\n- **状态**: OPEN\n- **作者**: sunweihunu\n- **日期**: 2025-07-22\n- **描述**: 希望能增加点击Chrome扩展程序的工具\n\n#### #125 二维码已过期\n\n- **状态**: OPEN\n- **作者**: NuoLanC\n- **日期**: 2025-07-29\n\n### 已关闭\n\n#### #95 整理网页文档包含图片的效果不如 playwright\n\n- **状态**: CLOSED\n- **作者**: Xuzan9396\n- **日期**: 2025-07-12\n\n#### #94 readme 视频链接失效\n\n- **状态**: CLOSED\n- **作者**: vcan\n- **日期**: 2025-07-11\n\n#### #91 群满人了，大佬加下我\n\n- **状态**: CLOSED\n- **作者**: huangxingzhao\n- **日期**: 2025-07-11\n\n#### #89 请问这个是什么工具\n\n- **状态**: CLOSED\n- **作者**: Messilimeng\n- **日期**: 2025-07-11\n- **描述**: 我用cursor有没有很好的互动prompt呢\n\n#### #84 如何配置自己的AI？\n\n- **状态**: CLOSED\n- **作者**: liaoyu-zju\n- **日期**: 2025-07-08\n\n#### #83 中文文档中的微信二维码已过期\n\n- **状态**: CLOSED\n- **作者**: YunfanGoForIt\n- **日期**: 2025-07-07\n\n#### #79 english ?\n\n- **状态**: CLOSED\n- **作者**: michabbb\n- **日期**: 2025-07-06\n- **描述**: README是英文的，而Chrome扩展完全是中文的\n\n#### #75 prompt 目录下的文件如何引用\n\n- **状态**: CLOSED\n- **作者**: jovezhong\n- **日期**: 2025-07-05\n\n#### #52 README 中多媒体资源 404 问题\n\n- **状态**: CLOSED\n- **作者**: yunkst\n- **日期**: 2025-06-26\n\n#### #49 视频里面在浏览器右侧这个大模型聊天工具是什么啊？\n\n- **状态**: CLOSED\n- **作者**: MoeMoeFish\n- **日期**: 2025-06-25\n\n#### #48 建议楼主创建一个微信群\n\n- **状态**: CLOSED\n- **作者**: goreycn\n- **日期**: 2025-06-25\n\n#### #44 没有看到查看MCP配置的连接按扭\n\n- **状态**: CLOSED\n- **作者**: jimleee\n- **日期**: 2025-06-25\n\n#### #35 画图功能没有调动起来\n\n- **状态**: CLOSED\n- **作者**: guangzhou\n- **日期**: 2025-06-23\n\n#### #34 怎么才能在画板上画图呢\n\n- **状态**: CLOSED\n- **作者**: guangzhou\n- **日期**: 2025-06-23\n\n#### #31 可增加对Consle日志的读取吗\n\n- **状态**: CLOSED\n- **作者**: ZoidbergPi\n- **日期**: 2025-06-23\n\n#### #26 使用教程\n\n- **状态**: CLOSED\n- **作者**: fanhaoj\n- **日期**: 2025-06-22\n\n#### #23 怎么打开对话框？\n\n- **状态**: CLOSED\n- **作者**: kokwiw\n- **日期**: 2025-06-20\n\n#### #17 对比2个京东商品就超token了\n\n- **状态**: CLOSED\n- **作者**: namejee\n- **日期**: 2025-06-18\n\n#### #15 Claude Desktop\n\n- **状态**: CLOSED\n- **作者**: GoldRush520\n- **日期**: 2025-06-18\n- **描述**: Claude Desktop国内用不了，有没有其他可替代的\n\n#### #11 大佬有没有可能添加一个drag and drop功能\n\n- **状态**: CLOSED\n- **作者**: tom63001\n- **日期**: 2025-06-17\n\n---\n\n## ✅ 已解决的问题\n\n### 社区交流相关\n\n#### #213 求个微信群组，互相交流\n\n- **状态**: OPEN\n- **作者**: zhangchao0323\n- **日期**: 2025-09-29\n\n#### #211 求拉群，想参与项目贡献～\n\n- **状态**: OPEN\n- **作者**: suoaiyisheng\n- **日期**: 2025-09-27\n\n### 使用问题\n\n#### #176 claude code 无法画图\n\n- **状态**: OPEN\n- **作者**: woshihoujinxin\n- **日期**: 2025-08-26\n- **描述**: 打开excalidraw.com画图，但没有流畅效果\n\n#### #166 画图问题\n\n- **状态**: OPEN\n- **作者**: fyture\n- **日期**: 2025-08-18\n- **描述**: 模型说已完成，但excalidraw上什么都没有\n\n### Python集成\n\n#### #194 如何在代码上接入呢，不用AI agent\n\n- **状态**: CLOSED\n- **作者**: dreambe\n- **日期**: 2025-09-05\n- **描述**: 比如python，有没有demo代码\n\n#### #82 尝试使用python代码直接调用工具失败\n\n- **状态**: CLOSED\n- **作者**: YunfanGoForIt\n- **日期**: 2025-07-07\n\n#### #24 可以使用python代码调用这个插件吗？\n\n- **状态**: CLOSED\n- **作者**: liulint\n- **日期**: 2025-06-20\n\n#### #21 请问目前不带有MCP功能的的LLM可以接入这个mcp服务器吗\n\n- **状态**: CLOSED\n- **作者**: JessiePen\n- **日期**: 2025-06-19\n\n### 服务器部署\n\n#### #74 Suggestion: Enable External Access to Local Server\n\n- **状态**: OPEN\n- **作者**: ErrorGz\n- **日期**: 2025-07-05\n- **描述**: 建议修改HOST为0.0.0.0以允许外部访问\n\n#### #72 Tab串联问题\n\n- **状态**: CLOSED\n- **作者**: fundoop\n- **日期**: 2025-07-04\n- **描述**: 是否可以增加指定tab页面操作，切换tab等\n\n#### #71 这个mcp服务器不能和客户端分开吗\n\n- **状态**: CLOSED\n- **作者**: xiaodiao216\n- **日期**: 2025-07-03\n\n#### #70 【Help Wanted】项目首页视频里的MCP客户端是什么？\n\n- **状态**: CLOSED\n- **作者**: tonyxu721\n- **日期**: 2025-07-03\n\n### 其他\n\n#### #97 请问使用示例中出现的对话工具是什么\n\n- **状态**: CLOSED\n- **作者**: sbwg\n- **日期**: 2025-07-12\n\n#### #96 入口在哪里啊？\n\n- **状态**: CLOSED\n- **作者**: DavidCalls\n- **日期**: 2025-07-12\n\n#### #80 alternative way question\n\n- **状态**: CLOSED\n- **作者**: yiminhale\n- **日期**: 2025-07-06\n- **描述**: 能否用npm而不是pnpm\n\n#### #51 navigate功能不能标签打开地址\n\n- **状态**: CLOSED\n- **作者**: adoin\n- **日期**: 2025-06-26\n\n#### #25 [Feature Request] - Can I use it with my Cursor?\n\n- **状态**: CLOSED\n- **作者**: DaleXiao\n- **日期**: 2025-06-21\n\n#### #14 How to support VSCode or trae?\n\n- **状态**: CLOSED\n- **作者**: loki-zhou\n- **日期**: 2025-06-17\n\n#### #5 佬，augment里咋设置mcp？\n\n- **状态**: CLOSED\n- **作者**: gally16\n- **日期**: 2025-06-15\n\n---\n\n## 📈 Issue 趋势分析\n\n### 高频问题类型\n\n1. **安装配置问题** (约40%): 主要集中在Native Messaging连接失败、服务未启动\n2. **兼容性问题** (约25%): 不同客户端（Cursor、Claude Code、Cherry Studio等）的集成问题\n3. **功能请求** (约20%): 文件上传、鼠标悬停、多窗口隔离等\n4. **Bug报告** (约15%): 工具调用错误、超时、元素查找失败等\n\n### 常见解决方案\n\n1. **权限问题**: 使用`chmod -R 755`赋予dist目录权限\n2. **Node.js路径问题**: 重新安装Node.js到默认路径\n3. **配置格式问题**: 不同客户端使用不同的配置格式（streamableHttp vs streamable-http）\n4. **端口访问**: 默认127.0.0.1，需要外部访问时改为0.0.0.0\n\n---\n\n## 🔗 相关资源\n\n- [故障排除文档](TROUBLESHOOTING_zh.md)\n- [贡献指南](CONTRIBUTING_zh.md)\n- [工具文档](TOOLS_zh.md)\n- [Windows安装指南](WINDOWS_INSTALL_zh.md)\n\n---\n\n**最后更新**: 2025-10-11  \n**统计数据来源**: GitHub Issues API\n"
  },
  {
    "path": "docs/TOOLS.md",
    "content": "# Chrome MCP Server API Reference 📚\n\nComplete reference for all available tools and their parameters.\n\n## 📋 Table of Contents\n\n- [Browser Management](#browser-management)\n- [Screenshots & Visual](#screenshots--visual)\n- [Network Monitoring](#network-monitoring)\n- [Content Analysis](#content-analysis)\n- [Interaction](#interaction)\n- [Data Management](#data-management)\n- [Response Format](#response-format)\n\n## 📊 Browser Management\n\n### `get_windows_and_tabs`\n\nList all currently open browser windows and tabs.\n\n**Parameters**: None\n\n**Response**:\n\n```json\n{\n  \"windowCount\": 2,\n  \"tabCount\": 5,\n  \"windows\": [\n    {\n      \"windowId\": 123,\n      \"tabs\": [\n        {\n          \"tabId\": 456,\n          \"url\": \"https://example.com\",\n          \"title\": \"Example Page\",\n          \"active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n### `chrome_navigate`\n\nNavigate to a URL with optional viewport control.\n\n**Parameters**:\n\n- `url` (string, optional): URL to navigate to (omit when `refresh=true`)\n- `newWindow` (boolean, optional): Create new window (default: false)\n- `tabId` (number, optional): Target an existing tab by ID (navigate/refresh that tab)\n- `background` (boolean, optional): Do not activate the tab or focus the window (default: false)\n- `width` (number, optional): Viewport width in pixels (default: 1280)\n- `height` (number, optional): Viewport height in pixels (default: 720)\n\n**Example**:\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"newWindow\": true,\n  \"width\": 1920,\n  \"height\": 1080\n}\n```\n\n### `chrome_close_tabs`\n\nClose specific tabs or windows.\n\n**Parameters**:\n\n- `tabIds` (array, optional): Array of tab IDs to close\n- `windowIds` (array, optional): Array of window IDs to close\n\n**Example**:\n\n```json\n{\n  \"tabIds\": [123, 456],\n  \"windowIds\": [789]\n}\n```\n\n### `chrome_switch_tab`\n\nSwitch to a specific browser tab.\n\n**Parameters**:\n\n- `tabId` (number, required): The ID of the tab to switch to.\n- `windowId` (number, optional): The ID of the window where the tab is located.\n\n**Example**:\n\n```json\n{\n  \"tabId\": 456,\n  \"windowId\": 123\n}\n```\n\n### `chrome_go_back_or_forward`\n\nNavigate browser history.\n\n**Parameters**:\n\n- `direction` (string, required): \"back\" or \"forward\"\n- `tabId` (number, optional): Specific tab ID (default: active tab)\n\n**Example**:\n\n```json\n{\n  \"direction\": \"back\",\n  \"tabId\": 123\n}\n```\n\n## 📸 Screenshots & Visual\n\n### `chrome_screenshot`\n\nTake advanced screenshots with various options.\n\n**Parameters**:\n\n- `name` (string, optional): Screenshot filename\n- `selector` (string, optional): CSS selector for element screenshot\n- `tabId` (number, optional): Target tab to capture (default: active tab)\n- `background` (boolean, optional): Attempt capture without bringing tab/window to foreground (viewport-only uses CDP)\n- `width` (number, optional): Width in pixels (default: 800)\n- `height` (number, optional): Height in pixels (default: 600)\n- `storeBase64` (boolean, optional): Return base64 data (default: false)\n- `fullPage` (boolean, optional): Capture full page (default: true)\n\n**Example**:\n\n```json\n{\n  \"selector\": \".main-content\",\n  \"fullPage\": true,\n  \"storeBase64\": true,\n  \"width\": 1920,\n  \"height\": 1080\n}\n```\n\n**Response**:\n\n```json\n{\n  \"success\": true,\n  \"base64\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...\",\n  \"dimensions\": {\n    \"width\": 1920,\n    \"height\": 1080\n  }\n}\n```\n\n## 🌐 Network Monitoring\n\n### `chrome_network_capture_start`\n\nStart capturing network requests using webRequest API.\n\n**Parameters**:\n\n- `url` (string, optional): URL to navigate to and capture\n- `maxCaptureTime` (number, optional): Maximum capture time in ms (default: 30000)\n- `inactivityTimeout` (number, optional): Stop after inactivity in ms (default: 3000)\n- `includeStatic` (boolean, optional): Include static resources (default: false)\n\n**Example**:\n\n```json\n{\n  \"url\": \"https://api.example.com\",\n  \"maxCaptureTime\": 60000,\n  \"includeStatic\": false\n}\n```\n\n### `chrome_network_capture_stop`\n\nStop network capture and return collected data.\n\n**Parameters**: None\n\n**Response**:\n\n```json\n{\n  \"success\": true,\n  \"capturedRequests\": [\n    {\n      \"url\": \"https://api.example.com/data\",\n      \"method\": \"GET\",\n      \"status\": 200,\n      \"requestHeaders\": {...},\n      \"responseHeaders\": {...},\n      \"responseTime\": 150\n    }\n  ],\n  \"summary\": {\n    \"totalRequests\": 15,\n    \"captureTime\": 5000\n  }\n}\n```\n\n### `chrome_network_debugger_start`\n\nStart capturing with Chrome Debugger API (includes response bodies).\n\n**Parameters**:\n\n- `url` (string, optional): URL to navigate to and capture\n\n### `chrome_network_debugger_stop`\n\nStop debugger capture and return data with response bodies.\n\n### `chrome_network_request`\n\nSend custom HTTP requests.\n\n**Parameters**:\n\n- `url` (string, required): Request URL\n- `method` (string, optional): HTTP method (default: \"GET\")\n- `headers` (object, optional): Request headers\n- `body` (string, optional): Request body\n\n**Example**:\n\n```json\n{\n  \"url\": \"https://api.example.com/data\",\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-Type\": \"application/json\"\n  },\n  \"body\": \"{\\\"key\\\": \\\"value\\\"}\"\n}\n```\n\n## 🔍 Content Analysis\n\n### `chrome_read_page`\n\nBuild an accessibility-like tree of the current page (visible viewport by default) with stable `ref_*` identifiers and viewport info. Useful for semantic element discovery or agent planning.\n\nParameters:\n\n- `filter` (string, optional): `interactive` to only include interactive elements; default includes structural and labeled nodes.\n- `tabId` (number, optional): Target an existing tab by ID (default: active tab).\n\nExample:\n\n```json\n{\n  \"filter\": \"interactive\"\n}\n```\n\nResponse contains `pageContent` (text tree), `viewport`, and a `refMapCount` summary. Use `chrome_get_interactive_elements` or your own logic to act on returned refs.\n\n### `search_tabs_content`\n\nAI-powered semantic search across browser tabs.\n\n**Parameters**:\n\n- `query` (string, required): Search query\n\n**Example**:\n\n```json\n{\n  \"query\": \"machine learning tutorials\"\n}\n```\n\n**Response**:\n\n```json\n{\n  \"success\": true,\n  \"totalTabsSearched\": 10,\n  \"matchedTabsCount\": 3,\n  \"vectorSearchEnabled\": true,\n  \"indexStats\": {\n    \"totalDocuments\": 150,\n    \"totalTabs\": 10,\n    \"semanticEngineReady\": true\n  },\n  \"matchedTabs\": [\n    {\n      \"tabId\": 123,\n      \"url\": \"https://example.com/ml-tutorial\",\n      \"title\": \"Machine Learning Tutorial\",\n      \"semanticScore\": 0.85,\n      \"matchedSnippets\": [\"Introduction to machine learning...\"],\n      \"chunkSource\": \"content\"\n    }\n  ]\n}\n```\n\n### `chrome_get_web_content`\n\nExtract HTML or text content from web pages.\n\n**Parameters**:\n\n- `format` (string, optional): \"html\" or \"text\" (default: \"text\")\n- `selector` (string, optional): CSS selector for specific elements\n- `tabId` (number, optional): Specific tab ID (default: active tab)\n- `background` (boolean, optional): Do not activate tab/focus window while fetching (default: false)\n\n**Example**:\n\n```json\n{\n  \"format\": \"text\",\n  \"selector\": \".article-content\"\n}\n```\n\n### `chrome_get_interactive_elements` (deprecated)\n\nReplaced by `chrome_read_page` as the primary discovery tool. The `read_page` implementation will automatically fallback to the interactive-elements logic when the accessibility tree is unavailable or too sparse. This tool is no longer listed via ListTools and is kept only for backward compatibility.\n\n## 🎯 Interaction\n\n### `chrome_computer`\n\nUnified advanced interaction tool that prioritizes high-level DOM actions with CDP fallback. Supports hover, click, drag, scroll, typing, key chords, fill, wait and screenshot. If a recent screenshot was taken via `chrome_screenshot`, coordinates are auto-scaled from screenshot space to viewport space.\n\nParameters:\n\n- `action` (string, required): `left_click` | `right_click` | `double_click` | `triple_click` | `left_click_drag` | `scroll` | `type` | `key` | `fill` | `hover` | `wait` | `screenshot`\n- `tabId` (number, optional): Target an existing tab by ID (default: active tab)\n- `background` (boolean, optional): Avoid focusing/activating tab/window for certain operations (best-effort)\n- `ref` (string, optional): element ref from `chrome_read_page` (preferred). Used for click/scroll/type/key and as drag end when provided\n- `coordinates` (object, optional): `{ \"x\": 100, \"y\": 200 }` for click/scroll or drag end\n- `startRef` (string, optional): element ref for drag start\n- `startCoordinates` (object, optional): for `left_click_drag` when no `startRef`\n- `scrollDirection` (string, optional): `up` | `down` | `left` | `right`\n- `scrollAmount` (number, optional): ticks 1–10 (default 3)\n- `text` (string, optional): for `type` (raw text) or `key` (space-separated chords/keys like `\"cmd+a Enter\"`)\n- `duration` (number, optional): seconds for `wait` (max 30)\n- `selector` (string, optional): for `fill` when no `ref`\n- `value` (string, optional): for `fill` value\n\nExamples:\n\n```json\n{ \"action\": \"left_click\", \"coordinates\": { \"x\": 420, \"y\": 260 } }\n```\n\n```json\n{ \"action\": \"key\", \"text\": \"cmd+a Backspace\" }\n```\n\n````json\n{ \"action\": \"fill\", \"ref\": \"ref_7\", \"value\": \"user@example.com\" }\n\n```json\n{ \"action\": \"hover\", \"ref\": \"ref_12\", \"duration\": 0.6 }\n````\n\n````\n\n```json\n{ \"action\": \"left_click_drag\", \"startRef\": \"ref_10\", \"ref\": \"ref_15\" }\n````\n\n### `chrome_click_element`\n\nClick elements using a ref, selector, or coordinates.\n\n**Parameters**:\n\n- `ref` (string, optional): Element ref from `chrome_read_page` (preferred when available)\n- `selector` (string, optional): CSS selector for target element\n- `coordinates` (object, optional): `{ \"x\": 120, \"y\": 240 }` viewport coordinates\n\nAt least one of `ref`, `selector`, or `coordinates` must be provided.\n\n**Example**:\n\n```json\n{\n  \"ref\": \"ref_42\"\n}\n```\n\n### `chrome_fill_or_select`\n\nFill form fields or select options.\n\n**Parameters**:\n\n- `ref` (string, optional): Element ref from `chrome_read_page`\n- `selector` (string, optional): CSS selector for target element\n- `value` (string, required): Value to fill or select\n\nProvide `ref` or `selector` to identify the element.\n\n**Example**:\n\n```json\n{\n  \"ref\": \"ref_7\",\n  \"value\": \"user@example.com\"\n}\n```\n\n### `chrome_keyboard`\n\nSimulate keyboard input and shortcuts.\n\n**Parameters**:\n\n- `keys` (string, required): Key combination (e.g., \"Ctrl+C\", \"Enter\")\n- `selector` (string, optional): Target element selector\n- `delay` (number, optional): Delay between keystrokes in ms (default: 0)\n\n**Example**:\n\n```json\n{\n  \"keys\": \"Ctrl+A\",\n  \"selector\": \"#text-input\",\n  \"delay\": 100\n}\n```\n\n## 📚 Data Management\n\n### `chrome_history`\n\nSearch browser history with filters.\n\n**Parameters**:\n\n- `text` (string, optional): Search text in URL/title\n- `startTime` (string, optional): Start date (ISO format)\n- `endTime` (string, optional): End date (ISO format)\n- `maxResults` (number, optional): Maximum results (default: 100)\n- `excludeCurrentTabs` (boolean, optional): Exclude current tabs (default: true)\n\n**Example**:\n\n```json\n{\n  \"text\": \"github\",\n  \"startTime\": \"2024-01-01\",\n  \"maxResults\": 50\n}\n```\n\n### `chrome_bookmark_search`\n\nSearch bookmarks by keywords.\n\n**Parameters**:\n\n- `query` (string, optional): Search keywords\n- `maxResults` (number, optional): Maximum results (default: 100)\n- `folderPath` (string, optional): Search within specific folder\n\n**Example**:\n\n```json\n{\n  \"query\": \"documentation\",\n  \"maxResults\": 20,\n  \"folderPath\": \"Work/Resources\"\n}\n```\n\n### `chrome_bookmark_add`\n\nAdd new bookmarks with folder support.\n\n**Parameters**:\n\n- `url` (string, optional): URL to bookmark (default: current tab)\n- `title` (string, optional): Bookmark title (default: page title)\n- `parentId` (string, optional): Parent folder ID or path\n- `createFolder` (boolean, optional): Create folder if not exists (default: false)\n\n**Example**:\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"title\": \"Example Site\",\n  \"parentId\": \"Work/Resources\",\n  \"createFolder\": true\n}\n```\n\n### `chrome_bookmark_delete`\n\nDelete bookmarks by ID or URL.\n\n**Parameters**:\n\n- `bookmarkId` (string, optional): Bookmark ID to delete\n- `url` (string, optional): URL to find and delete\n\n**Example**:\n\n```json\n{\n  \"url\": \"https://example.com\"\n}\n```\n\n## 📋 Response Format\n\nAll tools return responses in the following format:\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"JSON string containing the actual response data\"\n    }\n  ],\n  \"isError\": false\n}\n```\n\nFor errors:\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"Error message describing what went wrong\"\n    }\n  ],\n  \"isError\": true\n}\n```\n\n## 🔧 Usage Examples\n\n### Complete Workflow Example\n\n```javascript\n// 1. Navigate to a page\nawait callTool('chrome_navigate', {\n  url: 'https://example.com',\n});\n\n// 2. Take a screenshot\nconst screenshot = await callTool('chrome_screenshot', {\n  fullPage: true,\n  storeBase64: true,\n});\n\n// 3. Start network monitoring\nawait callTool('chrome_network_capture_start', {\n  maxCaptureTime: 30000,\n});\n\n// 4. Interact with the page\nawait callTool('chrome_click_element', {\n  selector: '#load-data-button',\n});\n\n// 5. Search content semantically\nconst searchResults = await callTool('search_tabs_content', {\n  query: 'user data analysis',\n});\n\n// 6. Stop network capture\nconst networkData = await callTool('chrome_network_capture_stop');\n\n// 7. Save bookmark\nawait callTool('chrome_bookmark_add', {\n  title: 'Data Analysis Page',\n  parentId: 'Work/Analytics',\n});\n```\n\nThis API provides comprehensive browser automation capabilities with AI-enhanced content analysis and semantic search features.\n"
  },
  {
    "path": "docs/TOOLS_zh.md",
    "content": "# Chrome MCP Server API 参考 📚\n\n所有可用工具及其参数的完整参考。\n\n## 📋 目录\n\n- [浏览器管理](#浏览器管理)\n- [截图和视觉](#截图和视觉)\n- [网络监控](#网络监控)\n- [内容分析](#内容分析)\n- [交互操作](#交互操作)\n- [数据管理](#数据管理)\n- [响应格式](#响应格式)\n\n## 📊 浏览器管理\n\n### `get_windows_and_tabs`\n\n列出当前打开的所有浏览器窗口和标签页。\n\n**参数**：无\n\n**响应**：\n\n```json\n{\n  \"windowCount\": 2,\n  \"tabCount\": 5,\n  \"windows\": [\n    {\n      \"windowId\": 123,\n      \"tabs\": [\n        {\n          \"tabId\": 456,\n          \"url\": \"https://example.com\",\n          \"title\": \"示例页面\",\n          \"active\": true\n        }\n      ]\n    }\n  ]\n}\n```\n\n### `chrome_navigate`\n\n导航到指定 URL，可选择控制视口。\n\n**参数**：\n\n- `url` (字符串，必需)：要导航到的 URL\n- `newWindow` (布尔值，可选)：创建新窗口（默认：false）\n- `width` (数字，可选)：视口宽度（像素，默认：1280）\n- `height` (数字，可选)：视口高度（像素，默认：720）\n\n**示例**：\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"newWindow\": true,\n  \"width\": 1920,\n  \"height\": 1080\n}\n```\n\n### `chrome_close_tabs`\n\n关闭指定的标签页或窗口。\n\n**参数**：\n\n- `tabIds` (数组，可选)：要关闭的标签页 ID 数组\n- `windowIds` (数组，可选)：要关闭的窗口 ID 数组\n\n**示例**：\n\n```json\n{\n  \"tabIds\": [123, 456],\n  \"windowIds\": [789]\n}\n```\n\n### `chrome_switch_tab`\n\n切换到指定的浏览器标签页。\n\n**参数**：\n\n- `tabId` (数字，必需)：要切换到的标签页的 ID。\n- `windowId` (数字，可选)：该标签页所在窗口的 ID。\n\n**示例**：\n\n```json\n{\n  \"tabId\": 456,\n  \"windowId\": 123\n}\n```\n\n### `chrome_go_back_or_forward`\n\n浏览器历史导航。\n\n**参数**：\n\n- `direction` (字符串，必需)：\"back\" 或 \"forward\"\n- `tabId` (数字，可选)：特定标签页 ID（默认：活动标签页）\n\n**示例**：\n\n```json\n{\n  \"direction\": \"back\",\n  \"tabId\": 123\n}\n```\n\n## 📸 截图和视觉\n\n### `chrome_screenshot`\n\n使用各种选项进行高级截图。\n\n**参数**：\n\n- `name` (字符串，可选)：截图文件名\n- `selector` (字符串，可选)：元素截图的 CSS 选择器\n- `width` (数字，可选)：宽度（像素，默认：800）\n- `height` (数字，可选)：高度（像素，默认：600）\n- `storeBase64` (布尔值，可选)：返回 base64 数据（默认：false）\n- `fullPage` (布尔值，可选)：捕获整个页面（默认：true）\n\n**示例**：\n\n```json\n{\n  \"selector\": \".main-content\",\n  \"fullPage\": true,\n  \"storeBase64\": true,\n  \"width\": 1920,\n  \"height\": 1080\n}\n```\n\n**响应**：\n\n```json\n{\n  \"success\": true,\n  \"base64\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...\",\n  \"dimensions\": {\n    \"width\": 1920,\n    \"height\": 1080\n  }\n}\n```\n\n## 🌐 网络监控\n\n### `chrome_network_capture_start`\n\n使用 webRequest API 开始捕获网络请求。\n\n**参数**：\n\n- `url` (字符串，可选)：要导航并捕获的 URL\n- `maxCaptureTime` (数字，可选)：最大捕获时间（毫秒，默认：30000）\n- `inactivityTimeout` (数字，可选)：无活动后停止时间（毫秒，默认：3000）\n- `includeStatic` (布尔值，可选)：包含静态资源（默认：false）\n\n**示例**：\n\n```json\n{\n  \"url\": \"https://api.example.com\",\n  \"maxCaptureTime\": 60000,\n  \"includeStatic\": false\n}\n```\n\n### `chrome_network_capture_stop`\n\n停止网络捕获并返回收集的数据。\n\n**参数**：无\n\n**响应**：\n\n```json\n{\n  \"success\": true,\n  \"capturedRequests\": [\n    {\n      \"url\": \"https://api.example.com/data\",\n      \"method\": \"GET\",\n      \"status\": 200,\n      \"requestHeaders\": {...},\n      \"responseHeaders\": {...},\n      \"responseTime\": 150\n    }\n  ],\n  \"summary\": {\n    \"totalRequests\": 15,\n    \"captureTime\": 5000\n  }\n}\n```\n\n### `chrome_network_debugger_start`\n\n使用 Chrome Debugger API 开始捕获（包含响应体）。\n\n**参数**：\n\n- `url` (字符串，可选)：要导航并捕获的 URL\n\n### `chrome_network_debugger_stop`\n\n停止调试器捕获并返回包含响应体的数据。\n\n### `chrome_network_request`\n\n发送自定义 HTTP 请求。\n\n**参数**：\n\n- `url` (字符串，必需)：请求 URL\n- `method` (字符串，可选)：HTTP 方法（默认：\"GET\"）\n- `headers` (对象，可选)：请求头\n- `body` (字符串，可选)：请求体\n\n**示例**：\n\n```json\n{\n  \"url\": \"https://api.example.com/data\",\n  \"method\": \"POST\",\n  \"headers\": {\n    \"Content-Type\": \"application/json\"\n  },\n  \"body\": \"{\\\"key\\\": \\\"value\\\"}\"\n}\n```\n\n## 🔍 内容分析\n\n### `search_tabs_content`\n\n跨浏览器标签页的 AI 驱动语义搜索。\n\n**参数**：\n\n- `query` (字符串，必需)：搜索查询\n\n**示例**：\n\n```json\n{\n  \"query\": \"机器学习教程\"\n}\n```\n\n**响应**：\n\n```json\n{\n  \"success\": true,\n  \"totalTabsSearched\": 10,\n  \"matchedTabsCount\": 3,\n  \"vectorSearchEnabled\": true,\n  \"indexStats\": {\n    \"totalDocuments\": 150,\n    \"totalTabs\": 10,\n    \"semanticEngineReady\": true\n  },\n  \"matchedTabs\": [\n    {\n      \"tabId\": 123,\n      \"url\": \"https://example.com/ml-tutorial\",\n      \"title\": \"机器学习教程\",\n      \"semanticScore\": 0.85,\n      \"matchedSnippets\": [\"机器学习简介...\"],\n      \"chunkSource\": \"content\"\n    }\n  ]\n}\n```\n\n### `chrome_get_web_content`\n\n从网页提取 HTML 或文本内容。\n\n**参数**：\n\n- `format` (字符串，可选)：\"html\" 或 \"text\"（默认：\"text\"）\n- `selector` (字符串，可选)：特定元素的 CSS 选择器\n- `tabId` (数字，可选)：特定标签页 ID（默认：活动标签页）\n\n**示例**：\n\n```json\n{\n  \"format\": \"text\",\n  \"selector\": \".article-content\"\n}\n```\n\n### `chrome_get_interactive_elements`\n\n查找页面上可点击和交互的元素。\n\n**参数**：\n\n- `tabId` (数字，可选)：特定标签页 ID（默认：活动标签页）\n\n**响应**：\n\n```json\n{\n  \"elements\": [\n    {\n      \"selector\": \"#submit-button\",\n      \"type\": \"button\",\n      \"text\": \"提交\",\n      \"visible\": true,\n      \"clickable\": true\n    }\n  ]\n}\n```\n\n## 🎯 交互操作\n\n### `chrome_click_element`\n\n使用 CSS 选择器点击元素。\n\n**参数**：\n\n- `selector` (字符串，必需)：目标元素的 CSS 选择器\n- `tabId` (数字，可选)：特定标签页 ID（默认：活动标签页）\n\n**示例**：\n\n```json\n{\n  \"selector\": \"#submit-button\"\n}\n```\n\n### `chrome_fill_or_select`\n\n填充表单字段或选择选项。\n\n**参数**：\n\n- `selector` (字符串，必需)：目标元素的 CSS 选择器\n- `value` (字符串，必需)：要填充或选择的值\n- `tabId` (数字，可选)：特定标签页 ID（默认：活动标签页）\n\n**示例**：\n\n```json\n{\n  \"selector\": \"#email-input\",\n  \"value\": \"user@example.com\"\n}\n```\n\n### `chrome_keyboard`\n\n模拟键盘输入和快捷键。\n\n**参数**：\n\n- `keys` (字符串，必需)：按键组合（如：\"Ctrl+C\"、\"Enter\"）\n- `selector` (字符串，可选)：目标元素选择器\n- `delay` (数字，可选)：按键间延迟（毫秒，默认：0）\n\n**示例**：\n\n```json\n{\n  \"keys\": \"Ctrl+A\",\n  \"selector\": \"#text-input\",\n  \"delay\": 100\n}\n```\n\n## 📚 数据管理\n\n### `chrome_history`\n\n使用过滤器搜索浏览器历史记录。\n\n**参数**：\n\n- `text` (字符串，可选)：在 URL/标题中搜索文本\n- `startTime` (字符串，可选)：开始日期（ISO 格式）\n- `endTime` (字符串，可选)：结束日期（ISO 格式）\n- `maxResults` (数字，可选)：最大结果数（默认：100）\n- `excludeCurrentTabs` (布尔值，可选)：排除当前标签页（默认：true）\n\n**示例**：\n\n```json\n{\n  \"text\": \"github\",\n  \"startTime\": \"2024-01-01\",\n  \"maxResults\": 50\n}\n```\n\n### `chrome_bookmark_search`\n\n按关键词搜索书签。\n\n**参数**：\n\n- `query` (字符串，可选)：搜索关键词\n- `maxResults` (数字，可选)：最大结果数（默认：100）\n- `folderPath` (字符串，可选)：在特定文件夹内搜索\n\n**示例**：\n\n```json\n{\n  \"query\": \"文档\",\n  \"maxResults\": 20,\n  \"folderPath\": \"工作/资源\"\n}\n```\n\n### `chrome_bookmark_add`\n\n添加支持文件夹的新书签。\n\n**参数**：\n\n- `url` (字符串，可选)：要收藏的 URL（默认：当前标签页）\n- `title` (字符串，可选)：书签标题（默认：页面标题）\n- `parentId` (字符串，可选)：父文件夹 ID 或路径\n- `createFolder` (布尔值，可选)：如果不存在则创建文件夹（默认：false）\n\n**示例**：\n\n```json\n{\n  \"url\": \"https://example.com\",\n  \"title\": \"示例网站\",\n  \"parentId\": \"工作/资源\",\n  \"createFolder\": true\n}\n```\n\n### `chrome_bookmark_delete`\n\n按 ID 或 URL 删除书签。\n\n**参数**：\n\n- `bookmarkId` (字符串，可选)：要删除的书签 ID\n- `url` (字符串，可选)：要查找并删除的 URL\n\n**示例**：\n\n```json\n{\n  \"url\": \"https://example.com\"\n}\n```\n\n## 📋 响应格式\n\n所有工具都返回以下格式的响应：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"包含实际响应数据的 JSON 字符串\"\n    }\n  ],\n  \"isError\": false\n}\n```\n\n对于错误：\n\n```json\n{\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"描述出错原因的错误消息\"\n    }\n  ],\n  \"isError\": true\n}\n```\n\n## 🔧 使用示例\n\n### 完整工作流示例\n\n```javascript\n// 1. 导航到页面\nawait callTool('chrome_navigate', {\n  url: 'https://example.com',\n});\n\n// 2. 截图\nconst screenshot = await callTool('chrome_screenshot', {\n  fullPage: true,\n  storeBase64: true,\n});\n\n// 3. 开始网络监控\nawait callTool('chrome_network_capture_start', {\n  maxCaptureTime: 30000,\n});\n\n// 4. 与页面交互\nawait callTool('chrome_click_element', {\n  selector: '#load-data-button',\n});\n\n// 5. 语义搜索内容\nconst searchResults = await callTool('search_tabs_content', {\n  query: '用户数据分析',\n});\n\n// 6. 停止网络捕获\nconst networkData = await callTool('chrome_network_capture_stop');\n\n// 7. 保存书签\nawait callTool('chrome_bookmark_add', {\n  title: '数据分析页面',\n  parentId: '工作/分析',\n});\n```\n\n此 API 提供全面的浏览器自动化功能，具有 AI 增强的内容分析和语义搜索特性。\n"
  },
  {
    "path": "docs/TROUBLESHOOTING.md",
    "content": "# 🚀 Installation and Connection Issues\n\n## Quick Diagnosis\n\nRun the diagnostic tool to identify common issues:\n\n```bash\nmcp-chrome-bridge doctor\n```\n\nTo automatically fix common issues:\n\n```bash\nmcp-chrome-bridge doctor --fix\n```\n\n## Export Report for GitHub Issues\n\nIf you need to open an issue, export a diagnostic report:\n\n```bash\n# Print Markdown report to terminal (copy/paste into GitHub Issue)\nmcp-chrome-bridge report\n\n# Write to a file\nmcp-chrome-bridge report --output mcp-report.md\n\n# Copy directly to clipboard\nmcp-chrome-bridge report --copy\n```\n\nBy default, usernames, paths, and tokens are redacted. Use `--no-redact` if you're comfortable sharing full paths.\n\n## If Connection Fails After Clicking the Connect Button on the Extension\n\n1. **Run the diagnostic tool first**\n\n```bash\nmcp-chrome-bridge doctor\n```\n\nThis will check installation, manifest, permissions, and Node.js path.\n\n2. **Check if mcp-chrome-bridge is installed successfully**, ensure it's globally installed\n\n```bash\nmcp-chrome-bridge -V\n```\n\n<img width=\"612\" alt=\"Screenshot 2025-06-11 15 09 57\" src=\"https://github.com/user-attachments/assets/59458532-e6e1-457c-8c82-3756a5dbb28e\" />\n\n2. **Check if the manifest file is in the correct directory**\n\nWindows path: C:\\Users\\xxx\\AppData\\Roaming\\Google\\Chrome\\NativeMessagingHosts\n\nMac path: /Users/xxx/Library/Application\\ Support/Google/Chrome/NativeMessagingHosts\n\nIf the npm package is installed correctly, a file named `com.chromemcp.nativehost.json` should be generated in this directory\n\n3. **Check logs**\n   Logs are now stored in user-writable directories:\n\n- **macOS**: `~/Library/Logs/mcp-chrome-bridge/`\n- **Windows**: `%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\\`\n- **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`\n\n<img width=\"804\" alt=\"Screenshot 2025-06-11 15 09 41\" src=\"https://github.com/user-attachments/assets/ce7b7c94-7c84-409a-8210-c9317823aae1\" />\n\n4. **Check if you have execution permissions**\n   You need to check your installation path (if unclear, open the manifest file in step 2, the path field shows the installation directory). For example, if the Mac installation path is as follows:\n\n`xxx/node_modules/mcp-chrome-bridge/dist/run_host.sh`\n\nCheck if this script has execution permissions. Run to fix:\n\n```bash\nmcp-chrome-bridge fix-permissions\n```\n\n5. **Node.js not found**\n   If you use a Node version manager (nvm, volta, asdf, fnm), the wrapper script may not find Node.js. Set the `CHROME_MCP_NODE_PATH` environment variable:\n\n```bash\nexport CHROME_MCP_NODE_PATH=/path/to/your/node\n```\n\nOr run `mcp-chrome-bridge doctor --fix` to write the current Node path.\n\n## Log Locations\n\nWrapper logs are now stored in user-writable locations:\n\n- **macOS**: `~/Library/Logs/mcp-chrome-bridge/`\n- **Windows**: `%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\\`\n- **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`\n"
  },
  {
    "path": "docs/TROUBLESHOOTING_zh.md",
    "content": "## 🚀 安装和连接问题\n\n### 快速诊断\n\n运行诊断工具来识别常见问题：\n\n```bash\nmcp-chrome-bridge doctor\n```\n\n自动修复常见问题：\n\n```bash\nmcp-chrome-bridge doctor --fix\n```\n\n### 导出诊断报告\n\n如果需要提交 Issue，可以导出诊断报告：\n\n```bash\n# 打印 Markdown 报告到终端（复制粘贴到 GitHub Issue）\nmcp-chrome-bridge report\n\n# 写入到文件\nmcp-chrome-bridge report --output mcp-report.md\n\n# 直接复制到剪贴板\nmcp-chrome-bridge report --copy\n```\n\n默认情况下，用户名、路径和令牌会被脱敏。如果你需要提供完整路径，可以使用 `--no-redact`。\n\n### 常见问题\n\n#### 连接成功，但是服务启动失败\n\n启动失败基本上都是**权限问题**或者用包管理工具安装的**node**导致的启动脚本找不到对应的node。\n\n**推荐先运行诊断工具：**\n\n```bash\nmcp-chrome-bridge doctor\n```\n\n核心排查流程\n\n1. npm包全局安装后，确认清单文件com.chromemcp.nativehost.json的位置，里面有一个**path**字段，指向的是一个启动脚本:\n\n1.1 **检查mcp-chrome-bridge是否安装成功**，确保是**全局安装**的\n\n```bash\nmcp-chrome-bridge -V\n```\n\n<img width=\"612\" alt=\"截屏2025-06-11 15 09 57\" src=\"https://github.com/user-attachments/assets/59458532-e6e1-457c-8c82-3756a5dbb28e\" />\n\n1.2 **检查清单文件是否已放在正确目录**\n\nwindows路径：C:\\Users\\xxx\\AppData\\Roaming\\Google\\Chrome\\NativeMessagingHosts\n\nmac路径： /Users/xxx/Library/Application\\ Support/Google/Chrome/NativeMessagingHosts\n\n如果npm包安装正常的话，这个目录下会生成一个`com.chromemcp.nativehost.json`\n\n```json\n{\n  \"name\": \"com.chromemcp.nativehost\",\n  \"description\": \"Node.js Host for Browser Bridge Extension\",\n  \"path\": \"/Users/xxx/Library/pnpm/global/5/.pnpm/mcp-chrome-bridge@1.0.23/node_modules/mcp-chrome-bridge/dist/run_host.sh\",\n  \"type\": \"stdio\",\n  \"allowed_origins\": [\"chrome-extension://hbdgbgagpkpjffpklnamcljpakneikee/\"]\n}\n```\n\n> 如果发现没有此清单文件，可以尝试命令行执行：`mcp-chrome-bridge register`\n\n2. **检查日志**\n\n日志现在存储在用户可写目录：\n\n- **macOS**: `~/Library/Logs/mcp-chrome-bridge/`\n- **Windows**: `%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\\`（例如 `C:\\Users\\xxx\\AppData\\Local\\mcp-chrome-bridge\\logs\\`）\n- **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`\n\n<img width=\"804\" alt=\"截屏2025-06-11 15 09 41\" src=\"https://github.com/user-attachments/assets/ce7b7c94-7c84-409a-8210-c9317823aae1\" />\n\n3. 一般失败的原因就是两种\n\n3.1. run_host.sh(windows是run_host.bat)没有执行权限：运行以下命令修复：\n\n```bash\nmcp-chrome-bridge fix-permissions\n```\n\n3.2. 脚本找不到node：如果你使用 Node 版本管理工具（nvm、volta、asdf、fnm），可以设置 `CHROME_MCP_NODE_PATH` 环境变量：\n\n```bash\nexport CHROME_MCP_NODE_PATH=/path/to/your/node\n```\n\n或者运行 `mcp-chrome-bridge doctor --fix` 来写入当前 Node 路径。\n\n3.3 如果排除了以上两种原因都不行，则查看日志目录的日志，然后提issue\n\n### 日志位置\n\n包装器日志现在存储在用户可写的位置：\n\n- **macOS**: `~/Library/Logs/mcp-chrome-bridge/`\n- **Windows**: `%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\\`\n- **Linux**: `~/.local/state/mcp-chrome-bridge/logs/`\n\n#### 工具执行超时\n\n有可能长时间连接的时候session会超时，这个时候重新连接即可\n\n#### 效果问题\n\n不同的agent，不同的模型使用工具的效果是不一样的，这些都需要你自行尝试，我更推荐用聪明的agent，比如augment，claude code等等...\n"
  },
  {
    "path": "docs/VisualEditor.md",
    "content": "# A Visual Editor for Claude Code & Codex\n\n**How to enable:**\n`Right Click > Chrome MCP Server > Toggle Web Editing Mode`\n**Shortcut:** `Cmd/Ctrl` + `Shift` + `O`\n\n### Interactive Sizing & Layout Adjustment\n\nDirectly drag element edges on the canvas to adjust width, height, and font sizes. All visual manipulations are automatically converted into code suggestions and applied to your source code by the Agent, bridging the gap between design and development in real-time.\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/76_DsUU7aHs\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### Visual Property Controls\n\nManage CSS properties directly through a visual inspector panel. Effortlessly tweak Flex/Grid layouts, margins, padding, and styling details with a single click. Perfect for rapid prototyping or UI fine-tuning, significantly reducing the time spent writing raw CSS.\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/ADOzT7El2mI\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### Live Component State Debugging (Vue/React)\n\nInspect and modify React and Vue component props in real-time. Test how your components render under different data states without ever leaving your current view or writing temporary console logs.\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/PaIxdpGcEEk\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### Point, Click & Prompt\n\nSelect any element on the page and send instructions directly to Claude Code or Codex. The tool automatically captures the component's structure and context, enabling the AI to provide modifications with far greater precision and lower latency than global chat contexts.\n\nSimply click an element and say, _\"Make this bigger\"_ or _\"Change the background to red\"_, and watch Claude Code implement the exact changes in seconds.\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/dSkt5HaTU_s\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n"
  },
  {
    "path": "docs/VisualEditor_zh.md",
    "content": "## 让Claude Code/Codex也能使用的可视化编辑器\n\n如何开启：`右键 > chrome mcp server > 切换网页编辑模式`\n或者快捷键： `cmd/ctrl + shift + o`\n\n### 交互式尺寸与排版调整\n\n接在画布上拖拽元素边缘调整宽、高及字体大小。所有的视觉调整将自动转换为代码变更建议，由 Agent 应用到源码中，实现设计与代码的实时同步。\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/76_DsUU7aHs\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### 可视化属性面板\n\n通过元素属性面板直接管理 CSS 属性。支持一键调整 Flex/Grid 布局、内外边距及样式细节。适合快速原型设计或 UI 微调，大幅减少 CSS 编写时间。\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/ADOzT7El2mI\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### 直接调试组件Vue/React组件的状态\n\n支持实时查看和修改 React 及 Vue 组件的 props，无需离开当前视图，即可测试组件在不同状态下的渲染表现。\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/PaIxdpGcEEk\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n\n### 点选并提示\n\n选中任意页面元素，直接向Claude Code或者Codex发送修改指令。工具会自动提取选中组件结构与上下文信息发送给 AI，从而实现比全局对话更精准、更低延迟的代码修改。比如你可以点选某个元素然后说「把这个变大一些」，让Claude Code帮你在几秒内实现精准修改并实时生效\n\n<div align=\"center\">\n  <a href=\"https://youtu.be/dSkt5HaTU_s\">\n    <img src=\"https://img.youtube.com/vi/76_DsUU7aHs/maxresdefault.jpg\" alt=\"Interactive Sizing & Layout Adjustment\" style=\"width:100%; max-width:600px;\">\n  </a>\n</div>\n"
  },
  {
    "path": "docs/WINDOWS_INSTALL_zh.md",
    "content": "# windows 安装指南 🔧\n\nChrome MCP Server 在windows电脑的详细安装和配置步骤\n\n## 📋 安装\n\n1. **从github上下载最新的chrome扩展**\n\n下载地址：https://github.com/hangwin/mcp-chrome/releases\n\n2. **全局安装mcp-chrome-bridge**\n\n确保电脑上已经安装了node，如果没安装请自行先安装\n\n```bash\nnpm install -g mcp-chrome-bridge\n```\n\n3. **加载 Chrome 扩展**\n   - 打开 Chrome 并访问 `chrome://extensions/`\n   - 启用\"开发者模式\"\n   - 点击\"加载已解压的扩展程序\"，选择 `your/dowloaded/extension/folder`\n   - 点击插件图标打开插件，点击连接即可看到mcp的配置\n     <img width=\"475\" alt=\"截屏2025-06-09 15 52 06\" src=\"https://github.com/user-attachments/assets/241e57b8-c55f-41a4-9188-0367293dc5bc\" />\n\n4. **在 CherryStudio 中使用**\n\n类型选streamableHttp，url填http://127.0.0.1:12306/mcp\n\n<img width=\"675\" alt=\"截屏2025-06-11 15 00 29\" src=\"https://github.com/user-attachments/assets/6631e9e4-57f9-477e-b708-6a285cc0d881\" />\n\n查看工具列表，如果能列出工具，说明已经可以使用了\n\n<img width=\"672\" alt=\"截屏2025-06-11 15 14 55\" src=\"https://github.com/user-attachments/assets/d08b7e51-3466-4ab7-87fa-3f1d7be9d112\" />\n\n```json\n{\n  \"mcpServers\": {\n    \"streamable-mcp-server\": {\n      \"type\": \"streamable-http\",\n      \"url\": \"http://127.0.0.1:12306/mcp\"\n    }\n  }\n}\n```\n\n## 🚀 安装和连接问题\n\n### 快速诊断\n\n如果遇到问题，运行诊断工具：\n\n```bash\nmcp-chrome-bridge doctor\n```\n\n自动修复常见问题：\n\n```bash\nmcp-chrome-bridge doctor --fix\n```\n\n### 点击扩展的连接按钮后如果没连接成功\n\n1. **检查mcp-chrome-bridge是否安装成功**，确保是全局安装的\n\n```bash\nmcp-chrome-bridge -V\n```\n\n<img width=\"612\" alt=\"截屏2025-06-11 15 09 57\" src=\"https://github.com/user-attachments/assets/59458532-e6e1-457c-8c82-3756a5dbb28e\" />\n\n2. **检查清单文件是否已放在正确目录**\n\n路径：C:\\Users\\xxx\\AppData\\Roaming\\Google\\Chrome\\NativeMessagingHosts\n\n3. **检查日志**\n\n日志现在存储在用户目录：`%LOCALAPPDATA%\\mcp-chrome-bridge\\logs\\`\n\n例如：`C:\\Users\\xxx\\AppData\\Local\\mcp-chrome-bridge\\logs\\`\n\n<img width=\"804\" alt=\"截屏2025-06-11 15 09 41\" src=\"https://github.com/user-attachments/assets/ce7b7c94-7c84-409a-8210-c9317823aae1\" />\n\n4. **Node.js 路径问题**\n\n如果使用 Node 版本管理器（nvm-windows、volta、fnm），可以设置环境变量：\n\n```cmd\nset CHROME_MCP_NODE_PATH=C:\\path\\to\\your\\node.exe\n```\n\n或者运行 `mcp-chrome-bridge doctor --fix` 自动写入当前 Node 路径。\n"
  },
  {
    "path": "docs/mcp-cli-config.md",
    "content": "# CLI MCP Configuration Guide\n\nThis guide explains how to configure Codex CLI and Claude Code to connect to the Chrome MCP Server.\n\n## Overview\n\nThe Chrome MCP Server exposes its MCP interface at `http://127.0.0.1:12306/mcp` (default port).\nBoth Codex CLI and Claude Code can connect to this endpoint to use Chrome browser control tools.\n\n## Codex CLI Configuration\n\n### Option 1: HTTP MCP Server (Recommended)\n\nAdd the following to your `~/.codex/config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp\": {\n      \"url\": \"http://127.0.0.1:12306/mcp\"\n    }\n  }\n}\n```\n\n### Option 2: Via Environment Variable\n\nSet the MCP URL via environment variable before running codex:\n\n```bash\nexport MCP_HTTP_PORT=12306\n```\n\n## Claude Code Configuration\n\n### Option 1: HTTP MCP Server\n\nAdd the following to your `~/.claude/claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp\": {\n      \"url\": \"http://127.0.0.1:12306/mcp\"\n    }\n  }\n}\n```\n\n### Option 2: Stdio Server (Alternative)\n\nIf you prefer stdio-based MCP communication:\n\n```json\n{\n  \"mcpServers\": {\n    \"chrome-mcp\": {\n      \"command\": \"node\",\n      \"args\": [\"/path/to/mcp-chrome/dist/mcp/mcp-server-stdio.js\"]\n    }\n  }\n}\n```\n\n## Verifying Connection\n\nAfter configuration, the CLI tools should be able to see and use Chrome MCP tools such as:\n\n- `chrome_get_windows_and_tabs` - Get browser window and tab information\n- `chrome_navigate` - Navigate to a URL\n- `chrome_click_element` - Click on page elements\n- `chrome_get_page_content` - Get page content\n- And more...\n\n## Troubleshooting\n\n### Connection Refused\n\nIf you get \"connection refused\" errors:\n\n1. Ensure the Chrome extension is installed and the native server is running\n2. Check that the port matches (default: 12306)\n3. Verify no firewall is blocking localhost connections\n4. Run `mcp-chrome-bridge doctor` to diagnose issues\n\n### Tools Not Appearing\n\nIf MCP tools don't appear in the CLI:\n\n1. Restart the CLI tool after configuration changes\n2. Check the configuration file syntax (valid JSON)\n3. Ensure the MCP server URL is accessible\n\n### Port Conflicts\n\nIf port 12306 is already in use:\n\n1. Set a custom port in the extension settings\n2. Update the CLI configuration to match the new port\n3. Run `mcp-chrome-bridge update-port <new-port>` to update the stdio config\n\n## Environment Variables\n\n| Variable                     | Description                            | Default |\n| ---------------------------- | -------------------------------------- | ------- |\n| `MCP_HTTP_PORT`              | HTTP port for MCP server               | 12306   |\n| `MCP_ALLOWED_WORKSPACE_BASE` | Additional allowed workspace directory | (none)  |\n| `CHROME_MCP_NODE_PATH`       | Override Node.js executable path       | (auto)  |\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import globals from 'globals';\nimport js from '@eslint/js';\nimport tseslint from 'typescript-eslint';\nimport eslintConfigPrettier from 'eslint-config-prettier';\n\nexport default tseslint.config(\n  // Global ignores first - these apply to all configurations\n  {\n    ignores: [\n      'node_modules/',\n      'dist/',\n      '.output/',\n      '.wxt/',\n      'logs/',\n      '*.log',\n      '.cache/',\n      '.temp/',\n      '.idea/',\n      '.DS_Store',\n      'Thumbs.db',\n      '*.zip',\n      '*.tar.gz',\n      'stats.html',\n      'stats-*.json',\n      'pnpm-lock.yaml',\n      '**/workers/**',\n      'app/**/workers/**',\n      'packages/**/workers/**',\n      'test-inject-script.js',\n    ],\n  },\n\n  js.configs.recommended,\n  ...tseslint.configs.recommended,\n  // Global rule adjustments\n  {\n    // Allow intentionally empty catch blocks (common in extension code),\n    // while keeping other empty blocks reported.\n    rules: {\n      'no-empty': ['error', { allowEmptyCatch: true }],\n    },\n  },\n  {\n    files: ['app/**/*.{js,jsx,ts,tsx}', 'packages/**/*.{js,jsx,ts,tsx}'],\n    ignores: ['**/workers/**'], // Additional ignores for this specific config\n    languageOptions: {\n      ecmaVersion: 2021,\n      sourceType: 'module',\n      parser: tseslint.parser,\n      globals: {\n        ...globals.node,\n        ...globals.es2021,\n      },\n    },\n\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-require-imports': 'off',\n      '@typescript-eslint/no-unused-vars': 'off',\n    },\n  },\n  eslintConfigPrettier,\n);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"mcp-chrome-bridge-monorepo\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"author\": \"hangye\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build:shared\": \"pnpm --filter chrome-mcp-shared build\",\n    \"build:native\": \"pnpm --filter mcp-chrome-bridge build\",\n    \"build:extension\": \"pnpm --filter chrome-mcp-server build\",\n    \"build:wasm\": \"pnpm --filter @chrome-mcp/wasm-simd build && pnpm run copy:wasm\",\n    \"build\": \"pnpm -r --filter='!@chrome-mcp/wasm-simd' build\",\n    \"copy:wasm\": \"cp ./packages/wasm-simd/pkg/simd_math.js ./packages/wasm-simd/pkg/simd_math_bg.wasm ./app/chrome-extension/workers/\",\n    \"dev:shared\": \"pnpm --filter chrome-mcp-shared dev\",\n    \"dev:native\": \"pnpm --filter mcp-chrome-bridge dev\",\n    \"dev:extension\": \"pnpm --filter chrome-mcp-server dev\",\n    \"dev\": \"pnpm --filter chrome-mcp-shared build && pnpm -r --parallel dev\",\n    \"lint\": \"pnpm -r lint\",\n    \"lint:fix\": \"pnpm -r lint:fix\",\n    \"format\": \"pnpm -r format\",\n    \"clean:dist\": \"pnpm -r exec rm -rf dist .turbo\",\n    \"clean:modules\": \"pnpm -r exec rm -rf node_modules && rm -rf node_modules\",\n    \"clean\": \"npm run clean:dist && npm run clean:modules\",\n    \"typecheck\": \"pnpm -r exec tsc --noEmit\",\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"@commitlint/cli\": \"^19.8.1\",\n    \"@commitlint/config-conventional\": \"^19.8.1\",\n    \"@eslint/js\": \"^9.25.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.32.0\",\n    \"@typescript-eslint/parser\": \"^8.32.0\",\n    \"eslint\": \"^9.26.0\",\n    \"eslint-config-prettier\": \"^10.1.5\",\n    \"eslint-plugin-vue\": \"^10.0.0\",\n    \"globals\": \"^16.1.0\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^15.5.1\",\n    \"prettier\": \"^3.5.3\",\n    \"typescript\": \"^5.8.3\",\n    \"typescript-eslint\": \"^8.32.0\",\n    \"vue-eslint-parser\": \"^10.1.3\"\n  },\n  \"lint-staged\": {\n    \"**/*.{js,jsx,ts,tsx,vue}\": [\n      \"eslint --fix\",\n      \"prettier --write\"\n    ],\n    \"**/*.{json,md,yaml,html,css}\": [\n      \"prettier --write\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/shared/package.json",
    "content": "{\n  \"name\": \"chrome-mcp-shared\",\n  \"version\": \"1.0.1\",\n  \"author\": \"hangye\",\n  \"main\": \"dist/index.js\",\n  \"module\": \"./dist/index.mjs\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"import\": {\n        \"types\": \"./dist/index.d.ts\",\n        \"default\": \"./dist/index.mjs\"\n      },\n      \"require\": {\n        \"types\": \"./dist/index.d.ts\",\n        \"default\": \"./dist/index.js\"\n      }\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsup src/index.ts --format cjs,esm --dts --clean\",\n    \"dev\": \"tsup src/index.ts --format cjs,esm --dts --watch\",\n    \"lint\": \"npx eslint 'src/**/*.{js,ts}'\",\n    \"lint:fix\": \"npx eslint 'src/**/*.{js,ts}' --fix\",\n    \"format\": \"prettier --write 'src/**/*.{js,ts,json}'\"\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"devDependencies\": {\n    \"@types/node\": \"^18.0.0\",\n    \"@typescript-eslint/parser\": \"^8.32.0\",\n    \"tsup\": \"^8.4.0\"\n  },\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.11.0\",\n    \"zod\": \"^3.24.4\"\n  }\n}\n"
  },
  {
    "path": "packages/shared/src/agent-types.ts",
    "content": "/**\n * Agent-side shared data contracts.\n * These types are shared between native-server and chrome-extension to ensure consistency.\n *\n * English is used for technical contracts; Chinese comments explain design choices.\n */\n\n// ============================================================\n// Core Types\n// ============================================================\n\nexport type AgentRole = 'user' | 'assistant' | 'tool' | 'system';\n\nexport interface AgentMessage {\n  id: string;\n  sessionId: string;\n  role: AgentRole;\n  content: string;\n  messageType: 'chat' | 'tool_use' | 'tool_result' | 'status';\n  cliSource?: string;\n  requestId?: string;\n  isStreaming?: boolean;\n  isFinal?: boolean;\n  createdAt: string;\n  metadata?: Record<string, unknown>;\n}\n\n// ============================================================\n// Stream Events\n// ============================================================\n\nexport type StreamTransport = 'sse' | 'websocket';\n\nexport interface AgentStatusEvent {\n  sessionId: string;\n  status: 'starting' | 'ready' | 'running' | 'completed' | 'error' | 'cancelled';\n  message?: string;\n  requestId?: string;\n}\n\nexport interface AgentConnectedEvent {\n  sessionId: string;\n  transport: StreamTransport;\n  timestamp: string;\n}\n\nexport interface AgentHeartbeatEvent {\n  timestamp: string;\n}\n\n/** Usage statistics for a request */\nexport interface AgentUsageStats {\n  sessionId: string;\n  requestId?: string;\n  inputTokens: number;\n  outputTokens: number;\n  cacheReadInputTokens?: number;\n  cacheCreationInputTokens?: number;\n  totalCostUsd: number;\n  durationMs: number;\n  numTurns: number;\n}\n\nexport type RealtimeEvent =\n  | { type: 'message'; data: AgentMessage }\n  | { type: 'status'; data: AgentStatusEvent }\n  | { type: 'error'; error: string; data?: { sessionId?: string; requestId?: string } }\n  | { type: 'connected'; data: AgentConnectedEvent }\n  | { type: 'heartbeat'; data: AgentHeartbeatEvent }\n  | { type: 'usage'; data: AgentUsageStats };\n\n// ============================================================\n// HTTP API Contracts\n// ============================================================\n\nexport interface AgentAttachment {\n  type: 'file' | 'image';\n  name: string;\n  mimeType: string;\n  dataBase64: string;\n}\n\nexport type AgentCliPreference = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';\n\nexport interface AgentActRequest {\n  instruction: string;\n  cliPreference?: AgentCliPreference;\n  model?: string;\n  attachments?: AgentAttachment[];\n  /**\n   * Optional logical project identifier. When provided, the backend\n   * can resolve a stable workspace configuration instead of relying\n   * solely on ad-hoc paths.\n   */\n  projectId?: string;\n  /**\n   * Optional database session ID (sessions.id). When provided, the backend\n   * will load session-level configuration (engine, model, permission mode,\n   * resume ids, etc.) from the sessions table.\n   */\n  dbSessionId?: string;\n  /**\n   * Optional project root / workspace directory on the local filesystem\n   * that the engine should use as its working directory.\n   */\n  projectRoot?: string;\n  /**\n   * Optional request id from client; server will generate one if missing.\n   */\n  requestId?: string;\n  /**\n   * Optional client metadata to store with the user message.\n   * For extension-specific context that should be preserved.\n   */\n  clientMeta?: Record<string, unknown>;\n  /**\n   * Optional display text override for the instruction.\n   * When set, UI should display this instead of raw instruction.\n   */\n  displayText?: string;\n}\n\nexport interface AgentActResponse {\n  requestId: string;\n  sessionId: string;\n  status: 'accepted';\n}\n\n// ============================================================\n// Project & Engine Types\n// ============================================================\n\nexport interface AgentProject {\n  id: string;\n  name: string;\n  description?: string;\n  /**\n   * Absolute filesystem path for this project workspace.\n   */\n  rootPath: string;\n  preferredCli?: AgentCliPreference;\n  selectedModel?: string;\n  /**\n   * Active Claude session ID (UUID format) for session resumption.\n   * Captured from SDK's system/init message and used for the 'resume' parameter.\n   */\n  activeClaudeSessionId?: string;\n  /**\n   * Whether to use Claude Code Router (CCR) for this project.\n   * When enabled, the engine will auto-detect CCR configuration.\n   */\n  useCcr?: boolean;\n  /**\n   * Whether to enable Chrome MCP integration for this project.\n   * Default: true\n   */\n  enableChromeMcp?: boolean;\n  createdAt: string;\n  updatedAt: string;\n  lastActiveAt?: string;\n}\n\nexport interface AgentEngineInfo {\n  name: string;\n  supportsMcp?: boolean;\n}\n\n// ============================================================\n// Session Types\n// ============================================================\n\n/**\n * System prompt configuration for a session.\n */\nexport type AgentSystemPromptConfig =\n  | { type: 'custom'; text: string }\n  | { type: 'preset'; preset: 'claude_code'; append?: string };\n\n/**\n * Tools configuration - can be a list of tool names or a preset.\n */\nexport type AgentToolsConfig = string[] | { type: 'preset'; preset: 'claude_code' };\n\n/**\n * Session options configuration.\n */\nexport interface AgentSessionOptionsConfig {\n  settingSources?: string[];\n  allowedTools?: string[];\n  disallowedTools?: string[];\n  tools?: AgentToolsConfig;\n  betas?: string[];\n  maxThinkingTokens?: number;\n  maxTurns?: number;\n  maxBudgetUsd?: number;\n  mcpServers?: Record<string, unknown>;\n  outputFormat?: Record<string, unknown>;\n  enableFileCheckpointing?: boolean;\n  sandbox?: Record<string, unknown>;\n  env?: Record<string, string>;\n  /**\n   * Optional Codex-specific configuration overrides.\n   * Only applicable when using CodexEngine.\n   */\n  codexConfig?: Partial<CodexEngineConfig>;\n}\n\n/**\n * Cached management information from Claude SDK.\n */\nexport interface AgentManagementInfo {\n  tools?: string[];\n  agents?: string[];\n  plugins?: Array<{ name: string; path?: string }>;\n  skills?: string[];\n  mcpServers?: Array<{ name: string; status: string }>;\n  slashCommands?: string[];\n  model?: string;\n  permissionMode?: string;\n  cwd?: string;\n  outputStyle?: string;\n  betas?: string[];\n  claudeCodeVersion?: string;\n  apiKeySource?: string;\n  lastUpdated?: string;\n}\n\n/**\n * Agent session - represents an independent conversation within a project.\n */\nexport interface AgentSession {\n  id: string;\n  projectId: string;\n  engineName: AgentCliPreference;\n  engineSessionId?: string;\n  name?: string;\n  /** Preview text from first user message, for display in session list */\n  preview?: string;\n  model?: string;\n  permissionMode: string;\n  allowDangerouslySkipPermissions: boolean;\n  systemPromptConfig?: AgentSystemPromptConfig;\n  optionsConfig?: AgentSessionOptionsConfig;\n  managementInfo?: AgentManagementInfo;\n  createdAt: string;\n  updatedAt: string;\n}\n\n/**\n * Options for creating a new session.\n */\nexport interface CreateAgentSessionInput {\n  engineName: AgentCliPreference;\n  name?: string;\n  model?: string;\n  permissionMode?: string;\n  allowDangerouslySkipPermissions?: boolean;\n  systemPromptConfig?: AgentSystemPromptConfig;\n  optionsConfig?: AgentSessionOptionsConfig;\n}\n\n/**\n * Options for updating a session.\n */\nexport interface UpdateAgentSessionInput {\n  name?: string | null;\n  model?: string | null;\n  permissionMode?: string | null;\n  allowDangerouslySkipPermissions?: boolean | null;\n  systemPromptConfig?: AgentSystemPromptConfig | null;\n  optionsConfig?: AgentSessionOptionsConfig | null;\n}\n\n// ============================================================\n// Stored Message (for persistence)\n// ============================================================\n\nexport interface AgentStoredMessage {\n  id: string;\n  projectId: string;\n  sessionId: string;\n  conversationId?: string | null;\n  role: AgentRole;\n  content: string;\n  messageType: AgentMessage['messageType'];\n  metadata?: Record<string, unknown>;\n  cliSource?: string | null;\n  createdAt?: string;\n  requestId?: string;\n}\n\n// ============================================================\n// Codex Engine Configuration\n// ============================================================\n\n/**\n * Sandbox mode for Codex CLI execution.\n */\nexport type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';\n\n/**\n * Reasoning effort for Codex models.\n * - low/medium/high: supported by all models\n * - xhigh: only supported by gpt-5.2 and gpt-5.1-codex-max\n */\nexport type CodexReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';\n\n/**\n * Configuration options for Codex Engine.\n * These can be overridden per-session via session settings.\n */\nexport interface CodexEngineConfig {\n  /** Enable apply_patch tool for file modifications. Default: true */\n  includeApplyPatchTool: boolean;\n  /** Enable plan tool for task planning. Default: true */\n  includePlanTool: boolean;\n  /** Enable web search capability. Default: true */\n  enableWebSearch: boolean;\n  /** Use experimental streamable shell tool. Default: true */\n  useStreamableShell: boolean;\n  /** Sandbox mode for command execution. Default: 'danger-full-access' */\n  sandboxMode: CodexSandboxMode;\n  /** Maximum number of turns. Default: 20 */\n  maxTurns: number;\n  /** Maximum thinking tokens. Default: 4096 */\n  maxThinkingTokens: number;\n  /** Reasoning effort for supported models. Default: 'medium' */\n  reasoningEffort: CodexReasoningEffort;\n  /** Auto instructions for autonomous behavior. Default: AUTO_INSTRUCTIONS */\n  autoInstructions: string;\n  /** Append project context (file listing) to prompt. Default: true */\n  appendProjectContext: boolean;\n}\n\n/**\n * Default auto instructions for Codex to act autonomously.\n * Aligned with other/cweb implementation.\n */\nexport const CODEX_AUTO_INSTRUCTIONS = `Act autonomously without asking for confirmations.\nUse apply_patch to create and modify files directly in the current working directory (do not create subdirectories unless the user explicitly requests it).\nUse exec_command to run, build, and test as needed.\nYou have full permissions. Keep taking concrete actions until the task is complete.\nRespect the existing project structure when creating or modifying files.\nPrefer concise status updates over questions.`;\n\n/**\n * Default configuration for Codex Engine.\n * Aligned with other/cweb implementation for feature parity.\n */\nexport const DEFAULT_CODEX_CONFIG: CodexEngineConfig = {\n  includeApplyPatchTool: true,\n  includePlanTool: true,\n  enableWebSearch: true,\n  useStreamableShell: true,\n  sandboxMode: 'danger-full-access',\n  maxTurns: 20,\n  maxThinkingTokens: 4096,\n  reasoningEffort: 'medium',\n  autoInstructions: CODEX_AUTO_INSTRUCTIONS,\n  appendProjectContext: true,\n};\n\n// ============================================================\n// Attachment Types\n// ============================================================\n\n/**\n * Metadata for a persisted attachment file.\n */\nexport interface AttachmentMetadata {\n  /** Schema version for forward compatibility */\n  version: number;\n  /** Kind of attachment (e.g., 'image', 'file') */\n  kind: string;\n  /** Project ID this attachment belongs to */\n  projectId: string;\n  /** Message ID this attachment is associated with */\n  messageId: string;\n  /** Index of this attachment in the message */\n  index: number;\n  /** Persisted filename under project dir */\n  filename: string;\n  /** URL path to access this attachment */\n  urlPath: string;\n  /** MIME type of the attachment */\n  mimeType: string;\n  /** File size in bytes */\n  sizeBytes: number;\n  /** Original filename from upload */\n  originalName: string;\n  /** Timestamp when attachment was created */\n  createdAt: string;\n}\n\n/**\n * Statistics for attachments in a single project.\n */\nexport interface AttachmentProjectStats {\n  projectId: string;\n  /** Directory path for this project's attachments */\n  dirPath: string;\n  /** Whether the directory exists */\n  exists: boolean;\n  fileCount: number;\n  totalBytes: number;\n  /** Last modification timestamp (only when exists is true) */\n  lastModifiedAt?: string;\n}\n\n/**\n * Cleanup result for a single project.\n */\nexport interface CleanupProjectResult {\n  projectId: string;\n  dirPath: string;\n  existed: boolean;\n  removedFiles: number;\n  removedBytes: number;\n}\n\n/**\n * Response for attachment statistics endpoint.\n */\nexport interface AttachmentStatsResponse {\n  success: boolean;\n  rootDir: string;\n  totalFiles: number;\n  totalBytes: number;\n  projects: Array<\n    AttachmentProjectStats & {\n      projectName?: string;\n      existsInDb: boolean;\n    }\n  >;\n  orphanProjectIds: string[];\n}\n\n/**\n * Request body for attachment cleanup endpoint.\n */\nexport interface AttachmentCleanupRequest {\n  /** If provided, cleanup only these projects. Otherwise cleanup all. */\n  projectIds?: string[];\n}\n\n/**\n * Response for attachment cleanup endpoint.\n */\nexport interface AttachmentCleanupResponse {\n  success: boolean;\n  scope: 'project' | 'selected' | 'all';\n  removedFiles: number;\n  removedBytes: number;\n  results: CleanupProjectResult[];\n}\n\n// ============================================================\n// Open Project Types\n// ============================================================\n\n/**\n * Target application for opening a project directory.\n */\nexport type OpenProjectTarget = 'vscode' | 'terminal';\n\n/**\n * Request body for open-project endpoint.\n */\nexport interface OpenProjectRequest {\n  /** Target application to open the project in */\n  target: OpenProjectTarget;\n}\n\n/**\n * Response for open-project endpoint.\n */\nexport type OpenProjectResponse = { success: true } | { success: false; error: string };\n"
  },
  {
    "path": "packages/shared/src/constants.ts",
    "content": "export const DEFAULT_SERVER_PORT = 12306;\nexport const HOST_NAME = 'com.chromemcp.nativehost';\n"
  },
  {
    "path": "packages/shared/src/index.ts",
    "content": "export * from './constants';\nexport * from './types';\nexport * from './tools';\nexport * from './rr-graph';\nexport * from './step-types';\nexport * from './labels';\nexport * from './node-spec';\nexport * from './node-spec-registry';\nexport * from './node-specs-builtin';\nexport * from './agent-types';\n"
  },
  {
    "path": "packages/shared/src/labels.ts",
    "content": "// labels.ts — centralized labels for edges and other enums\n\nexport const EDGE_LABELS = {\n  DEFAULT: 'default',\n  TRUE: 'true',\n  FALSE: 'false',\n  ON_ERROR: 'onError',\n} as const;\n\nexport type EdgeLabel = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];\n"
  },
  {
    "path": "packages/shared/src/node-spec-registry.ts",
    "content": "// node-spec-registry.ts — runtime registry for NodeSpec (shared between UI/runtime)\nimport type { NodeSpec } from './node-spec';\n\nconst REG = new Map<string, NodeSpec>();\n\nexport function registerNodeSpec(spec: NodeSpec) {\n  REG.set(spec.type, spec);\n}\n\nexport function getNodeSpec(type: string): NodeSpec | undefined {\n  return REG.get(type);\n}\n\nexport function listNodeSpecs(): NodeSpec[] {\n  return Array.from(REG.values());\n}\n"
  },
  {
    "path": "packages/shared/src/node-spec.ts",
    "content": "// node-spec.ts — shared NodeSpec types for UI-driven forms\n\nexport type FieldType = 'string' | 'number' | 'boolean' | 'select' | 'object' | 'array' | 'json';\n\nexport interface FieldSpecBase {\n  key: string;\n  label: string;\n  type: FieldType;\n  required?: boolean;\n  placeholder?: string;\n  help?: string;\n  // widget name used by UI; runtime ignores it\n  widget?: string;\n  uiProps?: Record<string, any>;\n}\n\nexport interface FieldString extends FieldSpecBase {\n  type: 'string';\n  default?: string;\n}\nexport interface FieldNumber extends FieldSpecBase {\n  type: 'number';\n  min?: number;\n  max?: number;\n  step?: number;\n  default?: number;\n}\nexport interface FieldBoolean extends FieldSpecBase {\n  type: 'boolean';\n  default?: boolean;\n}\nexport interface FieldSelect extends FieldSpecBase {\n  type: 'select';\n  options: Array<{ label: string; value: string | number | boolean }>;\n  default?: string | number | boolean;\n}\nexport interface FieldObject extends FieldSpecBase {\n  type: 'object';\n  fields: FieldSpec[];\n  default?: Record<string, any>;\n}\nexport interface FieldArray extends FieldSpecBase {\n  type: 'array';\n  item: FieldString | FieldNumber | FieldBoolean | FieldSelect | FieldObject | FieldJson;\n  default?: any[];\n}\nexport interface FieldJson extends FieldSpecBase {\n  type: 'json';\n  default?: any;\n}\n\nexport type FieldSpec =\n  | FieldString\n  | FieldNumber\n  | FieldBoolean\n  | FieldSelect\n  | FieldObject\n  | FieldArray\n  | FieldJson;\n\nexport type NodeCategory = 'Flow' | 'Actions' | 'Logic' | 'Tools' | 'Tabs' | 'Page';\n\nexport interface NodeSpecDisplay {\n  label: string;\n  iconClass: string;\n  category: NodeCategory;\n  docUrl?: string;\n}\n\nexport interface NodeSpec {\n  type: string; // Aligns with NodeType/StepType\n  version: number;\n  display: NodeSpecDisplay;\n  ports: { inputs: number | 'any'; outputs: Array<{ label?: string }> | 'any' };\n  schema: FieldSpec[];\n  defaults: Record<string, any>;\n  validate?: (config: any) => string[];\n}\n"
  },
  {
    "path": "packages/shared/src/node-specs-builtin.ts",
    "content": "// node-specs-builtin.ts — builtin NodeSpecs shared for UI + runtime\nimport type { NodeSpec } from './node-spec';\nimport { registerNodeSpec } from './node-spec-registry';\nimport { STEP_TYPES } from './step-types';\n\nexport function registerBuiltinSpecs() {\n  const nav: NodeSpec = {\n    type: STEP_TYPES.NAVIGATE,\n    version: 1,\n    display: { label: '导航', iconClass: 'icon-navigate', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'url',\n        label: 'URL',\n        type: 'string',\n        required: true,\n        placeholder: 'https://example.com',\n        help: '目标地址，支持变量模板 {var}',\n        default: '',\n      },\n    ],\n    defaults: { url: '' },\n    validate: (cfg) => {\n      const errs: string[] = [];\n      if (!cfg || !cfg.url || String(cfg.url).trim() === '') errs.push('URL 必填');\n      return errs;\n    },\n  };\n  registerNodeSpec(nav);\n\n  // Click / Dblclick\n  registerNodeSpec({\n    type: STEP_TYPES.CLICK,\n    version: 1,\n    display: { label: '点击', iconClass: 'icon-click', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'target',\n        label: '目标',\n        type: 'json',\n        widget: 'targetlocator',\n        help: '选择或输入元素选择器',\n      },\n      {\n        key: 'before',\n        label: '执行前',\n        type: 'object',\n        fields: [\n          { key: 'scrollIntoView', label: '滚动到可见', type: 'boolean', default: true },\n          { key: 'waitForSelector', label: '等待选择器', type: 'boolean', default: true },\n        ],\n      },\n      {\n        key: 'after',\n        label: '执行后',\n        type: 'object',\n        fields: [\n          { key: 'waitForNavigation', label: '等待导航完成', type: 'boolean', default: false },\n          { key: 'waitForNetworkIdle', label: '等待网络空闲', type: 'boolean', default: false },\n        ],\n      },\n    ],\n    defaults: { before: { scrollIntoView: true, waitForSelector: true }, after: {} },\n  });\n  registerNodeSpec({\n    type: STEP_TYPES.DBLCLICK,\n    version: 1,\n    display: { label: '双击', iconClass: 'icon-click', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'target', label: '目标', type: 'json', widget: 'targetlocator' },\n      {\n        key: 'before',\n        label: '执行前',\n        type: 'object',\n        fields: [\n          { key: 'scrollIntoView', label: '滚动到可见', type: 'boolean', default: true },\n          { key: 'waitForSelector', label: '等待选择器', type: 'boolean', default: true },\n        ],\n      },\n      {\n        key: 'after',\n        label: '执行后',\n        type: 'object',\n        fields: [\n          { key: 'waitForNavigation', label: '等待导航完成', type: 'boolean', default: false },\n          { key: 'waitForNetworkIdle', label: '等待网络空闲', type: 'boolean', default: false },\n        ],\n      },\n    ],\n    defaults: { before: { scrollIntoView: true, waitForSelector: true }, after: {} },\n  });\n\n  // Fill\n  registerNodeSpec({\n    type: STEP_TYPES.FILL,\n    version: 1,\n    display: { label: '填充', iconClass: 'icon-fill', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'target', label: '目标', type: 'json', widget: 'targetlocator' },\n      { key: 'value', label: '输入值', type: 'string', required: true, help: '支持 {var} 模板' },\n    ],\n    defaults: { value: '' },\n  });\n\n  // Key\n  registerNodeSpec({\n    type: STEP_TYPES.KEY,\n    version: 1,\n    display: { label: '键盘', iconClass: 'icon-key', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'keys',\n        label: '按键序列',\n        type: 'string',\n        widget: 'keysequence',\n        required: true,\n        help: '如 Backspace Enter 或 cmd+a',\n      },\n      { key: 'target', label: '焦点目标(可选)', type: 'json', widget: 'targetlocator' },\n    ],\n    defaults: { keys: '' },\n  });\n\n  // Scroll\n  registerNodeSpec({\n    type: STEP_TYPES.SCROLL,\n    version: 1,\n    display: { label: '滚动', iconClass: 'icon-scroll', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'mode',\n        label: '模式',\n        type: 'select',\n        options: [\n          { label: '元素', value: 'element' },\n          { label: '偏移', value: 'offset' },\n          { label: '容器', value: 'container' },\n        ] as any,\n        default: 'offset',\n      },\n      { key: 'target', label: '目标(当元素/容器)', type: 'json', widget: 'targetlocator' },\n      {\n        key: 'offset',\n        label: '偏移',\n        type: 'object',\n        fields: [\n          { key: 'x', label: 'X', type: 'number' },\n          { key: 'y', label: 'Y', type: 'number' },\n        ],\n      },\n    ],\n    defaults: { mode: 'offset', offset: { x: 0, y: 300 } },\n  });\n\n  // Drag\n  registerNodeSpec({\n    type: STEP_TYPES.DRAG,\n    version: 1,\n    display: { label: '拖拽', iconClass: 'icon-drag', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'start', label: '起点', type: 'json', widget: 'targetlocator' },\n      { key: 'end', label: '终点', type: 'json', widget: 'targetlocator' },\n      {\n        key: 'path',\n        label: '路径坐标',\n        type: 'array',\n        item: {\n          key: 'p',\n          label: '点',\n          type: 'object',\n          fields: [\n            { key: 'x', label: 'X', type: 'number' },\n            { key: 'y', label: 'Y', type: 'number' },\n          ],\n        } as any,\n      },\n    ],\n    defaults: {},\n  });\n\n  // Wait\n  registerNodeSpec({\n    type: STEP_TYPES.WAIT,\n    version: 1,\n    display: { label: '等待', iconClass: 'icon-wait', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'condition',\n        label: '条件(JSON)',\n        type: 'json',\n        help: '如 {\"sleep\":1000} 或 {\"text\":\"Hello\",\"appear\":true}',\n      },\n    ],\n    defaults: { condition: { sleep: 500 } },\n  });\n\n  // Assert\n  registerNodeSpec({\n    type: STEP_TYPES.ASSERT,\n    version: 1,\n    display: { label: '断言', iconClass: 'icon-assert', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }, { label: 'onError' }] },\n    schema: [\n      {\n        key: 'assert',\n        label: '断言(JSON)',\n        type: 'json',\n        help: '如 {\"exists\":\"#id\"} / {\"visible\":\".btn\"}',\n      },\n      {\n        key: 'failStrategy',\n        label: '失败策略',\n        type: 'select',\n        options: [\n          { label: '停止', value: 'stop' },\n          { label: '警告', value: 'warn' },\n          { label: '重试', value: 'retry' },\n        ] as any,\n        default: 'stop',\n      },\n    ],\n    defaults: { assert: {} },\n  });\n\n  // HTTP\n  registerNodeSpec({\n    type: STEP_TYPES.HTTP,\n    version: 1,\n    display: { label: 'HTTP', iconClass: 'icon-http', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'method',\n        label: '方法',\n        type: 'select',\n        options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => ({\n          label: m,\n          value: m,\n        })) as any,\n        default: 'GET',\n      },\n      { key: 'url', label: 'URL', type: 'string', required: true },\n      { key: 'headers', label: '请求头(JSON)', type: 'json' },\n      { key: 'body', label: '请求体(JSON)', type: 'json' },\n      { key: 'formData', label: '表单(JSON)', type: 'json' },\n      { key: 'saveAs', label: '保存为变量', type: 'string' },\n      { key: 'assign', label: '映射(JSON)', type: 'json' },\n    ],\n    defaults: { method: 'GET' },\n  });\n\n  // Extract\n  registerNodeSpec({\n    type: STEP_TYPES.EXTRACT,\n    version: 1,\n    display: { label: '提取', iconClass: 'icon-extract', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'selector', label: '选择器', type: 'string', widget: 'selector' },\n      {\n        key: 'attr',\n        label: '属性',\n        type: 'select',\n        options: [\n          { label: '文本(text)', value: 'text' },\n          { label: '文本(textContent)', value: 'textContent' },\n          { label: '自定义属性名', value: 'attr' },\n        ] as any,\n      },\n      { key: 'js', label: '自定义JS', type: 'string', help: '在页面中执行并返回值' },\n      { key: 'saveAs', label: '保存变量', type: 'string', required: true },\n    ],\n    defaults: { saveAs: '' },\n  });\n\n  // Screenshot\n  registerNodeSpec({\n    type: STEP_TYPES.SCREENSHOT,\n    version: 1,\n    display: { label: '截图', iconClass: 'icon-screenshot', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'selector', label: '目标选择器', type: 'string' },\n      { key: 'fullPage', label: '整页截图', type: 'boolean', default: false },\n      { key: 'saveAs', label: '保存变量', type: 'string' },\n    ],\n    defaults: { fullPage: false },\n  });\n\n  // TriggerEvent\n  registerNodeSpec({\n    type: STEP_TYPES.TRIGGER_EVENT,\n    version: 1,\n    display: { label: '触发事件', iconClass: 'icon-trigger', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'target', label: '目标', type: 'json', widget: 'targetlocator' },\n      { key: 'event', label: '事件类型', type: 'string', required: true },\n      { key: 'bubbles', label: '冒泡', type: 'boolean', default: true },\n      { key: 'cancelable', label: '可取消', type: 'boolean', default: false },\n    ],\n    defaults: { event: '' },\n  });\n\n  // SetAttribute\n  registerNodeSpec({\n    type: STEP_TYPES.SET_ATTRIBUTE,\n    version: 1,\n    display: { label: '设置属性', iconClass: 'icon-attr', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'target', label: '目标', type: 'json', widget: 'targetlocator' },\n      { key: 'name', label: '属性名', type: 'string', required: true },\n      { key: 'value', label: '属性值', type: 'string' },\n      { key: 'remove', label: '移除属性', type: 'boolean', default: false },\n    ],\n    defaults: { remove: false },\n  });\n\n  // LoopElements\n  registerNodeSpec({\n    type: STEP_TYPES.LOOP_ELEMENTS,\n    version: 1,\n    display: { label: '循环元素', iconClass: 'icon-loop', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'selector', label: '选择器', type: 'string', required: true },\n      { key: 'saveAs', label: '列表变量名', type: 'string', default: 'elements' },\n      { key: 'itemVar', label: '项变量名', type: 'string', default: 'item' },\n      { key: 'subflowId', label: '子流程ID', type: 'string', required: true },\n    ],\n    defaults: { saveAs: 'elements', itemVar: 'item' },\n  });\n\n  // SwitchFrame\n  registerNodeSpec({\n    type: STEP_TYPES.SWITCH_FRAME,\n    version: 1,\n    display: { label: '切换Frame', iconClass: 'icon-frame', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'frame',\n        label: 'frame定位',\n        type: 'object',\n        fields: [\n          { key: 'index', label: '索引', type: 'number' },\n          { key: 'urlContains', label: 'URL包含', type: 'string' },\n        ],\n      },\n    ],\n    defaults: {},\n  });\n\n  // HandleDownload\n  registerNodeSpec({\n    type: STEP_TYPES.HANDLE_DOWNLOAD,\n    version: 1,\n    display: { label: '下载处理', iconClass: 'icon-download', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'filenameContains', label: '文件名包含', type: 'string' },\n      { key: 'waitForComplete', label: '等待完成', type: 'boolean', default: true },\n      { key: 'timeoutMs', label: '超时(ms)', type: 'number', default: 60000 },\n      { key: 'saveAs', label: '保存变量', type: 'string' },\n    ],\n    defaults: { waitForComplete: true, timeoutMs: 60000 },\n  });\n\n  // Script\n  registerNodeSpec({\n    type: STEP_TYPES.SCRIPT,\n    version: 1,\n    display: { label: '脚本', iconClass: 'icon-script', category: 'Tools' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'world',\n        label: '执行上下文',\n        type: 'select',\n        options: [\n          { label: 'ISOLATED', value: 'ISOLATED' },\n          { label: 'MAIN', value: 'MAIN' },\n        ] as any,\n        default: 'ISOLATED',\n      },\n      { key: 'code', label: '脚本代码', type: 'string', widget: 'code', required: true },\n      {\n        key: 'when',\n        label: '执行时机',\n        type: 'select',\n        options: [\n          { label: 'before', value: 'before' },\n          { label: 'after', value: 'after' },\n        ] as any,\n        default: 'after',\n      },\n      { key: 'assign', label: '映射(JSON)', type: 'json' },\n      { key: 'saveAs', label: '保存变量', type: 'string' },\n    ],\n    defaults: { world: 'ISOLATED', when: 'after' },\n  });\n\n  // Tabs\n  registerNodeSpec({\n    type: STEP_TYPES.OPEN_TAB,\n    version: 1,\n    display: { label: '打开标签', iconClass: 'icon-openTab', category: 'Tabs' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'url', label: 'URL', type: 'string' },\n      { key: 'newWindow', label: '新窗口', type: 'boolean', default: false },\n    ],\n    defaults: { newWindow: false },\n  });\n  registerNodeSpec({\n    type: 'executeFlow' as any,\n    version: 1,\n    display: { label: '执行子流程', iconClass: 'icon-exec', category: 'Flow' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'flowId', label: '流程ID', type: 'string', required: true },\n      { key: 'inline', label: '内联执行', type: 'boolean', default: false },\n      { key: 'args', label: '参数(JSON)', type: 'json' },\n    ],\n    defaults: { inline: false },\n  });\n  registerNodeSpec({\n    type: STEP_TYPES.SWITCH_TAB,\n    version: 1,\n    display: { label: '切换标签', iconClass: 'icon-switchTab', category: 'Tabs' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'tabId', label: 'TabId', type: 'number' },\n      { key: 'urlContains', label: 'URL包含', type: 'string' },\n      { key: 'titleContains', label: '标题包含', type: 'string' },\n    ],\n    defaults: {},\n  });\n  registerNodeSpec({\n    type: STEP_TYPES.CLOSE_TAB,\n    version: 1,\n    display: { label: '关闭标签', iconClass: 'icon-closeTab', category: 'Tabs' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'tabIds',\n        label: 'TabIds',\n        type: 'array',\n        item: { key: 'id', label: 'id', type: 'number' } as any,\n      },\n      { key: 'url', label: 'URL', type: 'string' },\n    ],\n    defaults: {},\n  });\n\n  // Logic\n  registerNodeSpec({\n    type: STEP_TYPES.IF,\n    version: 1,\n    display: { label: '条件', iconClass: 'icon-if', category: 'Logic' },\n    ports: { inputs: 1, outputs: 'any' },\n    schema: [\n      {\n        key: 'condition',\n        label: '条件表达式(JSON)',\n        type: 'json',\n        help: '如 {\"expression\":\"vars.a>0\"} 等',\n      },\n      {\n        key: 'branches',\n        label: '分支',\n        type: 'array',\n        item: {\n          key: 'b',\n          label: 'case',\n          type: 'object',\n          fields: [\n            { key: 'id', label: 'ID', type: 'string' },\n            { key: 'name', label: '名称', type: 'string' },\n            { key: 'expr', label: '表达式', type: 'string' },\n          ],\n        } as any,\n      },\n      { key: 'else', label: '启用 else', type: 'boolean', default: true },\n    ],\n    defaults: { else: true },\n  });\n  registerNodeSpec({\n    type: STEP_TYPES.FOREACH,\n    version: 1,\n    display: { label: '循环', iconClass: 'icon-foreach', category: 'Logic' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'listVar', label: '列表变量', type: 'string', required: true },\n      { key: 'itemVar', label: '项变量', type: 'string', default: 'item' },\n      { key: 'subflowId', label: '子流程ID', type: 'string', required: true },\n      {\n        key: 'concurrency',\n        label: '并发数',\n        type: 'number',\n        default: 1,\n        help: '并发执行子流程（浅拷贝变量，不自动合并）',\n      },\n    ],\n    defaults: { itemVar: 'item' },\n  });\n  registerNodeSpec({\n    type: STEP_TYPES.WHILE,\n    version: 1,\n    display: { label: '循环', iconClass: 'icon-while', category: 'Logic' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'condition', label: '条件(JSON)', type: 'json' },\n      { key: 'subflowId', label: '子流程ID', type: 'string', required: true },\n      { key: 'maxIterations', label: '最大次数', type: 'number', default: 100 },\n    ],\n    defaults: { maxIterations: 100 },\n  });\n\n  // Delay (UI-only helper)\n  registerNodeSpec({\n    type: STEP_TYPES.DELAY,\n    version: 1,\n    display: { label: '延迟', iconClass: 'icon-delay', category: 'Actions' },\n    ports: { inputs: 1, outputs: [{ label: 'default' }] },\n    schema: [\n      {\n        key: 'sleep',\n        label: '延迟',\n        type: 'number',\n        widget: 'duration',\n        required: true,\n        default: 1000,\n      },\n    ],\n    defaults: { sleep: 1000 },\n  });\n\n  // Trigger (builder-only, flow-level node)\n  registerNodeSpec({\n    type: STEP_TYPES.TRIGGER,\n    version: 1,\n    display: { label: '触发器', iconClass: 'icon-trigger', category: 'Flow' },\n    ports: { inputs: 0, outputs: [{ label: 'default' }] },\n    schema: [\n      { key: 'enabled', label: '启用', type: 'boolean', default: true },\n      { key: 'description', label: '描述', type: 'string' },\n      {\n        key: 'modes',\n        label: '模式',\n        type: 'object',\n        fields: [\n          { key: 'manual', label: '手动', type: 'boolean', default: true },\n          { key: 'url', label: 'URL 触发', type: 'boolean', default: false },\n          { key: 'contextMenu', label: '右键菜单', type: 'boolean', default: false },\n          { key: 'command', label: '快捷键', type: 'boolean', default: false },\n          { key: 'dom', label: 'DOM 事件', type: 'boolean', default: false },\n          { key: 'schedule', label: '定时', type: 'boolean', default: false },\n        ],\n      },\n      {\n        key: 'url',\n        label: 'URL 规则',\n        type: 'object',\n        fields: [\n          {\n            key: 'rules',\n            label: '规则列表',\n            type: 'array',\n            item: {\n              key: 'rule',\n              label: '规则',\n              type: 'object',\n              fields: [\n                {\n                  key: 'kind',\n                  label: '类型',\n                  type: 'select',\n                  options: [\n                    { label: 'URL', value: 'url' },\n                    { label: '域名', value: 'domain' },\n                    { label: '路径', value: 'path' },\n                  ] as any,\n                  default: 'url',\n                },\n                { key: 'value', label: '值', type: 'string' },\n              ],\n            } as any,\n          },\n        ],\n      },\n      {\n        key: 'contextMenu',\n        label: '右键菜单',\n        type: 'object',\n        fields: [\n          { key: 'title', label: '标题', type: 'string', default: '运行工作流' },\n          { key: 'enabled', label: '启用', type: 'boolean', default: false },\n        ],\n      },\n      {\n        key: 'command',\n        label: '快捷键',\n        type: 'object',\n        fields: [\n          { key: 'commandKey', label: '快捷键', type: 'string' },\n          { key: 'enabled', label: '启用', type: 'boolean', default: false },\n        ],\n      },\n      {\n        key: 'dom',\n        label: 'DOM 事件',\n        type: 'object',\n        fields: [\n          { key: 'selector', label: '选择器', type: 'string' },\n          { key: 'appear', label: '出现', type: 'boolean', default: true },\n          { key: 'once', label: '一次', type: 'boolean', default: true },\n          { key: 'debounceMs', label: '防抖(ms)', type: 'number', default: 800 },\n          { key: 'enabled', label: '启用', type: 'boolean', default: false },\n        ],\n      },\n      {\n        key: 'schedules',\n        label: '定时',\n        type: 'array',\n        item: {\n          key: 'sched',\n          label: '计划',\n          type: 'object',\n          fields: [\n            { key: 'id', label: 'ID', type: 'string' },\n            {\n              key: 'type',\n              label: '类型',\n              type: 'select',\n              options: [\n                { label: '一次', value: 'once' },\n                { label: '间隔', value: 'interval' },\n                { label: '每日', value: 'daily' },\n              ] as any,\n            },\n            { key: 'when', label: '时间(ISO/cron)', type: 'string' },\n            { key: 'enabled', label: '启用', type: 'boolean', default: true },\n          ],\n        } as any,\n      },\n    ],\n    defaults: { enabled: true },\n  });\n}\n"
  },
  {
    "path": "packages/shared/src/rr-graph.ts",
    "content": "// rr-graph.ts — shared DAG helpers for Record & Replay\n// Note: keep types lightweight to avoid cross-package coupling\n// Centralize step type strings and tiny helpers here to avoid magic literals.\n\nimport { EDGE_LABELS, type EdgeLabel } from './labels';\n\nexport interface RRNode {\n  id: string;\n  type: string;\n  config?: Record<string, unknown>;\n}\nexport interface RREdge {\n  id: string;\n  from: string;\n  to: string;\n  label?: EdgeLabel;\n}\n\n// Centralized step type strings (kept in shared to avoid duplication)\nexport const RR_STEP_TYPES = {\n  CLICK: 'click',\n  DBLCLICK: 'dblclick',\n  FILL: 'fill',\n  DRAG: 'drag',\n  KEY: 'key',\n  WAIT: 'wait',\n  ASSERT: 'assert',\n  IF: 'if',\n  FOREACH: 'foreach',\n  WHILE: 'while',\n  NAVIGATE: 'navigate',\n  SCRIPT: 'script',\n  HTTP: 'http',\n  EXTRACT: 'extract',\n  SCREENSHOT: 'screenshot',\n  SCROLL: 'scroll',\n  TRIGGER_EVENT: 'triggerEvent',\n  SET_ATTRIBUTE: 'setAttribute',\n  LOOP_ELEMENTS: 'loopElements',\n  SWITCH_FRAME: 'switchFrame',\n  OPEN_TAB: 'openTab',\n  SWITCH_TAB: 'switchTab',\n  CLOSE_TAB: 'closeTab',\n  EXECUTE_FLOW: 'executeFlow',\n  HANDLE_DOWNLOAD: 'handleDownload',\n  // UI-only, mapped to WAIT\n  DELAY: 'delay',\n} as const;\nexport type RRStepType = (typeof RR_STEP_TYPES)[keyof typeof RR_STEP_TYPES];\n\nfunction ensureTarget(t: any) {\n  return t && typeof t === 'object' ? t : { candidates: [] };\n}\n\n// Topological order using Kahn's algorithm; edges considered as-is (caller may pre-filter labels)\nexport function topoOrder<T extends RRNode>(nodes: T[], edges: RREdge[]): T[] {\n  const id2n = new Map(nodes.map((n) => [n.id, n] as const));\n  const indeg = new Map<string, number>(nodes.map((n) => [n.id, 0] as const));\n  for (const e of edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);\n  const nexts = new Map<string, string[]>(nodes.map((n) => [n.id, [] as string[]] as const));\n  for (const e of edges) nexts.get(e.from)!.push(e.to);\n  const q: string[] = nodes.filter((n) => (indeg.get(n.id) || 0) === 0).map((n) => n.id);\n  const out: T[] = [];\n  while (q.length) {\n    const id = q.shift()!;\n    const n = id2n.get(id);\n    if (!n) continue;\n    out.push(n as T);\n    for (const v of nexts.get(id)!) {\n      indeg.set(v, (indeg.get(v) || 0) - 1);\n      if ((indeg.get(v) || 0) === 0) q.push(v);\n    }\n  }\n  return out.length === nodes.length ? out : nodes.slice();\n}\n\n// Map a Node (Flow V2) to a linear Step (Flow V1)\nexport function mapNodeToStep(node: RRNode): any {\n  const c: any = node.config || {};\n  const base = { id: node.id } as any;\n  // Config-driven generic mapping (prefer this path)\n  try {\n    const type = String(node.type);\n    // UI-only helper: delay -> wait.sleep\n    if (type === 'delay') {\n      const sleep = Number((c as any).sleep ?? (c as any).ms ?? 1000);\n      return { ...base, type: 'wait', condition: { sleep: Math.max(0, sleep) } };\n    }\n    const step: any = { ...base, type, ...c };\n    if (step.target) step.target = ensureTarget(step.target);\n    if (step.start) step.start = ensureTarget(step.start);\n    if (step.end) step.end = ensureTarget(step.end);\n    return step;\n  } catch {}\n  switch (node.type) {\n    case RR_STEP_TYPES.CLICK:\n    case RR_STEP_TYPES.DBLCLICK:\n      return {\n        ...base,\n        type: node.type,\n        target: ensureTarget(c.target),\n        before: c.before,\n        after: c.after,\n      };\n    case RR_STEP_TYPES.FILL:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.FILL,\n        target: ensureTarget(c.target),\n        value: c.value || '',\n      };\n    case RR_STEP_TYPES.DRAG:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.DRAG,\n        start: ensureTarget(c.start),\n        end: ensureTarget(c.end),\n        path: Array.isArray(c.path) ? c.path : undefined,\n      };\n    case RR_STEP_TYPES.KEY:\n      return { ...base, type: RR_STEP_TYPES.KEY, keys: c.keys || '' };\n    case RR_STEP_TYPES.WAIT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.WAIT,\n        condition: c.condition || { text: '', appear: true },\n      };\n    case RR_STEP_TYPES.ASSERT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.ASSERT,\n        assert: c.assert || { exists: '' },\n        failStrategy: c.failStrategy,\n      };\n    case RR_STEP_TYPES.IF:\n      return { ...base, type: RR_STEP_TYPES.IF, condition: c.condition || {} };\n    case RR_STEP_TYPES.FOREACH:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.FOREACH,\n        listVar: c.listVar || '',\n        itemVar: c.itemVar || 'item',\n        subflowId: c.subflowId || '',\n      };\n    case RR_STEP_TYPES.WHILE:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.WHILE,\n        condition: c.condition || {},\n        subflowId: c.subflowId || '',\n        maxIterations: Math.max(0, Number(c.maxIterations ?? 100)),\n      };\n    case RR_STEP_TYPES.NAVIGATE:\n      return { ...base, type: RR_STEP_TYPES.NAVIGATE, url: c.url || '' };\n    case RR_STEP_TYPES.SCRIPT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SCRIPT,\n        world: c.world || 'ISOLATED',\n        code: c.code || '',\n        when: c.when,\n      };\n    case RR_STEP_TYPES.DELAY: // map to wait.sleep to avoid navigation confusion\n      return {\n        ...base,\n        type: RR_STEP_TYPES.WAIT,\n        condition: { sleep: Math.max(0, Number(c.ms ?? 1000)) },\n      };\n    case RR_STEP_TYPES.HTTP:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.HTTP,\n        method: c.method || 'GET',\n        url: c.url || '',\n        headers: c.headers || {},\n        body: c.body,\n        formData: c.formData,\n        saveAs: c.saveAs || '',\n      };\n    case RR_STEP_TYPES.EXTRACT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.EXTRACT,\n        selector: c.selector || '',\n        attr: c.attr || 'text',\n        js: c.js || '',\n        saveAs: c.saveAs || '',\n      };\n    case RR_STEP_TYPES.SCREENSHOT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SCREENSHOT,\n        selector: c.selector || '',\n        fullPage: !!c.fullPage,\n        saveAs: c.saveAs || '',\n      };\n    case RR_STEP_TYPES.SCROLL:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SCROLL,\n        mode: c.mode || 'offset',\n        target: ensureTarget(c.target),\n        offset: c.offset || { x: 0, y: 300 },\n      };\n    case RR_STEP_TYPES.TRIGGER_EVENT:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.TRIGGER_EVENT,\n        target: ensureTarget(c.target),\n        event: c.event || 'input',\n        bubbles: c.bubbles !== false,\n        cancelable: !!c.cancelable,\n      };\n    case RR_STEP_TYPES.SET_ATTRIBUTE:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SET_ATTRIBUTE,\n        target: ensureTarget(c.target),\n        name: c.name || '',\n        value: c.value,\n        remove: !!c.remove,\n      };\n    case RR_STEP_TYPES.LOOP_ELEMENTS:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.LOOP_ELEMENTS,\n        selector: c.selector || '',\n        saveAs: c.saveAs || 'elements',\n        itemVar: c.itemVar || 'item',\n        subflowId: c.subflowId || '',\n      };\n    case RR_STEP_TYPES.SWITCH_FRAME:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SWITCH_FRAME,\n        frame: {\n          index: c.frame && c.frame.index != null ? Number(c.frame.index) : undefined,\n          urlContains: c.frame?.urlContains || '',\n        },\n      };\n    case RR_STEP_TYPES.OPEN_TAB:\n      return { ...base, type: RR_STEP_TYPES.OPEN_TAB, url: c.url || '', newWindow: !!c.newWindow };\n    case RR_STEP_TYPES.SWITCH_TAB:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.SWITCH_TAB,\n        tabId: c.tabId || undefined,\n        urlContains: c.urlContains || '',\n        titleContains: c.titleContains || '',\n      };\n    case RR_STEP_TYPES.CLOSE_TAB:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.CLOSE_TAB,\n        tabIds: Array.isArray(c.tabIds) ? c.tabIds : undefined,\n        url: c.url || '',\n      };\n    case RR_STEP_TYPES.EXECUTE_FLOW:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.EXECUTE_FLOW,\n        flowId: c.flowId || '',\n        inline: c.inline !== false,\n        args: c.args || {},\n      };\n    case RR_STEP_TYPES.HANDLE_DOWNLOAD:\n      return {\n        ...base,\n        type: RR_STEP_TYPES.HANDLE_DOWNLOAD,\n        filenameContains: c.filenameContains || '',\n        waitForComplete: c.waitForComplete !== false,\n        timeoutMs: Math.max(0, Number(c.timeoutMs ?? 60000)),\n        saveAs: c.saveAs || '',\n      };\n    default:\n      return { ...base, type: RR_STEP_TYPES.SCRIPT, world: 'ISOLATED', code: '' };\n  }\n}\n\nexport function nodesToSteps(nodes: RRNode[], edges: RREdge[]): any[] {\n  const order = edges && edges.length ? topoOrder(nodes, edges) : nodes.slice();\n  return order.map((n) => mapNodeToStep(n));\n}\n\n// Reverse mapping (Step -> Node config)\nexport function mapStepToNodeConfig(step: unknown): Record<string, unknown> {\n  if (!step || typeof step !== 'object') return {};\n  const src = step as Record<string, unknown>;\n  const out: Record<string, unknown> = {};\n  for (const [k, v] of Object.entries(src)) {\n    if (k === 'id' || k === 'type') continue;\n    out[k] = v;\n  }\n  const target = out['target'];\n  if (target) out['target'] = ensureTarget(target);\n  const start = out['start'];\n  if (start) out['start'] = ensureTarget(start);\n  const end = out['end'];\n  if (end) out['end'] = ensureTarget(end);\n  return out;\n}\n\nexport function stepsToNodes(steps: ReadonlyArray<unknown>): RRNode[] {\n  const arr: RRNode[] = [];\n  steps.forEach((step, i) => {\n    const obj: Record<string, unknown> =\n      step && typeof step === 'object' ? (step as Record<string, unknown>) : {};\n    const idValue = obj['id'];\n    const typeValue = obj['type'];\n    const id = typeof idValue === 'string' && idValue ? idValue : `n_${i}`;\n    const type = typeof typeValue === 'string' && typeValue ? typeValue : RR_STEP_TYPES.SCRIPT;\n    arr.push({ id, type, config: mapStepToNodeConfig(step) });\n  });\n  return arr;\n}\n\n/**\n * Convert linear steps array to DAG format (nodes + edges).\n * Generates sequential edges connecting nodes in order.\n */\nexport function stepsToDAG(steps: ReadonlyArray<unknown>): { nodes: RRNode[]; edges: RREdge[] } {\n  const nodes = stepsToNodes(steps);\n  const edges: RREdge[] = [];\n  for (let i = 0; i < nodes.length - 1; i++) {\n    const from = nodes[i].id;\n    const to = nodes[i + 1].id;\n    // Include index in edge id to avoid collision when step ids repeat\n    edges.push({\n      id: `e_${i}_${from}_${to}`,\n      from,\n      to,\n      label: EDGE_LABELS.DEFAULT,\n    });\n  }\n  return { nodes, edges };\n}\n"
  },
  {
    "path": "packages/shared/src/step-types.ts",
    "content": "// step-types.ts — centralized step type constants for UI + runtime\n\nexport const STEP_TYPES = {\n  CLICK: 'click',\n  DBLCLICK: 'dblclick',\n  FILL: 'fill',\n  TRIGGER_EVENT: 'triggerEvent',\n  SET_ATTRIBUTE: 'setAttribute',\n  SCREENSHOT: 'screenshot',\n  SWITCH_FRAME: 'switchFrame',\n  LOOP_ELEMENTS: 'loopElements',\n  KEY: 'key',\n  SCROLL: 'scroll',\n  DRAG: 'drag',\n  WAIT: 'wait',\n  ASSERT: 'assert',\n  SCRIPT: 'script',\n  IF: 'if',\n  FOREACH: 'foreach',\n  WHILE: 'while',\n  NAVIGATE: 'navigate',\n  HTTP: 'http',\n  EXTRACT: 'extract',\n  OPEN_TAB: 'openTab',\n  SWITCH_TAB: 'switchTab',\n  CLOSE_TAB: 'closeTab',\n  HANDLE_DOWNLOAD: 'handleDownload',\n  EXECUTE_FLOW: 'executeFlow',\n  // UI-only helpers\n  TRIGGER: 'trigger',\n  DELAY: 'delay',\n} as const;\n\nexport type StepTypeConst = (typeof STEP_TYPES)[keyof typeof STEP_TYPES];\n"
  },
  {
    "path": "packages/shared/src/tools.ts",
    "content": "import { type Tool } from '@modelcontextprotocol/sdk/types.js';\n\nexport const TOOL_NAMES = {\n  BROWSER: {\n    GET_WINDOWS_AND_TABS: 'get_windows_and_tabs',\n    SEARCH_TABS_CONTENT: 'search_tabs_content',\n    NAVIGATE: 'chrome_navigate',\n    SCREENSHOT: 'chrome_screenshot',\n    CLOSE_TABS: 'chrome_close_tabs',\n    SWITCH_TAB: 'chrome_switch_tab',\n    WEB_FETCHER: 'chrome_get_web_content',\n    CLICK: 'chrome_click_element',\n    FILL: 'chrome_fill_or_select',\n    REQUEST_ELEMENT_SELECTION: 'chrome_request_element_selection',\n    GET_INTERACTIVE_ELEMENTS: 'chrome_get_interactive_elements',\n    NETWORK_CAPTURE: 'chrome_network_capture',\n    // Legacy tool names (kept for internal use, not exposed in TOOL_SCHEMAS)\n    NETWORK_CAPTURE_START: 'chrome_network_capture_start',\n    NETWORK_CAPTURE_STOP: 'chrome_network_capture_stop',\n    NETWORK_REQUEST: 'chrome_network_request',\n    NETWORK_DEBUGGER_START: 'chrome_network_debugger_start',\n    NETWORK_DEBUGGER_STOP: 'chrome_network_debugger_stop',\n    KEYBOARD: 'chrome_keyboard',\n    HISTORY: 'chrome_history',\n    BOOKMARK_SEARCH: 'chrome_bookmark_search',\n    BOOKMARK_ADD: 'chrome_bookmark_add',\n    BOOKMARK_DELETE: 'chrome_bookmark_delete',\n    INJECT_SCRIPT: 'chrome_inject_script',\n    SEND_COMMAND_TO_INJECT_SCRIPT: 'chrome_send_command_to_inject_script',\n    JAVASCRIPT: 'chrome_javascript',\n    CONSOLE: 'chrome_console',\n    FILE_UPLOAD: 'chrome_upload_file',\n    READ_PAGE: 'chrome_read_page',\n    COMPUTER: 'chrome_computer',\n    HANDLE_DIALOG: 'chrome_handle_dialog',\n    HANDLE_DOWNLOAD: 'chrome_handle_download',\n    USERSCRIPT: 'chrome_userscript',\n    PERFORMANCE_START_TRACE: 'performance_start_trace',\n    PERFORMANCE_STOP_TRACE: 'performance_stop_trace',\n    PERFORMANCE_ANALYZE_INSIGHT: 'performance_analyze_insight',\n    GIF_RECORDER: 'chrome_gif_recorder',\n  },\n  RECORD_REPLAY: {\n    FLOW_RUN: 'record_replay_flow_run',\n    LIST_PUBLISHED: 'record_replay_list_published',\n  },\n};\n\nexport const TOOL_SCHEMAS: Tool[] = [\n  {\n    name: TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS,\n    description: 'Get all currently open browser windows and tabs',\n    inputSchema: {\n      type: 'object',\n      properties: {},\n      required: [],\n    },\n  },\n  // {\n  //   name: TOOL_NAMES.RECORD_REPLAY.FLOW_RUN,\n  //   description:\n  //     'Run a recorded flow by ID with optional variables and run options. Returns a standardized run result.',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {\n  //       flowId: { type: 'string', description: 'ID of the flow to run' },\n  //       args: {\n  //         type: 'object',\n  //         description: 'Variable values for the flow (flat object of key/value)',\n  //       },\n  //       tabTarget: {\n  //         type: 'string',\n  //         description: \"Target tab: 'current' or 'new' (default: current)\",\n  //         enum: ['current', 'new'],\n  //       },\n  //       refresh: { type: 'boolean', description: 'Refresh before running (default false)' },\n  //       captureNetwork: {\n  //         type: 'boolean',\n  //         description: 'Capture network snippets for debugging (default false)',\n  //       },\n  //       returnLogs: { type: 'boolean', description: 'Return run logs (default false)' },\n  //       timeoutMs: { type: 'number', description: 'Global timeout in ms (optional)' },\n  //       startUrl: { type: 'string', description: 'Optional start URL to open before running' },\n  //     },\n  //     required: ['flowId'],\n  //   },\n  // },\n  // {\n  //   name: TOOL_NAMES.RECORD_REPLAY.LIST_PUBLISHED,\n  //   description: 'List published flows available as dynamic tools (for discovery).',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {},\n  //     required: [],\n  //   },\n  // },\n  {\n    name: TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE,\n    description:\n      'Starts a performance trace recording on the selected page. Optionally reloads the page and/or auto-stops after a short duration.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        reload: {\n          type: 'boolean',\n          description:\n            'Determines if, once tracing has started, the page should be automatically reloaded (ignore cache).',\n        },\n        autoStop: {\n          type: 'boolean',\n          description: 'Determines if the trace should be automatically stopped (default false).',\n        },\n        durationMs: {\n          type: 'number',\n          description: 'Auto-stop duration in milliseconds when autoStop is true (default 5000).',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE,\n    description: 'Stops the active performance trace recording on the selected page.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        saveToDownloads: {\n          type: 'boolean',\n          description: 'Whether to save the trace as a JSON file in Downloads (default true).',\n        },\n        filenamePrefix: {\n          type: 'string',\n          description: 'Optional filename prefix for the downloaded trace JSON.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT,\n    description:\n      'Provides a lightweight summary of the last recorded trace. For deep insights (CWV, breakdowns), integrate native-side DevTools trace engine.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        insightName: {\n          type: 'string',\n          description:\n            'Optional insight name for future deep analysis (e.g., \"DocumentLatency\"). Currently informational only.',\n        },\n        timeoutMs: {\n          type: 'number',\n          description:\n            'Timeout for deep analysis via native host (milliseconds). Default 60000. Increase for large traces.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.READ_PAGE,\n    description:\n      'Get an accessibility tree representation of visible elements on the page. Only returns elements that are visible in the viewport. Optionally filter for only interactive elements.\\nTip: If the returned elements do not include the specific element you need, use the computer tool\\'s screenshot (action=\"screenshot\") to capture the element\\'s on-screen coordinates, then operate by coordinates.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        filter: {\n          type: 'string',\n          description:\n            'Filter elements: \"interactive\" for such as  buttons/links/inputs only (default: all visible elements)',\n        },\n        depth: {\n          type: 'number',\n          description:\n            'Maximum DOM depth to traverse (integer >= 0). Lower values reduce output size and can improve performance.',\n        },\n        refId: {\n          type: 'string',\n          description:\n            'Focus on the subtree rooted at this element refId (e.g., \"ref_12\"). The refId must come from a recent chrome_read_page response in the same tab (refs may expire).',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target an existing tab by ID (default: active tab).',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Target window ID to pick active tab when tabId is omitted.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.COMPUTER,\n    description:\n      \"Use a mouse and keyboard to interact with a web browser, and take screenshots.\\n* Whenever you intend to click on an element like an icon, you should consult a read_page to determine the ref of the element before moving the cursor.\\n* If you tried clicking on a program or link but it failed to load, even after waiting, try screenshot and then adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.\",\n    inputSchema: {\n      type: 'object',\n      properties: {\n        tabId: { type: 'number', description: 'Target tab ID (default: active tab)' },\n        background: {\n          type: 'boolean',\n          description:\n            'Avoid focusing/activating tab/window for certain operations (best-effort). Default: false',\n        },\n        action: {\n          type: 'string',\n          description:\n            'Action to perform: left_click | right_click | double_click | triple_click | left_click_drag | scroll | scroll_to | type | key | fill | fill_form | hover | wait | resize_page | zoom | screenshot',\n        },\n        ref: {\n          type: 'string',\n          description:\n            'Element ref from chrome_read_page. For click/scroll/scroll_to/key/type and drag end when provided; takes precedence over coordinates.',\n        },\n        coordinates: {\n          type: 'object',\n          properties: {\n            x: { type: 'number', description: 'X coordinate' },\n            y: { type: 'number', description: 'Y coordinate' },\n          },\n          description:\n            'Coordinates for actions (in screenshot space if a recent screenshot was taken, otherwise viewport). Required for click/scroll and as end point for drag.',\n        },\n        startCoordinates: {\n          type: 'object',\n          properties: {\n            x: { type: 'number' },\n            y: { type: 'number' },\n          },\n          description: 'Starting coordinates for drag action',\n        },\n        startRef: {\n          type: 'string',\n          description: 'Drag start ref from chrome_read_page (alternative to startCoordinates).',\n        },\n        scrollDirection: {\n          type: 'string',\n          description: 'Scroll direction: up | down | left | right',\n        },\n        scrollAmount: {\n          type: 'number',\n          description: 'Scroll ticks (1-10), default 3',\n        },\n        text: {\n          type: 'string',\n          description:\n            'Text to type (for action=type) or keys/chords separated by space (for action=key, e.g. \"Backspace Enter\" or \"cmd+a\")',\n        },\n        repeat: {\n          type: 'number',\n          description:\n            'For action=key: number of times to repeat the key sequence (integer 1-100, default 1).',\n        },\n        modifiers: {\n          type: 'object',\n          description:\n            'Modifier keys for click actions (left_click/right_click/double_click/triple_click).',\n          properties: {\n            altKey: { type: 'boolean' },\n            ctrlKey: { type: 'boolean' },\n            metaKey: { type: 'boolean' },\n            shiftKey: { type: 'boolean' },\n          },\n        },\n        region: {\n          type: 'object',\n          description:\n            'For action=zoom: rectangular region to capture (x0,y0)-(x1,y1) in viewport pixels (or screenshot-space if a recent screenshot context exists).',\n          properties: {\n            x0: { type: 'number' },\n            y0: { type: 'number' },\n            x1: { type: 'number' },\n            y1: { type: 'number' },\n          },\n          required: ['x0', 'y0', 'x1', 'y1'],\n        },\n        // For action=fill\n        selector: {\n          type: 'string',\n          description: 'CSS selector for fill (alternative to ref).',\n        },\n        value: {\n          oneOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }],\n          description: 'Value to set for action=fill (string | boolean | number)',\n        },\n        elements: {\n          type: 'array',\n          description: 'For action=fill_form: list of elements to fill (ref + value)',\n          items: {\n            type: 'object',\n            properties: {\n              ref: { type: 'string', description: 'Element ref from chrome_read_page' },\n              value: { type: 'string', description: 'Value to set (stringified if non-string)' },\n            },\n            required: ['ref', 'value'],\n          },\n        },\n        width: { type: 'number', description: 'For action=resize_page: viewport width' },\n        height: { type: 'number', description: 'For action=resize_page: viewport height' },\n        appear: {\n          type: 'boolean',\n          description:\n            'For action=wait with text: whether to wait for the text to appear (true, default) or disappear (false)',\n        },\n        timeout: {\n          type: 'number',\n          description:\n            'For action=wait with text: timeout in milliseconds (default 10000, max 120000)',\n        },\n        duration: {\n          type: 'number',\n          description: 'Seconds to wait for action=wait (max 30s)',\n        },\n      },\n      required: ['action'],\n    },\n  },\n  // {\n  //   name: TOOL_NAMES.BROWSER.USERSCRIPT,\n  //   description:\n  //     'Unified userscript tool (create/list/get/enable/disable/update/remove/send_command/export). Paste JS/CSS/Tampermonkey script and the system will auto-select the best strategy (insertCSS / persistent script in ISOLATED or MAIN world / once by CDP) with CSP-aware fallbacks.',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {\n  //       action: {\n  //         type: 'string',\n  //         description:\n  //           'Operation to perform',\n  //         enum: [\n  //           'create',\n  //           'list',\n  //           'get',\n  //           'enable',\n  //           'disable',\n  //           'update',\n  //           'remove',\n  //           'send_command',\n  //           'export',\n  //         ],\n  //       },\n  //       args: {\n  //         type: 'object',\n  //         description:\n  //           'Arguments for the specified action.\\n- create: { script (required), name?, description?, matches?: string[], excludes?: string[], persist?: boolean (default true), runAt?: \"document_start\"|\"document_end\"|\"document_idle\"|\"auto\", world?: \"auto\"|\"ISOLATED\"|\"MAIN\", allFrames?: boolean (default true), mode?: \"auto\"|\"css\"|\"persistent\"|\"once\", dnrFallback?: boolean (default true), tags?: string[] }\\n- list: { query?: string, status?: \"enabled\"|\"disabled\", domain?: string }\\n- get: { id (required) }\\n- enable/disable: { id (required) }\\n- update: { id (required), script?, name?, description?, matches?, excludes?, runAt?, world?, allFrames?, persist?, dnrFallback?, tags? }\\n- remove: { id (required) }\\n- send_command: { id (required), payload?: string, tabId?: number }\\n- export: {}\\nTip: For a one-off execution that returns a value, use create with args.mode=\"once\". The returned value is included as onceResult in the tool response.',\n  //         properties: {\n  //           // Common identifiers\n  //           id: { type: 'string', description: 'Userscript id (for get/enable/disable/update/remove/send_command)' },\n  //           // Create / Update fields\n  //           script: { type: 'string', description: 'JS/CSS/Tampermonkey script source (required for create)' },\n  //           name: { type: 'string', description: 'Userscript name (optional)' },\n  //           description: { type: 'string', description: 'Userscript description (optional)' },\n  //           matches: {\n  //             type: 'array',\n  //             items: { type: 'string' },\n  //             description: 'Match patterns for pages to apply to (e.g., https://*.example.com/*)'\n  //           },\n  //           excludes: {\n  //             type: 'array',\n  //             items: { type: 'string' },\n  //             description: 'Exclude patterns'\n  //           },\n  //           persist: { type: 'boolean', description: 'Persist userscript for matched pages (default true)' },\n  //           runAt: {\n  //             type: 'string',\n  //             description: 'Injection timing',\n  //             enum: ['document_start', 'document_end', 'document_idle', 'auto'],\n  //           },\n  //           world: {\n  //             type: 'string',\n  //             description: 'Execution world',\n  //             enum: ['auto', 'ISOLATED', 'MAIN'],\n  //           },\n  //           allFrames: { type: 'boolean', description: 'Inject into all frames (default true)' },\n  //           mode: {\n  //             type: 'string',\n  //             description:\n  //               'Injection strategy: auto | css | persistent | once. Use once to evaluate immediately (no persistence) and include the return value in onceResult.',\n  //             enum: ['auto', 'css', 'persistent', 'once'],\n  //           },\n  //           dnrFallback: { type: 'boolean', description: 'Use DNR fallback when needed (default true)' },\n  //           tags: { type: 'array', items: { type: 'string' }, description: 'Custom tags' },\n  //           // List filters\n  //           query: { type: 'string', description: 'Search by name/description (list action)' },\n  //           status: { type: 'string', enum: ['enabled', 'disabled'], description: 'Filter by status (list action)' },\n  //           domain: { type: 'string', description: 'Filter by domain (list action)' },\n  //           // Send command\n  //           payload: { type: 'string', description: 'Arbitrary payload (stringified) for send_command' },\n  //           tabId: { type: 'number', description: 'Target tab for send_command (default active tab)' },\n  //         },\n  //       },\n  //     },\n  //     required: ['action'],\n  //   },\n  // },\n  {\n    name: TOOL_NAMES.BROWSER.NAVIGATE,\n    description:\n      'Navigate to a URL, refresh the current tab, or navigate browser history (back/forward)',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        url: {\n          type: 'string',\n          description:\n            'URL to navigate to. Special values: \"back\" or \"forward\" to navigate browser history in the target tab.',\n        },\n        newWindow: {\n          type: 'boolean',\n          description: 'Create a new window to navigate to the URL or not. Defaults to false',\n        },\n        tabId: {\n          type: 'number',\n          description:\n            'Target an existing tab by ID (if provided, navigate/refresh/back/forward that tab instead of the active tab).',\n        },\n        windowId: {\n          type: 'number',\n          description:\n            'Target an existing window by ID (when creating a new tab in existing window, or picking active tab if tabId is not provided).',\n        },\n        background: {\n          type: 'boolean',\n          description:\n            'Perform the operation without stealing focus (do not activate the tab or focus the window). Default: false',\n        },\n        width: {\n          type: 'number',\n          description:\n            'Window width in pixels (default: 1280). When width or height is provided, a new window will be created.',\n        },\n        height: {\n          type: 'number',\n          description:\n            'Window height in pixels (default: 720). When width or height is provided, a new window will be created.',\n        },\n        refresh: {\n          type: 'boolean',\n          description:\n            'Refresh the current active tab instead of navigating to a URL. When true, the url parameter is ignored. Defaults to false',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.SCREENSHOT,\n    description:\n      '[Prefer read_page over taking a screenshot and Prefer chrome_computer] Take a screenshot of the current page or a specific element. For new usage, use chrome_computer with action=\"screenshot\". Use this tool if you need advanced options.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        name: { type: 'string', description: 'Name for the screenshot, if saving as PNG' },\n        selector: { type: 'string', description: 'CSS selector for element to screenshot' },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID to capture from (default: active tab).',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Target window ID to pick active tab from when tabId is not provided.',\n        },\n        background: {\n          type: 'boolean',\n          description:\n            'Attempt capture without bringing tab/window to foreground. CDP-based capture is used for simple viewport captures. For element/full-page capture, the tab may still be made active in its window without focusing the window. Default: false',\n        },\n        width: { type: 'number', description: 'Width in pixels (default: 800)' },\n        height: { type: 'number', description: 'Height in pixels (default: 600)' },\n        storeBase64: {\n          type: 'boolean',\n          description:\n            'return screenshot in base64 format (default: false) if you want to see the page, recommend set this to be true',\n        },\n        fullPage: {\n          type: 'boolean',\n          description: 'Store screenshot of the entire page (default: true)',\n        },\n        savePng: {\n          type: 'boolean',\n          description:\n            'Save screenshot as PNG file (default: true)，if you want to see the page, recommend set this to be false, and set storeBase64 to be true',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.CLOSE_TABS,\n    description: 'Close one or more browser tabs',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        tabIds: {\n          type: 'array',\n          items: { type: 'number' },\n          description: 'Array of tab IDs to close. If not provided, will close the active tab.',\n        },\n        url: {\n          type: 'string',\n          description: 'Close tabs matching this URL. Can be used instead of tabIds.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.SWITCH_TAB,\n    description: 'Switch to a specific browser tab',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        tabId: {\n          type: 'number',\n          description: 'The ID of the tab to switch to.',\n        },\n        windowId: {\n          type: 'number',\n          description: 'The ID of the window where the tab is located.',\n        },\n      },\n      required: ['tabId'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.WEB_FETCHER,\n    description: 'Fetch content from a web page',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        url: {\n          type: 'string',\n          description: 'URL to fetch content from. If not provided, uses the current active tab',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target an existing tab by ID (default: active tab).',\n        },\n        background: {\n          type: 'boolean',\n          description: 'Do not activate tab/focus window while fetching (default: false)',\n        },\n        htmlContent: {\n          type: 'boolean',\n          description:\n            'Get the visible HTML content of the page. If true, textContent will be ignored (default: false)',\n        },\n        textContent: {\n          type: 'boolean',\n          description:\n            'Get the visible text content of the page with metadata. Ignored if htmlContent is true (default: true)',\n        },\n\n        selector: {\n          type: 'string',\n          description:\n            'CSS selector to get content from a specific element. If provided, only content from this element will be returned',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.NETWORK_REQUEST,\n    description: 'Send a network request from the browser with cookies and other browser context',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        url: {\n          type: 'string',\n          description: 'URL to send the request to',\n        },\n        method: {\n          type: 'string',\n          description: 'HTTP method to use (default: GET)',\n        },\n        headers: {\n          type: 'object',\n          description: 'Headers to include in the request',\n        },\n        body: {\n          type: 'string',\n          description: 'Body of the request (for POST, PUT, etc.)',\n        },\n        timeout: {\n          type: 'number',\n          description: 'Timeout in milliseconds (default: 30000)',\n        },\n        formData: {\n          type: 'object',\n          description:\n            'Multipart/form-data descriptor. If provided, overrides body and builds FormData with optional file attachments. Shape: { fields?: Record<string,string|number|boolean>, files?: Array<{ name: string, fileUrl?: string, filePath?: string, base64Data?: string, filename?: string, contentType?: string }> }. Also supports a compact array form: [ [name, fileSpec, filename?], ... ] where fileSpec may be url:, file:, or base64:.',\n        },\n      },\n      required: ['url'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.NETWORK_CAPTURE,\n    description:\n      'Unified network capture tool. Use action=\"start\" to begin capturing, action=\"stop\" to end and retrieve results. Set needResponseBody=true to capture response bodies (uses Debugger API, may conflict with DevTools). Default mode uses webRequest API (lightweight, no debugger conflict, but no response body).',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        action: {\n          type: 'string',\n          enum: ['start', 'stop'],\n          description: 'Action to perform: \"start\" begins capture, \"stop\" ends and returns results',\n        },\n        needResponseBody: {\n          type: 'boolean',\n          description:\n            'When true, captures response body using Debugger API (default: false). Only use when you need to inspect response content.',\n        },\n        url: {\n          type: 'string',\n          description:\n            'URL to capture network requests from. For action=\"start\". If not provided, uses the current active tab.',\n        },\n        maxCaptureTime: {\n          type: 'number',\n          description: 'Maximum capture time in milliseconds (default: 180000)',\n        },\n        inactivityTimeout: {\n          type: 'number',\n          description: 'Stop after inactivity in milliseconds (default: 60000). Set 0 to disable.',\n        },\n        includeStatic: {\n          type: 'boolean',\n          description: 'Include static resources like images/scripts/styles (default: false)',\n        },\n      },\n      required: ['action'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.HANDLE_DOWNLOAD,\n    description: 'Wait for a browser download and return details (id, filename, url, state, size)',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        filenameContains: { type: 'string', description: 'Filter by substring in filename or URL' },\n        timeoutMs: { type: 'number', description: 'Timeout in ms (default 60000, max 300000)' },\n        waitForComplete: { type: 'boolean', description: 'Wait until completed (default true)' },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.HISTORY,\n    description: 'Retrieve and search browsing history from Chrome',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        text: {\n          type: 'string',\n          description:\n            'Text to search for in history URLs and titles. Leave empty to retrieve all history entries within the time range.',\n        },\n        startTime: {\n          type: 'string',\n          description:\n            'Start time as a date string. Supports ISO format (e.g., \"2023-10-01\", \"2023-10-01T14:30:00\"), relative times (e.g., \"1 day ago\", \"2 weeks ago\", \"3 months ago\", \"1 year ago\"), and special keywords (\"now\", \"today\", \"yesterday\"). Default: 24 hours ago',\n        },\n        endTime: {\n          type: 'string',\n          description:\n            'End time as a date string. Supports ISO format (e.g., \"2023-10-31\", \"2023-10-31T14:30:00\"), relative times (e.g., \"1 day ago\", \"2 weeks ago\", \"3 months ago\", \"1 year ago\"), and special keywords (\"now\", \"today\", \"yesterday\"). Default: current time',\n        },\n        maxResults: {\n          type: 'number',\n          description:\n            'Maximum number of history entries to return. Use this to limit results for performance or to focus on the most relevant entries. (default: 100)',\n        },\n        excludeCurrentTabs: {\n          type: 'boolean',\n          description:\n            \"When set to true, filters out URLs that are currently open in any browser tab. Useful for finding pages you've visited but don't have open anymore. (default: false)\",\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.BOOKMARK_SEARCH,\n    description: 'Search Chrome bookmarks by title and URL',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        query: {\n          type: 'string',\n          description:\n            'Search query to match against bookmark titles and URLs. Leave empty to retrieve all bookmarks.',\n        },\n        maxResults: {\n          type: 'number',\n          description: 'Maximum number of bookmarks to return (default: 50)',\n        },\n        folderPath: {\n          type: 'string',\n          description:\n            'Optional folder path or ID to limit search to a specific bookmark folder. Can be a path string (e.g., \"Work/Projects\") or a folder ID.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.BOOKMARK_ADD,\n    description: 'Add a new bookmark to Chrome',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        url: {\n          type: 'string',\n          description: 'URL to bookmark. If not provided, uses the current active tab URL.',\n        },\n        title: {\n          type: 'string',\n          description: 'Title for the bookmark. If not provided, uses the page title from the URL.',\n        },\n        parentId: {\n          type: 'string',\n          description:\n            'Parent folder path or ID to add the bookmark to. Can be a path string (e.g., \"Work/Projects\") or a folder ID. If not provided, adds to the \"Bookmarks Bar\" folder.',\n        },\n        createFolder: {\n          type: 'boolean',\n          description: 'Whether to create the parent folder if it does not exist (default: false)',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.BOOKMARK_DELETE,\n    description: 'Delete a bookmark from Chrome',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        bookmarkId: {\n          type: 'string',\n          description: 'ID of the bookmark to delete. Either bookmarkId or url must be provided.',\n        },\n        url: {\n          type: 'string',\n          description: 'URL of the bookmark to delete. Used if bookmarkId is not provided.',\n        },\n        title: {\n          type: 'string',\n          description: 'Title of the bookmark to help with matching when deleting by URL.',\n        },\n      },\n      required: [],\n    },\n  },\n  // {\n  //   name: TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT,\n  //   description:\n  //     'search for related content from the currently open tab and return the corresponding web pages.',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {\n  //       query: {\n  //         type: 'string',\n  //         description: 'the query to search for related content.',\n  //       },\n  //     },\n  //     required: ['query'],\n  //   },\n  // },\n  // {\n  //   name: TOOL_NAMES.BROWSER.INJECT_SCRIPT,\n  //   description:\n  //     'inject the user-specified content script into the webpage. By default, inject into the currently active tab',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {\n  //       url: {\n  //         type: 'string',\n  //         description:\n  //           'If a URL is specified, inject the script into the webpage corresponding to the URL.',\n  //       },\n  //       tabId: {\n  //         type: 'number',\n  //         description:\n  //           'Target an existing tab by ID to inject into. Overrides url/active tab selection when provided.',\n  //       },\n  //       windowId: {\n  //         type: 'number',\n  //         description:\n  //           'Target window ID for selecting active tab or creating new tab when url is provided and tabId is omitted.',\n  //       },\n  //       background: {\n  //         type: 'boolean',\n  //         description:\n  //           'Do not activate tab/focus window during injection when true (default: false).',\n  //       },\n  //       type: {\n  //         type: 'string',\n  //         description:\n  //           'the javaScript world for a script to execute within. must be ISOLATED or MAIN',\n  //       },\n  //       jsScript: {\n  //         type: 'string',\n  //         description: 'the content script to inject',\n  //       },\n  //     },\n  //     required: ['type', 'jsScript'],\n  //   },\n  // },\n  // {\n  //   name: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,\n  //   description:\n  //     'if the script injected using chrome_inject_script listens for user-defined events, this tool can be used to trigger those events',\n  //   inputSchema: {\n  //     type: 'object',\n  //     properties: {\n  //       tabId: {\n  //         type: 'number',\n  //         description:\n  //           'the tab where you previously injected the script(if not provided,  use the currently active tab)',\n  //       },\n  //       eventName: {\n  //         type: 'string',\n  //         description: 'the eventName your injected content script listen for',\n  //       },\n  //       payload: {\n  //         type: 'string',\n  //         description: 'the payload passed to event, must be a json string',\n  //       },\n  //     },\n  //     required: ['eventName'],\n  //   },\n  // },\n  {\n    name: TOOL_NAMES.BROWSER.JAVASCRIPT,\n    description:\n      'Execute JavaScript code in a browser tab and return the result. Uses CDP Runtime.evaluate with awaitPromise and returnByValue; automatically falls back to chrome.scripting.executeScript if the debugger is busy. Output is sanitized (sensitive data redacted) and truncated by default.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        code: {\n          type: 'string',\n          description:\n            'JavaScript code to execute. Runs inside an async function body, so top-level await and \"return ...\" are supported.',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID. If omitted, uses the current active tab.',\n        },\n        timeoutMs: {\n          type: 'number',\n          description: 'Execution timeout in milliseconds (default: 15000).',\n        },\n        maxOutputBytes: {\n          type: 'number',\n          description:\n            'Maximum output size in bytes after sanitization (default: 51200). Output exceeding this limit will be truncated.',\n        },\n      },\n      required: ['code'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.CLICK,\n    description:\n      'Click on an element in a web page. Supports multiple targeting methods: CSS selector, XPath, element ref (from chrome_read_page), or viewport coordinates. More focused than chrome_computer for simple click operations.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        selector: {\n          type: 'string',\n          description: 'CSS selector or XPath for the element to click.',\n        },\n        selectorType: {\n          type: 'string',\n          enum: ['css', 'xpath'],\n          description: 'Type of selector (default: \"css\").',\n        },\n        ref: {\n          type: 'string',\n          description: 'Element ref from chrome_read_page (takes precedence over selector).',\n        },\n        coordinates: {\n          type: 'object',\n          description: 'Viewport coordinates to click at.',\n          properties: {\n            x: { type: 'number' },\n            y: { type: 'number' },\n          },\n          required: ['x', 'y'],\n        },\n        double: {\n          type: 'boolean',\n          description: 'Perform double click when true (default: false).',\n        },\n        button: {\n          type: 'string',\n          enum: ['left', 'right', 'middle'],\n          description: 'Mouse button to click (default: \"left\").',\n        },\n        modifiers: {\n          type: 'object',\n          description: 'Modifier keys to hold during click.',\n          properties: {\n            altKey: { type: 'boolean' },\n            ctrlKey: { type: 'boolean' },\n            metaKey: { type: 'boolean' },\n            shiftKey: { type: 'boolean' },\n          },\n        },\n        waitForNavigation: {\n          type: 'boolean',\n          description: 'Wait for navigation to complete after click (default: false).',\n        },\n        timeout: {\n          type: 'number',\n          description: 'Timeout in milliseconds for waiting (default: 5000).',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID. If omitted, uses the current active tab.',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Window ID to select active tab from (when tabId is omitted).',\n        },\n        frameId: {\n          type: 'number',\n          description: 'Target frame ID for iframe support.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.FILL,\n    description:\n      'Fill or select a form element on a web page. Supports input, textarea, select, checkbox, and radio elements. Use CSS selector, XPath, or element ref to target the element.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        selector: {\n          type: 'string',\n          description: 'CSS selector or XPath for the form element.',\n        },\n        selectorType: {\n          type: 'string',\n          enum: ['css', 'xpath'],\n          description: 'Type of selector (default: \"css\").',\n        },\n        ref: {\n          type: 'string',\n          description: 'Element ref from chrome_read_page (takes precedence over selector).',\n        },\n        value: {\n          type: ['string', 'number', 'boolean'],\n          description:\n            'Value to fill. For text inputs: string. For checkboxes/radios: boolean. For selects: option value or text.',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID. If omitted, uses the current active tab.',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Window ID to select active tab from (when tabId is omitted).',\n        },\n        frameId: {\n          type: 'number',\n          description: 'Target frame ID for iframe support.',\n        },\n      },\n      required: ['value'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.REQUEST_ELEMENT_SELECTION,\n    description:\n      'Request the user to manually select one or more elements on the current page. Use this as a human-in-the-loop fallback when you cannot reliably locate the target element after approximately 3 attempts using chrome_read_page combined with chrome_click_element/chrome_fill_or_select/chrome_computer. The user will see a panel with instructions and can click on the requested elements. Returns element refs compatible with chrome_click_element/chrome_fill_or_select (including iframe frameId for cross-frame support).',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        requests: {\n          type: 'array',\n          description:\n            'A list of element selection requests. Each request produces exactly one picked element. The user will see these requests in a panel and select each element by clicking on the page.',\n          minItems: 1,\n          items: {\n            type: 'object',\n            properties: {\n              id: {\n                type: 'string',\n                description:\n                  'Optional stable request id for correlation. If omitted, an id is auto-generated (e.g., \"req_1\").',\n              },\n              name: {\n                type: 'string',\n                description:\n                  'Short label shown to the user describing what element to select (e.g., \"Login button\", \"Email input field\").',\n              },\n              description: {\n                type: 'string',\n                description:\n                  'Optional longer instruction shown to the user with more context (e.g., \"Click on the primary login button in the top-right corner\").',\n              },\n            },\n            required: ['name'],\n          },\n        },\n        timeoutMs: {\n          type: 'number',\n          description:\n            'Timeout in milliseconds for the user to complete all selections. Default: 180000 (3 minutes). Maximum: 600000 (10 minutes).',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID. If omitted, uses the current active tab.',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Window ID to select active tab from (when tabId is omitted).',\n        },\n      },\n      required: ['requests'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.KEYBOARD,\n    description:\n      'Simulate keyboard input on a web page. Supports single keys (Enter, Tab, Escape), key combinations (Ctrl+C, Ctrl+V), and text input. Can target a specific element or send to the focused element.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        keys: {\n          type: 'string',\n          description:\n            'Keys or key combinations to simulate. Examples: \"Enter\", \"Tab\", \"Ctrl+C\", \"Shift+Tab\", \"Hello World\".',\n        },\n        selector: {\n          type: 'string',\n          description: 'CSS selector or XPath for target element to receive keyboard events.',\n        },\n        selectorType: {\n          type: 'string',\n          enum: ['css', 'xpath'],\n          description: 'Type of selector (default: \"css\").',\n        },\n        delay: {\n          type: 'number',\n          description: 'Delay between keystrokes in milliseconds (default: 50).',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target tab ID. If omitted, uses the current active tab.',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Window ID to select active tab from (when tabId is omitted).',\n        },\n        frameId: {\n          type: 'number',\n          description: 'Target frame ID for iframe support.',\n        },\n      },\n      required: ['keys'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.CONSOLE,\n    description:\n      'Capture console output from a browser tab. Supports snapshot mode (default; one-time capture with ~2s wait) and buffer mode (persistent per-tab buffer you can read/clear instantly without waiting).',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        url: {\n          type: 'string',\n          description:\n            'URL to navigate to and capture console from. If not provided, uses the current active tab',\n        },\n        tabId: {\n          type: 'number',\n          description: 'Target an existing tab by ID (default: active tab).',\n        },\n        windowId: {\n          type: 'number',\n          description: 'Target window ID to pick active tab when tabId is omitted.',\n        },\n        background: {\n          type: 'boolean',\n          description: 'Do not activate tab/focus window when capturing via CDP. Default: false',\n        },\n        includeExceptions: {\n          type: 'boolean',\n          description: 'Include uncaught exceptions in the output (default: true)',\n        },\n        maxMessages: {\n          type: 'number',\n          description:\n            'Maximum number of console messages to capture in snapshot mode (default: 100). If limit is provided, it takes precedence.',\n        },\n        mode: {\n          type: 'string',\n          enum: ['snapshot', 'buffer'],\n          description:\n            'Console capture mode: snapshot (default; waits ~2s for messages) or buffer (persistent per-tab buffer; reads from memory instantly).',\n        },\n        buffer: {\n          type: 'boolean',\n          description: 'Alias for mode=\"buffer\" (default: false).',\n        },\n        clear: {\n          type: 'boolean',\n          description:\n            'Buffer mode only: clear the buffered logs for this tab before reading (default: false). Use clearAfterRead instead to clear after reading (mcp-tools.js style).',\n        },\n        clearAfterRead: {\n          type: 'boolean',\n          description:\n            'Buffer mode only: clear the buffered logs for this tab AFTER reading, to avoid duplicate messages on subsequent calls (default: false). This matches mcp-tools.js behavior.',\n        },\n        pattern: {\n          type: 'string',\n          description:\n            'Optional regex filter applied to message/exception text. Supports /pattern/flags syntax.',\n        },\n        onlyErrors: {\n          type: 'boolean',\n          description:\n            'Only return error-level console messages (and exceptions when includeExceptions=true). Default: false.',\n        },\n        limit: {\n          type: 'number',\n          description:\n            'Limit returned console messages. In snapshot mode this is an alias for maxMessages; in buffer mode it limits returned messages from the buffer.',\n        },\n      },\n      required: [],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.FILE_UPLOAD,\n    description:\n      'Upload files to web forms with file input elements using Chrome DevTools Protocol',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        tabId: { type: 'number', description: 'Target tab ID (default: active tab)' },\n        windowId: {\n          type: 'number',\n          description: 'Target window ID to pick active tab when tabId is omitted',\n        },\n        selector: {\n          type: 'string',\n          description: 'CSS selector for the file input element (input[type=\"file\"])',\n        },\n        filePath: {\n          type: 'string',\n          description: 'Local file path to upload',\n        },\n        fileUrl: {\n          type: 'string',\n          description: 'URL to download file from before uploading',\n        },\n        base64Data: {\n          type: 'string',\n          description: 'Base64 encoded file data to upload',\n        },\n        fileName: {\n          type: 'string',\n          description: 'Optional filename when using base64 or URL (default: \"uploaded-file\")',\n        },\n        multiple: {\n          type: 'boolean',\n          description: 'Whether the input accepts multiple files (default: false)',\n        },\n      },\n      required: ['selector'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.HANDLE_DIALOG,\n    description: 'Handle JavaScript dialogs (alert/confirm/prompt) via CDP',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        action: { type: 'string', description: 'accept | dismiss' },\n        promptText: {\n          type: 'string',\n          description: 'Optional prompt text when accepting a prompt',\n        },\n      },\n      required: ['action'],\n    },\n  },\n  {\n    name: TOOL_NAMES.BROWSER.GIF_RECORDER,\n    description:\n      'Record browser tab activity as an animated GIF.\\n\\nModes:\\n- Fixed FPS mode (action=\"start\"): Captures frames at regular intervals. Good for animations/videos.\\n- Auto-capture mode (action=\"auto_start\"): Captures frames automatically when chrome_computer or chrome_navigate actions succeed. Better for interaction recordings with natural pacing.\\n\\nUse \"stop\" to end recording and save the GIF.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        action: {\n          type: 'string',\n          enum: ['start', 'stop', 'status', 'auto_start', 'capture', 'clear', 'export'],\n          description:\n            'Action to perform:\\n- \"start\": Begin fixed-FPS recording (captures frames at regular intervals)\\n- \"auto_start\": Begin auto-capture mode (frames captured on tool actions)\\n- \"stop\": End recording and save GIF\\n- \"status\": Get current recording state\\n- \"capture\": Manually trigger a frame capture in auto mode\\n- \"clear\": Clear all recording state and cached GIF without saving\\n- \"export\": Export the last recorded GIF (download or drag&drop upload)',\n        },\n        tabId: {\n          type: 'number',\n          description:\n            'Target tab ID (default: active tab). Used with \"start\"/\"auto_start\" for recording, and with \"export\" (download=false) for drag&drop upload target.',\n        },\n        fps: {\n          type: 'number',\n          description:\n            'Frames per second for fixed-FPS mode (1-30, default: 5). Higher values = smoother but larger file.',\n        },\n        durationMs: {\n          type: 'number',\n          description:\n            'Maximum recording duration in milliseconds (default: 5000, max: 60000). Only for fixed-FPS mode.',\n        },\n        maxFrames: {\n          type: 'number',\n          description:\n            'Maximum number of frames to capture (default: 50 for fixed-FPS, 100 for auto mode, max: 300).',\n        },\n        width: {\n          type: 'number',\n          description: 'Output GIF width in pixels (default: 800, max: 1920).',\n        },\n        height: {\n          type: 'number',\n          description: 'Output GIF height in pixels (default: 600, max: 1080).',\n        },\n        maxColors: {\n          type: 'number',\n          description:\n            'Maximum colors in palette (default: 256). Lower values = smaller file size.',\n        },\n        filename: {\n          type: 'string',\n          description: 'Output filename (without extension). Defaults to timestamped name.',\n        },\n        captureDelayMs: {\n          type: 'number',\n          description:\n            'Auto-capture mode only: Delay in ms after action before capturing frame (default: 150). Allows UI to stabilize.',\n        },\n        frameDelayCs: {\n          type: 'number',\n          description:\n            'Auto-capture mode only: Display duration per frame in centiseconds (default: 20 = 200ms per frame).',\n        },\n        annotation: {\n          type: 'string',\n          description:\n            'Auto-capture mode only (action=\"capture\"): Optional text label to render on the captured frame.',\n        },\n        download: {\n          type: 'boolean',\n          description:\n            'Export action only: Set to true (default) to download the GIF, or false to upload via drag&drop.',\n        },\n        coordinates: {\n          type: 'object',\n          description:\n            'Export action only (when download=false): Target coordinates for drag&drop upload.',\n          properties: {\n            x: { type: 'number' },\n            y: { type: 'number' },\n          },\n          required: ['x', 'y'],\n        },\n        ref: {\n          type: 'string',\n          description:\n            'Export action only (when download=false): Element ref from chrome_read_page for drag&drop target.',\n        },\n        selector: {\n          type: 'string',\n          description:\n            'Export action only (when download=false): CSS selector for drag&drop target element.',\n        },\n        enhancedRendering: {\n          type: 'object',\n          description:\n            'Auto-capture mode only: Configure visual overlays for recorded actions (click indicators, drag paths, labels). Pass `true` to enable all defaults.',\n          properties: {\n            clickIndicators: {\n              oneOf: [\n                { type: 'boolean' },\n                {\n                  type: 'object',\n                  properties: {\n                    enabled: {\n                      type: 'boolean',\n                      description: 'Enable click indicators (default: true)',\n                    },\n                    color: {\n                      type: 'string',\n                      description:\n                        'CSS color for click indicator (default: \"rgba(255, 87, 34, 0.8)\")',\n                    },\n                    radius: { type: 'number', description: 'Initial radius in px (default: 20)' },\n                    animationDurationMs: {\n                      type: 'number',\n                      description: 'Animation duration in ms (default: 400)',\n                    },\n                    animationFrames: {\n                      type: 'number',\n                      description: 'Number of animation frames (default: 3)',\n                    },\n                    animationIntervalMs: {\n                      type: 'number',\n                      description: 'Interval between animation frames in ms (default: 80)',\n                    },\n                  },\n                },\n              ],\n              description:\n                'Click indicator overlay config (true for defaults, or object for custom).',\n            },\n            dragPaths: {\n              oneOf: [\n                { type: 'boolean' },\n                {\n                  type: 'object',\n                  properties: {\n                    enabled: {\n                      type: 'boolean',\n                      description: 'Enable drag path rendering (default: true)',\n                    },\n                    color: {\n                      type: 'string',\n                      description: 'CSS color for drag path (default: \"rgba(33, 150, 243, 0.7)\")',\n                    },\n                    lineWidth: { type: 'number', description: 'Line width in px (default: 3)' },\n                    lineDash: {\n                      type: 'array',\n                      items: { type: 'number' },\n                      description: 'Dash pattern (default: [6, 4])',\n                    },\n                    arrowSize: {\n                      type: 'number',\n                      description: 'Arrow head size in px (default: 10)',\n                    },\n                  },\n                },\n              ],\n              description: 'Drag path overlay config (true for defaults, or object for custom).',\n            },\n            labels: {\n              oneOf: [\n                { type: 'boolean' },\n                {\n                  type: 'object',\n                  properties: {\n                    enabled: {\n                      type: 'boolean',\n                      description: 'Enable action labels (default: true)',\n                    },\n                    font: {\n                      type: 'string',\n                      description: 'Font for labels (default: \"bold 12px sans-serif\")',\n                    },\n                    textColor: { type: 'string', description: 'Text color (default: \"#fff\")' },\n                    bgColor: {\n                      type: 'string',\n                      description: 'Background color (default: \"rgba(0,0,0,0.7)\")',\n                    },\n                    padding: { type: 'number', description: 'Padding in px (default: 4)' },\n                    borderRadius: {\n                      type: 'number',\n                      description: 'Border radius in px (default: 4)',\n                    },\n                    offset: {\n                      type: 'object',\n                      properties: { x: { type: 'number' }, y: { type: 'number' } },\n                      description: 'Offset from action position (default: {x: 10, y: -20})',\n                    },\n                  },\n                },\n              ],\n              description: 'Action label overlay config (true for defaults, or object for custom).',\n            },\n            durationMs: {\n              type: 'number',\n              description: 'How long overlays remain visible in ms (default: 1500).',\n            },\n          },\n        },\n      },\n      required: ['action'],\n    },\n  },\n];\n"
  },
  {
    "path": "packages/shared/src/types.ts",
    "content": "export enum NativeMessageType {\n  START = 'start',\n  STARTED = 'started',\n  STOP = 'stop',\n  STOPPED = 'stopped',\n  PING = 'ping',\n  PONG = 'pong',\n  ERROR = 'error',\n  PROCESS_DATA = 'process_data',\n  PROCESS_DATA_RESPONSE = 'process_data_response',\n  CALL_TOOL = 'call_tool',\n  CALL_TOOL_RESPONSE = 'call_tool_response',\n  // Additional message types used in Chrome extension\n  SERVER_STARTED = 'server_started',\n  SERVER_STOPPED = 'server_stopped',\n  ERROR_FROM_NATIVE_HOST = 'error_from_native_host',\n  CONNECT_NATIVE = 'connectNative',\n  ENSURE_NATIVE = 'ensure_native',\n  PING_NATIVE = 'ping_native',\n  DISCONNECT_NATIVE = 'disconnect_native',\n}\n\nexport interface NativeMessage<P = any, E = any> {\n  type?: NativeMessageType;\n  responseToRequestId?: string;\n  payload?: P;\n  error?: E;\n}\n\n// ============================================================\n// Element Picker Types (chrome_request_element_selection)\n// ============================================================\n\n/**\n * A single element selection request from the AI.\n */\nexport interface ElementPickerRequest {\n  /**\n   * Optional stable request id. If omitted, the extension will generate one.\n   */\n  id?: string;\n  /**\n   * Short label shown to the user (e.g., \"Login button\").\n   */\n  name: string;\n  /**\n   * Optional longer instruction shown to the user.\n   */\n  description?: string;\n}\n\n/**\n * Bounding rectangle of a picked element.\n */\nexport interface PickedElementRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n/**\n * Center point of a picked element.\n */\nexport interface PickedElementPoint {\n  x: number;\n  y: number;\n}\n\n/**\n * A picked element that can be used with other tools (click, fill, etc.).\n */\nexport interface PickedElement {\n  /**\n   * Element ref written into window.__claudeElementMap (frame-local).\n   * Can be used directly with chrome_click_element, chrome_fill_or_select, etc.\n   */\n  ref: string;\n  /**\n   * Best-effort stable CSS selector.\n   */\n  selector: string;\n  /**\n   * Selector type (currently CSS only).\n   */\n  selectorType: 'css';\n  /**\n   * Bounding rect in the element's frame viewport coordinates.\n   */\n  rect: PickedElementRect;\n  /**\n   * Center point in the element's frame viewport coordinates.\n   * Can be used as coordinates for chrome_computer.\n   */\n  center: PickedElementPoint;\n  /**\n   * Optional text snippet to help verify the selection.\n   */\n  text?: string;\n  /**\n   * Lowercased tag name.\n   */\n  tagName?: string;\n  /**\n   * Chrome frameId for iframe targeting.\n   * Pass this to chrome_click_element/chrome_fill_or_select for cross-frame support.\n   */\n  frameId: number;\n}\n\n/**\n * Result for a single element selection request.\n */\nexport interface ElementPickerResultItem {\n  /**\n   * The request id (matches the input request).\n   */\n  id: string;\n  /**\n   * The request name (for reference).\n   */\n  name: string;\n  /**\n   * The picked element, or null if not selected.\n   */\n  element: PickedElement | null;\n  /**\n   * Error message if selection failed for this request.\n   */\n  error?: string;\n}\n\n/**\n * Result of the chrome_request_element_selection tool.\n */\nexport interface ElementPickerResult {\n  /**\n   * True if the user confirmed all selections.\n   */\n  success: boolean;\n  /**\n   * Session identifier for this picker session.\n   */\n  sessionId: string;\n  /**\n   * Timeout value used for this session.\n   */\n  timeoutMs: number;\n  /**\n   * True if the user cancelled the selection.\n   */\n  cancelled?: boolean;\n  /**\n   * True if the selection timed out.\n   */\n  timedOut?: boolean;\n  /**\n   * List of request IDs that were not selected (for debugging).\n   */\n  missingRequestIds?: string[];\n  /**\n   * Results for each requested element.\n   */\n  results: ElementPickerResultItem[];\n}\n"
  },
  {
    "path": "packages/shared/tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n      \"target\": \"ES2020\",\n      \"module\": \"NodeNext\",\n      \"moduleResolution\": \"NodeNext\",\n      \"esModuleInterop\": true,\n      \"declaration\": true,\n      \"outDir\": \"./dist\",\n      \"strict\": true,\n      \"skipLibCheck\": true\n    },\n    \"include\": [\"src/**/*\"],\n    \"exclude\": [\"node_modules\", \"dist\"]\n  }"
  },
  {
    "path": "packages/wasm-simd/.gitignore",
    "content": "# WASM build outputs\n/pkg/\n/target/\n\n# Rust\nCargo.lock\n\n# Node.js\nnode_modules/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# OS\n.DS_Store\nThumbs.db\n"
  },
  {
    "path": "packages/wasm-simd/BUILD.md",
    "content": "# WASM SIMD 构建指南\n\n## 🚀 快速构建\n\n### 前置要求\n\n```bash\n# 安装 Rust\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\n\n# 安装 wasm-pack\ncurl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh\n```\n\n### 构建选项\n\n1. **从项目根目录构建**（推荐）：\n\n   ```bash\n   # 构建 WASM 并自动复制到 Chrome 扩展\n   npm run build:wasm\n   ```\n\n2. **只构建 WASM 包**：\n\n   ```bash\n   # 从 packages/wasm-simd 目录\n   npm run build\n\n   # 或者从任何地方使用 pnpm filter\n   pnpm --filter @chrome-mcp/wasm-simd build\n   ```\n\n3. **开发模式构建**：\n   ```bash\n   npm run build:dev  # 未优化版本，构建更快\n   ```\n\n### 构建产物\n\n构建完成后，在 `pkg/` 目录下会生成：\n\n- `simd_math.js` - JavaScript 绑定\n- `simd_math_bg.wasm` - WebAssembly 二进制文件\n- `simd_math.d.ts` - TypeScript 类型定义\n- `package.json` - NPM 包信息\n\n### 集成到 Chrome 扩展\n\nWASM 文件会自动复制到 `app/chrome-extension/workers/` 目录，Chrome 扩展可以直接使用：\n\n```typescript\n// 在 Chrome 扩展中使用\nconst wasmUrl = chrome.runtime.getURL('workers/simd_math.js');\nconst wasmModule = await import(wasmUrl);\n```\n\n## 🔧 开发工作流\n\n1. 修改 `src/lib.rs` 中的 Rust 代码\n2. 运行 `npm run build` 重新构建\n3. Chrome 扩展会自动使用新的 WASM 文件\n\n## 📊 性能测试\n\n```bash\n# 在 Chrome 扩展中运行基准测试\nimport { runSIMDBenchmark } from './utils/simd-benchmark';\nawait runSIMDBenchmark();\n```\n"
  },
  {
    "path": "packages/wasm-simd/Cargo.toml",
    "content": "[package]\nname = \"simd-math\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nwasm-bindgen = \"0.2\"\nwide = \"0.7\"\nconsole_error_panic_hook = \"0.1\"\n\n[dependencies.web-sys]\nversion = \"0.3\"\nfeatures = [\n  \"console\",\n]\n\n[profile.release]\nopt-level = 3\nlto = true\ncodegen-units = 1\npanic = \"abort\"\n"
  },
  {
    "path": "packages/wasm-simd/README.md",
    "content": "# @chrome-mcp/wasm-simd\n\nSIMD-optimized WebAssembly math functions for high-performance vector operations.\n\n## Features\n\n- 🚀 **SIMD Acceleration**: Uses WebAssembly SIMD instructions for 4-8x performance boost\n- 🧮 **Vector Operations**: Optimized cosine similarity, batch processing, and matrix operations\n- 🔧 **Memory Efficient**: Smart memory pooling and aligned buffer management\n- 🌐 **Browser Compatible**: Works in all modern browsers with WebAssembly SIMD support\n\n## Performance\n\n| Operation                      | JavaScript | SIMD WASM | Speedup |\n| ------------------------------ | ---------- | --------- | ------- |\n| Cosine Similarity (768d)       | 100ms      | 18ms      | 5.6x    |\n| Batch Similarity (100x768d)    | 850ms      | 95ms      | 8.9x    |\n| Similarity Matrix (50x50x384d) | 2.1s       | 180ms     | 11.7x   |\n\n## Usage\n\n```rust\n// The Rust implementation provides SIMD-optimized functions\nuse wasm_bindgen::prelude::*;\n\n#[wasm_bindgen]\npub struct SIMDMath;\n\n#[wasm_bindgen]\nimpl SIMDMath {\n    #[wasm_bindgen(constructor)]\n    pub fn new() -> SIMDMath { SIMDMath }\n\n    #[wasm_bindgen]\n    pub fn cosine_similarity(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {\n        // SIMD-optimized implementation\n    }\n}\n```\n\n## Building\n\n```bash\n# Install dependencies\ncargo install wasm-pack\n\n# Build for release\nnpm run build\n\n# Build for development\nnpm run build:dev\n```\n\n## Browser Support\n\n- Chrome 91+\n- Firefox 89+\n- Safari 16.4+\n- Edge 91+\n\nOlder browsers automatically fall back to JavaScript implementations.\n"
  },
  {
    "path": "packages/wasm-simd/package.json",
    "content": "{\n  \"name\": \"@chrome-mcp/wasm-simd\",\n  \"version\": \"0.1.0\",\n  \"description\": \"SIMD-optimized WebAssembly math functions for Chrome MCP\",\n  \"main\": \"pkg/simd_math.js\",\n  \"types\": \"pkg/simd_math.d.ts\",\n  \"files\": [\n    \"pkg/\"\n  ],\n  \"scripts\": {\n    \"build\": \"wasm-pack build --target web --out-dir pkg --release\",\n    \"build:dev\": \"wasm-pack build --target web --out-dir pkg --dev\",\n    \"clean\": \"rimraf pkg/\",\n    \"test\": \"wasm-pack test --headless --firefox\"\n  },\n  \"keywords\": [\n    \"wasm\",\n    \"simd\",\n    \"webassembly\",\n    \"math\",\n    \"cosine-similarity\",\n    \"vector-operations\"\n  ],\n  \"author\": \"hangye\",\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"rimraf\": \"^5.0.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/your-repo/chrome-mcp-server.git\",\n    \"directory\": \"packages/wasm-simd\"\n  }\n}\n"
  },
  {
    "path": "packages/wasm-simd/src/lib.rs",
    "content": "use wasm_bindgen::prelude::*;\nuse wide::f32x4;\n\n// 设置 panic hook 以便在浏览器中调试\n#[wasm_bindgen(start)]\npub fn main() {\n    console_error_panic_hook::set_once();\n}\n\n#[wasm_bindgen]\npub struct SIMDMath;\n\n#[wasm_bindgen]\nimpl SIMDMath {\n    #[wasm_bindgen(constructor)]\n    pub fn new() -> SIMDMath {\n        SIMDMath\n    }\n\n    // 辅助函数：仅计算点积 (SIMD)\n    #[inline]\n    fn dot_product_simd_only(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {\n        let len = vec_a.len();\n        let simd_lanes = 4;\n        let simd_len = len - (len % simd_lanes);\n        let mut dot_sum_simd = f32x4::ZERO;\n\n        for i in (0..simd_len).step_by(simd_lanes) {\n            // 使用 try_from 和 new 方法，这是 wide 库的正确 API\n            let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();\n            let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();\n            let a_chunk = f32x4::new(a_array);\n            let b_chunk = f32x4::new(b_array);\n            dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);\n        }\n\n        let mut dot_product = dot_sum_simd.reduce_add();\n        for i in simd_len..len {\n            dot_product += vec_a[i] * vec_b[i];\n        }\n        dot_product\n    }\n\n    #[wasm_bindgen]\n    pub fn cosine_similarity(&self, vec_a: &[f32], vec_b: &[f32]) -> f32 {\n        if vec_a.len() != vec_b.len() || vec_a.is_empty() {\n            return 0.0;\n        }\n\n        let len = vec_a.len();\n        let simd_lanes = 4;\n        let simd_len = len - (len % simd_lanes);\n\n        let mut dot_sum_simd = f32x4::ZERO;\n        let mut norm_a_sum_simd = f32x4::ZERO;\n        let mut norm_b_sum_simd = f32x4::ZERO;\n\n        // SIMD 处理\n        for i in (0..simd_len).step_by(simd_lanes) {\n            let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();\n            let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();\n            let a_chunk = f32x4::new(a_array);\n            let b_chunk = f32x4::new(b_array);\n\n            // 使用 Fused Multiply-Add (FMA)\n            dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);\n            norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);\n            norm_b_sum_simd = b_chunk.mul_add(b_chunk, norm_b_sum_simd);\n        }\n\n        // 水平求和\n        let mut dot_product = dot_sum_simd.reduce_add();\n        let mut norm_a_sq = norm_a_sum_simd.reduce_add();\n        let mut norm_b_sq = norm_b_sum_simd.reduce_add();\n\n        // 处理剩余元素\n        for i in simd_len..len {\n            dot_product += vec_a[i] * vec_b[i];\n            norm_a_sq += vec_a[i] * vec_a[i];\n            norm_b_sq += vec_b[i] * vec_b[i];\n        }\n\n        // 优化的数值稳定性处理\n        let norm_a = norm_a_sq.sqrt();\n        let norm_b = norm_b_sq.sqrt();\n\n        if norm_a == 0.0 || norm_b == 0.0 {\n            return 0.0;\n        }\n\n        let magnitude = norm_a * norm_b;\n        // 限制结果在 [-1.0, 1.0] 范围内，处理浮点精度误差\n        (dot_product / magnitude).max(-1.0).min(1.0)\n    }\n\n    #[wasm_bindgen]\n    pub fn batch_similarity(&self, vectors: &[f32], query: &[f32], vector_dim: usize) -> Vec<f32> {\n        if vector_dim == 0 { return Vec::new(); }\n        if vectors.len() % vector_dim != 0 { return Vec::new(); }\n        if query.len() != vector_dim { return Vec::new(); }\n\n        let num_vectors = vectors.len() / vector_dim;\n        let mut results = Vec::with_capacity(num_vectors);\n\n        // 预计算查询向量的范数\n        let query_norm_sq = self.compute_norm_squared_simd(query);\n        if query_norm_sq == 0.0 {\n            return vec![0.0; num_vectors];\n        }\n        let query_norm = query_norm_sq.sqrt();\n\n        for i in 0..num_vectors {\n            let start = i * vector_dim;\n            let vector_slice = &vectors[start..start + vector_dim];\n\n            // dot_product_and_norm_simd 计算 vector_slice (vec_a) 的范数\n            let (dot_product, vector_norm_sq) = self.dot_product_and_norm_simd(vector_slice, query);\n\n            if vector_norm_sq == 0.0 {\n                results.push(0.0);\n            } else {\n                let vector_norm = vector_norm_sq.sqrt();\n                let similarity = dot_product / (vector_norm * query_norm);\n                results.push(similarity.max(-1.0).min(1.0));\n            }\n        }\n        results\n    }\n\n    // 辅助函数：SIMD 计算范数平方\n    #[inline]\n    fn compute_norm_squared_simd(&self, vec: &[f32]) -> f32 {\n        let len = vec.len();\n        let simd_lanes = 4;\n        let simd_len = len - (len % simd_lanes);\n        let mut norm_sum_simd = f32x4::ZERO;\n\n        for i in (0..simd_len).step_by(simd_lanes) {\n            let array: [f32; 4] = vec[i..i + simd_lanes].try_into().unwrap();\n            let chunk = f32x4::new(array);\n            norm_sum_simd = chunk.mul_add(chunk, norm_sum_simd);\n        }\n\n        let mut norm_sq = norm_sum_simd.reduce_add();\n        for i in simd_len..len {\n            norm_sq += vec[i] * vec[i];\n        }\n        norm_sq\n    }\n\n    // 辅助函数：同时计算点积和vec_a的范数平方\n    #[inline]\n    fn dot_product_and_norm_simd(&self, vec_a: &[f32], vec_b: &[f32]) -> (f32, f32) {\n        let len = vec_a.len(); // 假设 vec_a.len() == vec_b.len()\n        let simd_lanes = 4;\n        let simd_len = len - (len % simd_lanes);\n\n        let mut dot_sum_simd = f32x4::ZERO;\n        let mut norm_a_sum_simd = f32x4::ZERO;\n\n        for i in (0..simd_len).step_by(simd_lanes) {\n            let a_array: [f32; 4] = vec_a[i..i + simd_lanes].try_into().unwrap();\n            let b_array: [f32; 4] = vec_b[i..i + simd_lanes].try_into().unwrap();\n            let a_chunk = f32x4::new(a_array);\n            let b_chunk = f32x4::new(b_array);\n\n            dot_sum_simd = a_chunk.mul_add(b_chunk, dot_sum_simd);\n            norm_a_sum_simd = a_chunk.mul_add(a_chunk, norm_a_sum_simd);\n        }\n\n        let mut dot_product = dot_sum_simd.reduce_add();\n        let mut norm_a_sq = norm_a_sum_simd.reduce_add();\n\n        for i in simd_len..len {\n            dot_product += vec_a[i] * vec_b[i];\n            norm_a_sq += vec_a[i] * vec_a[i];\n        }\n        (dot_product, norm_a_sq)\n    }\n\n    // 批量矩阵相似度计算 - 优化版\n    #[wasm_bindgen]\n    pub fn similarity_matrix(&self, vectors_a: &[f32], vectors_b: &[f32], vector_dim: usize) -> Vec<f32> {\n        if vector_dim == 0 || vectors_a.len() % vector_dim != 0 || vectors_b.len() % vector_dim != 0 {\n            return Vec::new();\n        }\n\n        let num_a = vectors_a.len() / vector_dim;\n        let num_b = vectors_b.len() / vector_dim;\n        let mut results = Vec::with_capacity(num_a * num_b);\n\n        // 1. 预计算 vectors_a 的范数\n        let norms_a: Vec<f32> = (0..num_a)\n            .map(|i| {\n                let start = i * vector_dim;\n                let vec_a_slice = &vectors_a[start..start + vector_dim];\n                self.compute_norm_squared_simd(vec_a_slice).sqrt()\n            })\n            .collect();\n\n        // 2. 预计算 vectors_b 的范数\n        let norms_b: Vec<f32> = (0..num_b)\n            .map(|j| {\n                let start = j * vector_dim;\n                let vec_b_slice = &vectors_b[start..start + vector_dim];\n                self.compute_norm_squared_simd(vec_b_slice).sqrt()\n            })\n            .collect();\n\n        for i in 0..num_a {\n            let start_a = i * vector_dim;\n            let vec_a = &vectors_a[start_a..start_a + vector_dim];\n            let norm_a = norms_a[i];\n\n            if norm_a == 0.0 {\n                // 如果 norm_a 为 0，所有相似度都为 0\n                for _ in 0..num_b {\n                    results.push(0.0);\n                }\n                continue;\n            }\n\n            for j in 0..num_b {\n                let start_b = j * vector_dim;\n                let vec_b = &vectors_b[start_b..start_b + vector_dim];\n                let norm_b = norms_b[j];\n\n                if norm_b == 0.0 {\n                    results.push(0.0);\n                    continue;\n                }\n\n                // 使用专用的点积函数\n                let dot_product = self.dot_product_simd_only(vec_a, vec_b);\n                let magnitude = norm_a * norm_b;\n\n                // magnitude 不应该为零，因为已经检查了 norm_a/norm_b\n                let similarity = (dot_product / magnitude).max(-1.0).min(1.0);\n                results.push(similarity);\n            }\n        }\n\n        results\n    }\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - 'app/*'\n  - 'packages/*'"
  },
  {
    "path": "prompt/content-analize.md",
    "content": "# Role:\n\n你是一名资深的【信息架构与视觉沟通专家】。\n\n# Profile:\n\n- **背景**: 拥有超过10年的内容分析与信息设计经验。\n- **专长**:\n  1.  **认知简化**: 能将复杂、零散的知识快速转化为结构清晰、易于理解的框架。\n  2.  **逻辑提炼**: 擅长识别信息背后的核心逻辑、因果关系和层级结构。\n  3.  **视觉叙事**: 是Excalidraw的顶级专家，精通利用其简洁的工具集构建富有表现力和洞察力的视觉化图表。\n\n# Workflow:\n\n当我提供内容后，请你严格按照以下步骤执行任务：\n\n1.  **【第一步：内容解构与提炼 (Content Deconstruction & Synthesis)】**\n\n    - **阅读并理解**: 完整阅读我提供的内容。\n    - **识别核心概念 (Core Concepts)**: 找出1-3个中心主题或思想。\n    - **提取关键信息 (Key Information)**: 罗列出支持核心概念的关键论点、数据、案例或步骤。\n    - **分析内在结构 (Structural Analysis)**: 分析信息之间的逻辑关系，是并列、递进、因果、包含还是流程关系？\n    - **形成文字摘要 (Text Summary)**: 输出一个结构化的文字摘要，可以使用Markdown的列表或标题格式，清晰地展示上述分析结果。\n\n2.  **【第二步：Excalidraw可视化策略 (Excalidraw Visualization Strategy)】**\n    - **选择最佳图表范式 (Select Optimal Diagram Paradigm)**:\n      - 基于内容的结构，明确建议最合适的图表类型。例如：\n        - **思维导图 (Mind Map)**: 用于发散性思考、头脑风暴或单一核心的多分支主题。\n        - **流程图 (Flowchart)**: 用于表示顺序操作、决策路径或工作流程。\n        - **概念图 (Concept Map)**: 用于展示多个概念之间复杂的、非线性的关系。\n        - **时间线 (Timeline)**: 用于呈现事件或阶段的时间顺序。\n        - **矩阵/表格 (Matrix/Table)**: 用于对比不同项目的功能或属性。\n    - **设计图表元素 (Design Element Scheme)**:\n      - **节点 (Nodes)**: 为不同层级或类型的信息指定Excalidraw图形。\n        - _示例_: \"核心主题使用带背景色的矩形，关键论点使用普通圆形，支撑细节使用无边框文本。\"\n      - **连接 (Connectors)**: 定义连线的用法。\n        - _示例_: \"使用实线箭头表示直接因果或流程，使用虚线表示弱相关或参考关系。\"\n      - **布局 (Layout)**: 建议一个清晰的画布布局。\n        - _示例_: \"采用从左到右的泳道布局\" 或 \"采用中心辐射的星型布局\"。\n    - **提供具体实现清单 (Provide Implementation Checklist)**:\n      - 输出一个清晰的列表，告诉我具体该创建哪些图形和连线，甚至可以包含建议的文本标签。\n\n# Output Format:\n\n请将你的回答分为以下两个部分，并使用Markdown格式化：\n\n---\n\n### **一、内容核心摘要**\n\n_(在此处提供你的结构化文字摘要)_\n\n### **二、Excalidraw 可视化蓝图**\n\n- **1. 推荐图表类型**: [例如：流程图]\n- **2. 核心元素设计**:\n  - **主题/起点**: [建议的图形和样式]\n  - **过程/步骤**: [建议的图形和样式]\n  - **决策/判断**: [建议的图形和样式]\n  - **数据/备注**: [建议的图形和样式]\n- **3. 布局与连接**:\n  - **整体布局**: [建议的布局方式]\n  - **连接线**: [实线、虚线、箭头的具体含义]\n- **4. 操作步骤建议**:\n  - 1. 创建...\n  - 2. 连接...\n  - 3. 标注...\n"
  },
  {
    "path": "prompt/excalidraw-prompt.md",
    "content": "## 角色\n\n你是一位顶级的解决方案架构师，不仅精通复杂的系统设计，更是Excalidraw的专家级用户。你对其**声明式的、基于JSON的数据模型**了如指掌，能够深刻理解元素（Element）的各项属性，并能娴熟地运用**绑定（Binding）、容器（Containment）、组合（Grouping）与框架（Framing）**等核心机制来绘制出结构清晰、布局优美、信息传达高效的架构图和流程图。\n\n## 核心任务\n\n根据用户的需求，通过调用工具与excalidraw.com画布交互，以编程方式创建、修改或删除元素，最终呈现一幅专业、美观的图表。\n\n## 规则\n\n1.  **注入脚本**: 必须首先调用 `chrome_inject_script` 工具，将一个内容脚本注入到 `excalidraw.com` 的主窗口（`MAIN`）\n2.  **脚本事件监听**: 该脚本会监听以下事件：\n    - `getSceneElements`: 获取画布上所有元素的完整数据\n    - `addElement`: 向画布添加一个或多个新元素\n    - `updateElement`: 修改画布的一个或多个元素\n    - `deleteElement`: 根据元素ID删除元素\n    - `cleanup`: 清空重置画布\n3.  **发送指令**: 通过 `chrome_send_command_to_inject_script` 工具与注入的脚本通信，触发上述事件。指令格式如下：\n    - 获取元素: `{ \"eventName\": \"getSceneElements\" }`\n    - 添加元素: `{ \"eventName\": \"addElement\", \"payload\": { \"eles\": [elementSkeleton1, elementSkeleton2] } }`\n    - 更新元素: `{ \"eventName\": \"updateElement\", \"payload\": [{ \"id\": \"id1\", ...其他要更新的属性 }] }`\n    - 删除元素: `{ \"eventName\": \"deleteElement\", \"payload\": { \"id\": \"xxx\" } }`\n    - 清空重置画布: `{ \"eventName\": \"cleanup\" }`\n4.  **遵循最佳实践**:\n    - **布局与对齐**: 合理规划整体布局，确保元素间距适当，并尽可能使用对齐工具（如顶部对齐、中心对齐）使图表整洁有序。\n    - **尺寸与层级**: 核心元素的尺寸应更大，次要元素稍小，以建立清晰的视觉层级。避免所有元素大小一致。\n    - **配色方案**: 使用一套和谐的配色方案（2-3种主色）。例如，用一种颜色表示外部服务，另一种表示内部组件。避免色彩过多或过少。\n    - **连接清晰**: 保证箭头和连接线路径清晰，尽量不交叉、不重叠。使用曲线箭头或调整`points`来绕过其他元素。\n    - **组织与管理**: 对于复杂的图表，使用**Frame（框架）**来组织和命名不同的区域，使其像幻灯片一样清晰。\n\n## Excalidraw Schema核心规则（基于Element Skeleton）\n\n**重要理念**: 你将通过创建**元素骨架 (`ExcalidrawElementSkeleton`)** 对象来添加元素，而非手动构建完整的 `ExcalidrawElement`。`ExcalidrawElementSkeleton` 是一个简化的、专为编程创建而设计的对象。Excalidraw前端会自动补全版本号、随机种子、等属性。\n\n### A. 通用核心属性 (所有元素骨架都包含)\n\n| 属性              | 类型     | 描述                                                                          | 示例                      |\n| :---------------- | :------- | :---------------------------------------------------------------------------- | :------------------------ |\n| `id`              | string   | **强烈推荐**. 元素的唯一标识符。在创建关系（绑定、容器）时**必须**提供。      | `\"user-db-01\"`            |\n| `type`            | string   | **必须**. 元素类型，如 `rectangle`, `arrow`, `text`, `frame`                  | `\"diamond\"`               |\n| `x`, `y`          | number   | **必须**. 元素左上角的画布坐标。                                              | `150`, `300`              |\n| `width`, `height` | number   | **必须**. 元素的尺寸。                                                        | `200`, `80`               |\n| `angle`           | number   | 旋转角度 (弧度制)，默认为0。                                                  | `0` (默认), `1.57` (90度) |\n| `strokeColor`     | string   | 边框颜色 (Hex)，默认为黑色。                                                  | `\"#1e1e1e\"`               |\n| `backgroundColor` | string   | 背景填充色 (Hex)，默认为透明。                                                | `\"#f3d9a0\"`               |\n| `fillStyle`       | string   | 填充样式：`\"hachure\"` (影线), `\"solid\"` (纯色), `\"zigzag\"`，默认为\"hachure\"。 | `\"solid\"`                 |\n| `strokeWidth`     | number   | 边框粗细，默认为1。                                                           | `1`, `2`, `4`             |\n| `strokeStyle`     | string   | 边框样式：`\"solid\"`, `\"dashed\"`, `\"dotted\"`，默认为\"solid\"。                  | `\"dashed\"`                |\n| `roughness`       | number   | \"手绘感\"程度 (0-2)。`0`最整洁, `2`最粗糙，默认为1。                           | `1`                       |\n| `opacity`         | number   | 透明度 (0-100)，默认为100。                                                   | `100`                     |\n| `groupIds`        | string[] | **(关系)** 元素所属的一个或多个组的ID列表。                                   | `[\"group-A\"]`             |\n| `frameId`         | string   | **(关系)** 元素所属的框架ID。                                                 | `\"frame-data-layer\"`      |\n\n### B. 元素特有属性\n\n1.  **形状 (`rectangle`, `ellipse`, `diamond`)**\n\n    - **核心**：形状元素本身不包含文本。要为形状添加标签，**必须**额外创建一个`text`元素，并使用`containerId`将其绑定到形状上。\n    - **必须**为需要被绑定的形状（作为容器或箭头目标）提供一个明确的`id`。\n\n2.  **文本 (`text`)**\n\n    - `text`: **必须**. 显示的文本内容, 支持`\\n`换行。\n    - `originText`: **必须**. 用于后续编辑。\n    - `fontSize`: 字体大小 (数字), 默认为20。如 `16`, `20`, `28`。\n    - `fontFamily`: 字体类型: `1` (手写/Virgil), `2` (正常/Helvetica), `3` (代码/Cascadia)，默认为1。\n    - `textAlign`: 水平对齐: `\"left\"`, `\"center\"`, `\"right\"`，默认为\"left\"。\n    - `verticalAlign`: 垂直对齐: `\"top\"`, `\"middle\"`, `\"bottom\"`，默认为\"top\"。\n    - `containerId`: **(核心关系)** 此属性是文本放入形状的关键。将其值设置为目标容器元素的`id`。\n    - **其他必须属性**: `autoResize: true`, `lineHeight: 1.25`。\n\n3.  **线性/箭头 (`line`, `arrow`)**\n    - `points`: **必须**. 定义路径的点坐标数组，**相对于元素自身的(x, y)点**。最简单的直线是 `[[0, 0], [width, height]]`。\n    - `startArrowhead`: 起始箭头样式，可为 `\"arrow\"`, `\"dot\"`, `\"triangle\"`, `\"bar\"` 或 `null`，默认为`null`。\n    - `endArrowhead`: 结束箭头样式，同上，`arrow`类型默认为`\"arrow\"`。\n\n### C. 元素关系创建规则（必须）\n\n1.  **将文本放入元素**\n\n    - **场景**: 当一个元素里面包含一个描述文本的时候，比如矩形a里面有一个text，则必须要把text和a关联起来\n    - **原理**: 必须建立双向链接。容器元素通过boundElements指向文本，文本通过containerId指回容器\n    - **流程**:\n      1. 为形状和文本元素分别创建唯一的id\n      2. 在文本元素中，添加containerId属性，其值为形状的id\n      3. 必须）调用updateElement，更新形状元素，添加boundElements属性，其值为一个数组，包含指向文本元素的引用\n      4. 为保证居中对齐，建议将文本元素的 `textAlign` 设置为 `\"center\"`，`verticalAlign` 设置为 `\"middle\"`\n    - **示例**:\n      ```json\n      [\n        {\n          \"id\": \"api-server-1\",\n          \"type\": \"rectangle\",\n          \"x\": 100,\n          \"y\": 100,\n          \"width\": 220,\n          \"height\": 80,\n          \"backgroundColor\": \"#e3f2fd\",\n          \"strokeColor\": \"#1976d2\",\n          \"fillStyle\": \"solid\",\n          \"boundElements\": [\n            {\n              \"type\": \"text\",\n              \"id\": \"21z5f7b\"\n            }\n          ]\n        },\n        {\n          \"id\": \"21z5f7b\",\n          \"type\": \"text\",\n          \"x\": 110,\n          \"y\": 125,\n          \"width\": 200,\n          \"height\": 50,\n          \"containerId\": \"api-server-1\",\n          \"text\": \"核心API服务\\n(Node.js)\",\n          \"fontSize\": 20,\n          \"fontFamily\": 2,\n          \"textAlign\": \"center\",\n          \"verticalAlign\": \"middle\",\n          \"autoResize\": true,\n          \"lineHeight\": 1.25\n        }\n      ]\n      ```\n\n2.  **绑定 (Binding): 将箭头连接到元素**\n\n    - **场景**: 当箭头或连线需要连接两个元素时，必须建立绑定关系\n    - **原理**: 必须建立双向链接。箭头通过start和end指向源/目标元素，同时源/目标元素也必须通过boundElements指回箭头。\n    - **流程**:\n      1. 为所有参与的元素（源、目标、箭头）创建唯一的id\n      2. （必须）调用updateElement，更新箭头元素设置 startBinding: { \"elementId\": \"源元素id\", focus: 0.0, gap: 5 } 和 endBinding(类似startBinding)\n      3. （必须）调用updateElement，在源元素和目标元素的boundElements数组中，分别添加指向箭头ID的引用\n    - **示例**:\n      ```json\n      [\n        {\n          \"id\": \"element-A\",\n          \"type\": \"rectangle\",\n          \"x\": 100,\n          \"y\": 300,\n          \"width\": 150,\n          \"height\": 60,\n          \"boundElements\": [{ \"id\": \"arrow-A-to-B\", \"type\": \"arrow\" }]\n        },\n        {\n          \"id\": \"element-B\",\n          \"type\": \"rectangle\",\n          \"x\": 400,\n          \"y\": 300,\n          \"width\": 150,\n          \"height\": 60,\n          \"boundElements\": [{ \"id\": \"arrow-A-to-B\", \"type\": \"arrow\" }]\n        },\n        {\n          \"id\": \"arrow-A-to-B\",\n          \"type\": \"arrow\",\n          \"x\": 250,\n          \"y\": 330,\n          \"width\": 150,\n          \"height\": 1,\n          \"endArrowhead\": \"arrow\",\n          \"startBinding\": {\n            \"elementId\": \"element-A\", // 绑定的元素ID\n            \"focus\": 0.0, // 连接点在元素边缘的位置（-1到1之间）\n            \"gap\": 5 // 箭头末端与元素边缘的间隙\n          },\n          \"endBinding\": {\n            \"elementId\": \"element-B\",\n            \"focus\": 0.0,\n            \"gap\": 5\n          }\n        }\n      ]\n      ```\n\n3.  **分组 (Grouping): 将多个元素组合**\n\n    - **方法**: 为所有相关元素设置一个完全相同的`groupIds`数组。例如 `groupIds: [\"auth-group\"]`。\n    - **效果**: 分组后的元素在UI上可以作为一个整体被选中、移动和操作。\n\n4.  **框架 (Framing): 用框架组织区域**\n    - **方法**: 创建一个`type: \"frame\"`的元素。然后将需要放入该框架的其他元素的`frameId`属性设置为该框架的`id`。\n    - **效果**: 框架在画布上创建一个命名的可视化区域，将内部元素组织在一起，非常适合划分架构层或功能模块。\n    - **示例**:\n      ```json\n      [\n        {\n          \"id\": \"data-layer-frame\",\n          \"type\": \"frame\",\n          \"x\": 50,\n          \"y\": 400,\n          \"width\": 600,\n          \"height\": 300,\n          \"name\": \"数据存储层\"\n        },\n        {\n          \"id\": \"postgres-db\",\n          \"type\": \"rectangle\",\n          \"frameId\": \"data-layer-frame\",\n          \"x\": 75,\n          \"y\": 480\n        }\n      ]\n      ```\n\n### D. 常用配色方案\n\n```json\n// 系统架构常用色彩\n{\n  \"frontend\": { \"bg\": \"#e8f5e8\", \"stroke\": \"#2e7d32\" }, // 前端 - 绿色\n  \"backend\": { \"bg\": \"#e3f2fd\", \"stroke\": \"#1976d2\" }, // 后端 - 蓝色\n  \"database\": { \"bg\": \"#fff3e0\", \"stroke\": \"#f57c00\" }, // 数据库 - 橙色\n  \"external\": { \"bg\": \"#fce4ec\", \"stroke\": \"#c2185b\" }, // 外部服务 - 粉色\n  \"cache\": { \"bg\": \"#ffebee\", \"stroke\": \"#d32f2f\" }, // 缓存 - 红色\n  \"queue\": { \"bg\": \"#f3e5f5\", \"stroke\": \"#7b1fa2\" } // 队列 - 紫色\n}\n```\n\n### E. 最佳实践提醒\n\n1.  **ID是关键**: 在构建任何有关系的图表时，养成给核心元素预先设定、并始终使用唯一`id`的习惯。\n2.  **先建对象，后建关系**: 确保在创建箭头或将文本放入容器之前，目标对象（带有`id`）已经存在于你将要发送的元素列表中，连线/箭头绑定之后，要更新对应元素的boundElements属性\n3.  **箭头/连线必须绑定元素** 箭头或连线必须双向链接到对应的元素上，比如eleA arrow eleB,必须俩俩双向链接\n4.  **统一更新绑定关系** 推荐用updateElement统一更新（文本/元素）（箭头/元素）（连线/元素）间的双向绑定关系\n5.  **分层组织**: 复杂图表使用Frame进行逻辑分区，每个Frame专注一个功能域。\n6.  **坐标规划**: 预先规划布局，避免元素重叠。通常间距设置为80-150像素。\n7.  **尺寸一致性**: 同类型元素保持相似尺寸，建立视觉节奏。\n8.  **画图前先清空当前画布，画完图后刷新当前页面**\n9.  **禁止使用截图工具**\n\n## 需要注入的脚本\n```javascript\n(()=>{const SCRIPT_ID='excalidraw-control-script';if(window[SCRIPT_ID]){return}function getExcalidrawAPIFromDOM(domElement){if(!domElement){return null}const reactFiberKey=Object.keys(domElement).find((key)=>key.startsWith('__reactFiber$')||key.startsWith('__reactInternalInstance$'),);if(!reactFiberKey){return null}let fiberNode=domElement[reactFiberKey];if(!fiberNode){return null}function isExcalidrawAPI(obj){return(typeof obj==='object'&&obj!==null&&typeof obj.updateScene==='function'&&typeof obj.getSceneElements==='function'&&typeof obj.getAppState==='function')}function findApiInObject(objToSearch){if(isExcalidrawAPI(objToSearch)){return objToSearch}if(typeof objToSearch==='object'&&objToSearch!==null){for(const key in objToSearch){if(Object.prototype.hasOwnProperty.call(objToSearch,key)){const found=findApiInObject(objToSearch[key]);if(found){return found}}}}return null}let excalidrawApiInstance=null;let attempts=0;const MAX_TRAVERSAL_ATTEMPTS=25;while(fiberNode&&attempts<MAX_TRAVERSAL_ATTEMPTS){if(fiberNode.stateNode&&fiberNode.stateNode.props){const api=findApiInObject(fiberNode.stateNode.props);if(api){excalidrawApiInstance=api;break}if(isExcalidrawAPI(fiberNode.stateNode.props.excalidrawAPI)){excalidrawApiInstance=fiberNode.stateNode.props.excalidrawAPI;break}}if(fiberNode.memoizedProps){const api=findApiInObject(fiberNode.memoizedProps);if(api){excalidrawApiInstance=api;break}if(isExcalidrawAPI(fiberNode.memoizedProps.excalidrawAPI)){excalidrawApiInstance=fiberNode.memoizedProps.excalidrawAPI;break}}if(fiberNode.tag===1&&fiberNode.stateNode&&fiberNode.stateNode.state){const api=findApiInObject(fiberNode.stateNode.state);if(api){excalidrawApiInstance=api;break}}if(fiberNode.tag===0||fiberNode.tag===2||fiberNode.tag===14||fiberNode.tag===15||fiberNode.tag===11){if(fiberNode.memoizedState){let currentHook=fiberNode.memoizedState;let hookAttempts=0;const MAX_HOOK_ATTEMPTS=15;while(currentHook&&hookAttempts<MAX_HOOK_ATTEMPTS){const api=findApiInObject(currentHook.memoizedState);if(api){excalidrawApiInstance=api;break}currentHook=currentHook.next;hookAttempts++}if(excalidrawApiInstance)break}}if(fiberNode.stateNode){const api=findApiInObject(fiberNode.stateNode);if(api&&api!==fiberNode.stateNode.props&&api!==fiberNode.stateNode.state){excalidrawApiInstance=api;break}}if(fiberNode.tag===9&&fiberNode.memoizedProps&&typeof fiberNode.memoizedProps.value!=='undefined'){const api=findApiInObject(fiberNode.memoizedProps.value);if(api){excalidrawApiInstance=api;break}}if(fiberNode.return){fiberNode=fiberNode.return}else{break}attempts++}if(excalidrawApiInstance){window.excalidrawAPI=excalidrawApiInstance;console.log('现在您可以通过 `window.foundExcalidrawAPI` 在控制台访问它。')}else{console.error('在检查组件树后未能找到 excalidrawAPI。')}return excalidrawApiInstance}function createFullExcalidrawElement(skeleton){const id=Math.random().toString(36).substring(2,9);const seed=Math.floor(Math.random()*2**31);const versionNonce=Math.floor(Math.random()*2**31);const defaults={isDeleted:false,fillStyle:'hachure',strokeWidth:1,strokeStyle:'solid',roughness:1,opacity:100,angle:0,groupIds:[],strokeColor:'#000000',backgroundColor:'transparent',version:1,locked:false,};const fullElement={id:id,seed:seed,versionNonce:versionNonce,updated:Date.now(),...defaults,...skeleton,};return fullElement}let targetElementForAPI=document.querySelector('.excalidraw-app');if(targetElementForAPI){getExcalidrawAPIFromDOM(targetElementForAPI)}const eventHandler={getSceneElements:()=>{try{return window.excalidrawAPI.getSceneElements()}catch(error){return{error:true,msg:JSON.stringify(error),}}},addElement:(param)=>{try{const existingElements=window.excalidrawAPI.getSceneElements();const newElements=[...existingElements];param.eles.forEach((ele,idx)=>{const newEle=createFullExcalidrawElement(ele);newEle.index=`a${existingElements.length+idx+1}`;newElements.push(newEle)});console.log('newElements ==>',newElements);const appState=window.excalidrawAPI.getAppState();window.excalidrawAPI.updateScene({elements:newElements,appState:appState,commitToHistory:true,});return{success:true,}}catch(error){return{error:true,msg:JSON.stringify(error),}}},deleteElement:(param)=>{try{const existingElements=window.excalidrawAPI.getSceneElements();const newElements=[...existingElements];const idx=newElements.findIndex((e)=>e.id===param.id);if(idx>=0){newElements.splice(idx,1);const appState=window.excalidrawAPI.getAppState();window.excalidrawAPI.updateScene({elements:newElements,appState:appState,commitToHistory:true,});return{success:true,}}else{return{error:true,msg:'element not found',}}}catch(error){return{error:true,msg:JSON.stringify(error),}}},updateElement:(param)=>{try{const existingElements=window.excalidrawAPI.getSceneElements();const resIds=[];for(let i=0;i<param.length;i++){const idx=existingElements.findIndex((e)=>e.id===param[i].id);if(idx>=0){resIds.push[idx];window.excalidrawAPI.mutateElement(existingElements[idx],{...param[i]})}}return{success:true,msg:`已更新元素：${resIds.join(',')}`,}}catch(error){return{error:true,msg:JSON.stringify(error),}}},cleanup:()=>{try{window.excalidrawAPI.resetScene();return{success:true,}}catch(error){return{error:true,msg:JSON.stringify(error),}}},};const handleExecution=(event)=>{const{action,payload,requestId}=event.detail;const param=JSON.parse(payload||'{}');let data,error;try{const handler=eventHandler[action];if(!handler){error='event name not found'}data=handler(param)}catch(e){error=e.message}window.dispatchEvent(new CustomEvent('chrome-mcp:response',{detail:{requestId,data,error}}),)};const initialize=()=>{window.addEventListener('chrome-mcp:execute',handleExecution);window.addEventListener('chrome-mcp:cleanup',cleanup);window[SCRIPT_ID]=true};const cleanup=()=>{window.removeEventListener('chrome-mcp:execute',handleExecution);window.removeEventListener('chrome-mcp:cleanup',cleanup);delete window[SCRIPT_ID];delete window.excalidrawAPI};initialize()})();\n```\n"
  },
  {
    "path": "prompt/modify-web.md",
    "content": "# Role:\n\n你是一名顶级的【浏览器自动化与扩展开发专家】。\n\n# Profile:\n\n- **背景**: 超过10年的前端开发经验，尤其在Chrome/Firefox扩展开发、Content Scripts编写和DOM性能优化方面有深厚造诣。\n\n- **核心原则**:\n      1.  **安全第一 (Security First)**: 绝不操作敏感信息，避免产生安全漏洞。\n      2.  **代码健壮 (Robustness)**: 编写的脚本能在各种边缘情况下稳定运行，尤其是针对SPA（单页应用）的动态内容变化。\n      3.  **性能意识 (Performance-Aware)**: 确保脚本对页面性能的影响降到最低，避免使用昂贵的DOM查询和操作。\n      4.  **代码洁癖 (Clean Code)**: 产出代码结构清晰、易于维护、不要有任何注释，要尽量简洁以节省token\n      5. 调用`chrome_get_web_content`工具时，必须设置htmlContent: true才能看到页面结构\n      6. 禁止使用截图工具chrome_screenshot查看页面内容7. 最后使用chrome_inject_script工具将脚本注入到页面，type设置为MAIN\n\n# Workflow:\n\n当我提出一个页面操作需求时，你将严格遵循以下工作流程：\n\n1.  **【第1步：需求与场景分析】**\n\n    _ **明确意图**: 彻底理解用户的最终目标。\n    _ **识别关键元素**: 分析要实现这个目标，需要与页面上的哪些元素进行交互（按钮、输入框、div容器等）。\n\n2.  **【第2步：DOM结构假设与策略制定】**\n    _ **声明假设**: 由于无法直接访问页面，你必须明确声明你对目标元素CSS选择器的假设。\n        _ _示例_: \"我假设页面的主题切换按钮是一个 `<button>` 元素，其ID为 `theme-switcher`。如果实际情况不同，你需要替换这个选择器。\"\n    _ **制定执行策略**:\n        _ **时机**: 判断脚本应在何时执行？是 `document.addEventListener('DOMContentLoaded', ...)`，还是需要使用 `MutationObserver` 来监听DOM变化（针对动态加载内容的网站）？\n        \\* **操作**: 确定具体要执行的DOM操作（如 `element.click()`、`element.style.backgroundColor = '...'`、`element.remove()`）。\n\n3.  **【第3步：生成Content Script代码】**\n    _ **编码**: 基于以上策略，编写JavaScript代码。\n    _ **必须遵循的编码规范**:\n        _ **作用域隔离**: 使用 `(function() { ... })();` 或 `(async function() { ... })();` 隔离作用域。\n        _ **元素存在性检查**: 在操作任何元素之前，必须检查 `if (element)` 是否存在。\n        _ **防重复执行**: 设计逻辑避免脚本在页面内被重复注入或执行，例如通过在 `<body>` 上添加一个标记class。\n        _ **使用 `const` 和 `let`**: 避免使用 `var`。\n        \\* **添加清晰的注释**: 解释代码块的目的和关键变量。\n\n4.  **【第4步：输出完整的解决方案】**\n    \\* 以Markdown格式提供一个包含代码和文档的完整回复。\n\n# Output Format:\n\n## 请将你的回答格式化为以下结构：\n\n### **1. 任务目标**\n\n> (在此简述你对用户需求的理解)\n\n### **2. 核心思路与假设**\n\n- **执行策略**: (简述脚本的触发时机和主要操作步骤)\n- **重要假设**: 本脚本假设了以下CSS选择器，你可能需要根据实际情况修改：\n      _ `目标元素A`: `[css-selector-A]`\n      _ `目标元素B`: `[css-selector-B]`\n\n### **3. Content Script (可直接使用)**\n\n```javascript\n(function () {\n  // --- 核心逻辑 ---\n  function doSomething() {\n    console.log('尝试执行主题切换脚本...');\n    const themeButton = document.querySelector(THEME_BUTTON_SELECTOR);\n    if (themeButton) {\n      console.log('找到主题按钮，执行点击操作。');\n      themeButton.click();\n    } else {\n      console.warn('未能找到主题切换按钮，请检查选择器是否正确: ', THEME_BUTTON_SELECTOR);\n    }\n  } // --- 执行脚本 ---\n  // 确保在DOM加载完成后执行\n  if (document.readyState === 'loading') {\n    document.addEventListener('DOMContentLoaded', doSomething);\n  } else {\n    doSomething();\n  }\n})();\n```\n"
  },
  {
    "path": "releases/README.md",
    "content": "# Chrome MCP Server Extension - Latest Release\n\n## 🚀 快速安装\n\n### 1. 下载扩展\n\n下载 [chrome-mcp-server-latest.zip](/releases/chrome-extension/latest/chrome-mcp-server-lastest.zip)\n\n### 2. 安装步骤\n\n1. 解压下载的 zip 文件\n2. 打开 Chrome 浏览器\n3. 地址栏输入 `chrome://extensions/`\n4. 开启右上角的\"开发者模式\"开关\n5. 点击\"加载已解压的扩展程序\"\n6. 选择解压后的文件夹\n\n### 3. 验证安装\n\n- 扩展图标应该出现在浏览器工具栏\n- 点击图标打开配置面板\n- 确认扩展状态显示正常\n\n## ⚙️ 配置说明\n\n### Native Server 连接\n\n1. 确保 Native Server 正在运行（默认端口 12306）\n2. 在扩展 popup 中输入正确的端口号\n3. 点击\"连接\"按钮测试连接\n\n## 🔧 故障排除\n\n### 常见问题\n\n1. **扩展无法加载**\n\n   - 确保已开启开发者模式\n   - 检查文件夹结构是否完整\n\n2. **无法连接 Native Server**\n\n   - 确认 Native Server 正在运行\n   - 检查端口号是否正确\n   - 查看浏览器控制台错误信息\n\n3. **功能异常**\n   - 刷新页面重试\n   - 重启浏览器\n   - 重新加载扩展\n\n## 📞 技术支持\n\n遇到问题请：\n\n1. 查看浏览器控制台错误信息\n2. 在 GitHub Issues 中搜索相似问题\n3. 提交新的 Issue 并附上详细信息\n\n## ⚠️ 安全提醒\n\n- 此扩展具有较高权限，请确保从可信来源下载\n"
  }
]